From e309713a6b2eee55663fae7d8452802bfa62e365 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Mon, 15 Dec 2025 10:56:29 +0100 Subject: [PATCH] Refcator for better code quality --- PRD-graphiti-multi-provider.md | 1103 ------ PRD-per-spec-worktree-architecture.md | 2070 ---------- PRD-smart-context-system.md | 3406 ----------------- PRD_INTELLIGENT_VALIDATION_SYSTEM.md | 2746 ------------- RECOVERY_AND_RESUMPTION.md | 268 -- auto-claude-ui/.gitignore | 4 + .../integration/subprocess-spawn.test.ts | 28 +- .../src/main/__tests__/ipc-handlers.test.ts | 51 +- .../main/__tests__/ipc-handlers.test.ts.bak | 463 +++ auto-claude-ui/src/main/agent-manager.ts | 1127 +----- .../src/main/agent-manager.ts.backup | 1101 ++++++ auto-claude-ui/src/main/agent/agent-events.ts | 182 + .../src/main/agent/agent-manager.ts | 229 ++ .../src/main/agent/agent-process.ts | 372 ++ auto-claude-ui/src/main/agent/agent-queue.ts | 447 +++ auto-claude-ui/src/main/agent/agent-state.ts | 88 + auto-claude-ui/src/main/agent/index.ts | 28 + auto-claude-ui/src/main/agent/types.ts | 60 + .../src/main/auto-claude-updater.ts | 136 +- auto-claude-ui/src/main/changelog-service.ts | 1289 +------ auto-claude-ui/src/main/changelog/README.md | 99 + .../src/main/changelog/changelog-service.ts | 479 +++ .../src/main/changelog/formatter.ts | 210 + .../src/main/changelog/generator.ts | 325 ++ .../src/main/changelog/git-integration.ts | 253 ++ auto-claude-ui/src/main/changelog/index.ts | 18 + auto-claude-ui/src/main/changelog/parser.ts | 166 + auto-claude-ui/src/main/changelog/types.ts | 30 + .../src/main/claude-profile-manager.ts | 7 +- auto-claude-ui/src/main/index.ts | 4 +- ...ipc-handlers.ts => ipc-handlers.ts.backup} | 21 +- .../src/main/ipc-handlers/README.md | 249 ++ .../ipc-handlers/agent-events-handlers.ts | 179 + .../ipc-handlers/autobuild-source-handlers.ts | 303 ++ .../main/ipc-handlers/changelog-handlers.ts | 297 ++ .../ipc-handlers/changelog-handlers.ts.bk | 264 ++ .../src/main/ipc-handlers/context-handlers.ts | 551 +++ .../src/main/ipc-handlers/env-handlers.ts | 422 ++ .../src/main/ipc-handlers/file-handlers.ts | 60 + .../src/main/ipc-handlers/github-handlers.ts | 742 ++++ .../main/ipc-handlers/ideation-handlers.ts | 605 +++ auto-claude-ui/src/main/ipc-handlers/index.ts | 109 + .../main/ipc-handlers/insights-handlers.ts | 244 ++ .../src/main/ipc-handlers/linear-handlers.ts | 525 +++ .../src/main/ipc-handlers/project-handlers.ts | 333 ++ .../src/main/ipc-handlers/roadmap-handlers.ts | 387 ++ .../sections/context-roadmap-section.txt | 877 +++++ .../sections/context_extracted.txt | 508 +++ .../sections/ideation-insights-section.txt | 1175 ++++++ .../sections/integration-section.txt | 1653 ++++++++ .../sections/roadmap_extracted.txt | 367 ++ .../ipc-handlers/sections/task-section.txt | 1497 ++++++++ .../ipc-handlers/sections/task_extracted.txt | 1496 ++++++++ .../sections/terminal-section.txt | 487 +++ .../sections/terminal_extracted.txt | 487 +++ .../main/ipc-handlers/settings-handlers.ts | 221 ++ .../src/main/ipc-handlers/task-handlers.ts | 1622 ++++++++ .../main/ipc-handlers/terminal-handlers.ts | 504 +++ auto-claude-ui/src/main/ipc-handlers/utils.ts | 32 + auto-claude-ui/src/main/ipc-setup.ts | 49 + auto-claude-ui/src/main/project-store.ts | 98 +- auto-claude-ui/src/main/terminal-manager.ts | 913 +---- auto-claude-ui/src/main/terminal/index.ts | 26 + .../src/main/terminal/output-parser.ts | 81 + .../src/main/terminal/pty-manager.ts | 113 + .../src/main/terminal/session-handler.ts | 231 ++ .../src/main/terminal/terminal-manager.ts | 630 +++ auto-claude-ui/src/main/terminal/types.ts | 64 + auto-claude-ui/src/preload/api/agent-api.ts | 656 ++++ auto-claude-ui/src/preload/api/file-api.ts | 14 + auto-claude-ui/src/preload/api/index.ts | 42 + auto-claude-ui/src/preload/api/project-api.ts | 122 + .../src/preload/api/settings-api.ts | 45 + auto-claude-ui/src/preload/api/task-api.ts | 266 ++ .../src/preload/api/terminal-api.ts | 267 ++ auto-claude-ui/src/preload/index.ts | 1139 +----- auto-claude-ui/src/renderer/App.tsx | 16 +- .../renderer/__tests__/TaskEditDialog.test.ts | 25 +- .../src/renderer/components/AppSettings.tsx | 1020 +---- .../src/renderer/components/Changelog.tsx | 1722 +-------- .../src/renderer/components/Context.tsx | 259 +- .../src/renderer/components/Ideation.tsx | 1760 +-------- .../src/renderer/components/KanbanBoard.tsx | 1 + .../renderer/components/ProjectSettings.tsx | 2 + .../renderer/components/RateLimitModal.tsx | 2 +- .../renderer/components/SDKRateLimitModal.tsx | 2 +- .../src/renderer/components/Sidebar.tsx | 2 +- .../src/renderer/components/TaskCard.tsx | 2 + .../renderer/components/TaskDetailPanel.tsx | 1770 +-------- .../src/renderer/components/Terminal.tsx | 92 +- .../src/renderer/components/TerminalGrid.tsx | 4 +- .../components/changelog/Changelog.tsx | 195 + .../components/changelog/ChangelogDetails.tsx | 596 +++ .../components/changelog/ChangelogEntry.tsx | 83 + .../components/changelog/ChangelogFilters.tsx | 416 ++ .../components/changelog/ChangelogHeader.tsx | 78 + .../components/changelog/ChangelogList.tsx | 182 + .../changelog/hooks/useChangelog.ts | 322 ++ .../renderer/components/changelog/index.ts | 8 + .../ideation/GenerationProgressScreen.tsx | 244 ++ .../renderer/components/ideation/IdeaCard.tsx | 142 + .../components/ideation/IdeaDetailPanel.tsx | 107 + .../components/ideation/IdeaSkeletonCard.tsx | 19 + .../renderer/components/ideation/Ideation.tsx | 217 ++ .../components/ideation/IdeationDialogs.tsx | 184 + .../ideation/IdeationEmptyState.tsx | 85 + .../components/ideation/IdeationFilters.tsx | 40 + .../components/ideation/IdeationHeader.tsx | 129 + .../renderer/components/ideation/TypeIcon.tsx | 33 + .../components/ideation/TypeStateIcon.tsx | 20 + .../renderer/components/ideation/constants.ts | 12 + .../details/CodeImprovementDetails.tsx | 96 + .../ideation/details/CodeQualityDetails.tsx | 177 + .../details/DocumentationGapDetails.tsx | 96 + .../PerformanceOptimizationDetails.tsx | 130 + .../details/SecurityHardeningDetails.tsx | 129 + .../ideation/details/UIUXDetails.tsx | 70 + .../components/ideation/hooks/useIdeation.ts | 183 + .../src/renderer/components/ideation/index.ts | 25 + .../components/ideation/type-guards.ts | 33 + .../project-settings/EnvironmentSettings.tsx | 164 + .../project-settings/GeneralSettings.tsx | 239 ++ .../project-settings/IntegrationSettings.tsx | 395 ++ .../project-settings/ProjectSettings.tsx | 194 + .../project-settings/SecuritySettings.tsx | 312 ++ .../hooks/useProjectSettings.ts | 393 ++ .../components/project-settings/index.ts | 7 + .../components/settings/AdvancedSettings.tsx | 236 ++ .../components/settings/AppSettings.tsx | 180 + .../components/settings/GeneralSettings.tsx | 96 + .../settings/IntegrationSettings.tsx | 595 +++ .../components/settings/SettingsSection.tsx | 24 + .../components/settings/ThemeSettings.tsx | 57 + .../components/settings/hooks/useSettings.ts | 75 + .../src/renderer/components/settings/index.ts | 12 + .../renderer/components/task-detail/README.md | 115 + .../components/task-detail/TaskActions.tsx | 165 + .../task-detail/TaskDetailPanel.tsx | 236 ++ .../components/task-detail/TaskHeader.tsx | 109 + .../components/task-detail/TaskLogs.tsx | 411 ++ .../components/task-detail/TaskMetadata.tsx | 246 ++ .../components/task-detail/TaskProgress.tsx | 122 + .../components/task-detail/TaskReview.tsx | 371 ++ .../components/task-detail/TaskSubtasks.tsx | 121 + .../components/task-detail/TaskWarnings.tsx | 90 + .../task-detail/hooks/useTaskDetail.ts | 237 ++ .../renderer/components/task-detail/index.ts | 10 + .../src/renderer/lib/browser-mock.ts | 225 +- .../src/renderer/stores/task-store.ts | 5 + auto-claude-ui/src/shared/types.ts | 1838 +-------- auto-claude-ui/src/shared/types/agent.ts | 109 + auto-claude-ui/src/shared/types/changelog.ts | 198 + auto-claude-ui/src/shared/types/common.ts | 10 + auto-claude-ui/src/shared/types/index.ts | 20 + auto-claude-ui/src/shared/types/insights.ts | 218 ++ .../src/shared/types/integrations.ts | 145 + auto-claude-ui/src/shared/types/ipc.ts | 429 +++ auto-claude-ui/src/shared/types/project.ts | 279 ++ auto-claude-ui/src/shared/types/roadmap.ts | 106 + auto-claude-ui/src/shared/types/settings.ts | 56 + auto-claude-ui/src/shared/types/task.ts | 340 ++ auto-claude-ui/src/shared/types/terminal.ts | 123 + auto-claude/VERSION | 1 - auto-claude/agent.py | 1473 +------ auto-claude/agents/README.md | 152 + auto-claude/agents/__init__.py | 71 + auto-claude/agents/base.py | 17 + auto-claude/agents/coder.py | 446 +++ auto-claude/agents/memory.py | 350 ++ auto-claude/agents/planner.py | 168 + auto-claude/agents/session.py | 446 +++ auto-claude/agents/test_refactoring.py | 150 + auto-claude/agents/utils.py | 117 + auto-claude/analyzer.py | 2373 +----------- auto-claude/analyzers/__init__.py | 90 + auto-claude/analyzers/base.py | 110 + auto-claude/analyzers/context_analyzer.py | 657 ++++ auto-claude/analyzers/database_detector.py | 294 ++ auto-claude/analyzers/framework_analyzer.py | 299 ++ auto-claude/analyzers/port_detector.py | 301 ++ .../analyzers/project_analyzer_module.py | 263 ++ auto-claude/analyzers/route_detector.py | 336 ++ auto-claude/analyzers/service_analyzer.py | 280 ++ auto-claude/cli/__init__.py | 18 + auto-claude/cli/build_commands.py | 759 ++++ auto-claude/cli/main.py | 354 ++ auto-claude/cli/qa_commands.py | 115 + auto-claude/cli/spec_commands.py | 132 + auto-claude/cli/utils.py | 201 + auto-claude/cli/workspace_commands.py | 109 + auto-claude/debug.py | 15 + auto-claude/graphiti/__init__.py | 40 + auto-claude/graphiti/client.py | 155 + auto-claude/graphiti/graphiti.py | 365 ++ auto-claude/graphiti/queries.py | 421 ++ auto-claude/graphiti/schema.py | 27 + auto-claude/graphiti/search.py | 193 + auto-claude/graphiti_memory.py | 984 +---- auto-claude/ideation/__init__.py | 28 + auto-claude/ideation/analyzer.py | 149 + auto-claude/ideation/formatter.py | 131 + auto-claude/ideation/generator.py | 209 + auto-claude/ideation/prioritizer.py | 90 + auto-claude/ideation/runner.py | 577 +++ auto-claude/ideation/types.py | 35 + auto-claude/ideation_runner.py | 1011 +---- auto-claude/ideation_runner_old.py.bak | 1122 ++++++ auto-claude/implementation_plan.py | 15 +- auto-claude/project/__init__.py | 110 + auto-claude/project/analyzer.py | 344 ++ auto-claude/project/command_registry.py | 549 +++ auto-claude/project/config_parser.py | 65 + auto-claude/project/framework_detector.py | 236 ++ auto-claude/project/models.py | 94 + auto-claude/project/stack_detector.py | 323 ++ auto-claude/project/structure_analyzer.py | 120 + auto-claude/project_analyzer.py | 1601 +------- auto-claude/qa/__init__.py | 108 + auto-claude/qa/criteria.py | 176 + auto-claude/qa/fixer.py | 180 + auto-claude/qa/loop.py | 276 ++ auto-claude/qa/report.py | 521 +++ auto-claude/qa/reviewer.py | 180 + auto-claude/qa_loop.py | 1278 +------ auto-claude/review.py | 945 +---- auto-claude/review/__init__.py | 80 + auto-claude/review/diff_analyzer.py | 122 + auto-claude/review/formatters.py | 301 ++ auto-claude/review/reviewer.py | 335 ++ auto-claude/review/state.py | 228 ++ auto-claude/run.py | 1300 +------ auto-claude/security.py | 942 +---- auto-claude/security/__init__.py | 107 + auto-claude/security/hooks.py | 145 + auto-claude/security/parser.py | 111 + auto-claude/security/profile.py | 60 + auto-claude/security/validator.py | 640 ++++ auto-claude/spec/__init__.py | 51 + auto-claude/spec/complexity.py | 370 ++ auto-claude/spec/context.py | 127 + auto-claude/spec/discovery.py | 78 + auto-claude/spec/phases.py | 640 ++++ auto-claude/spec/pipeline.py | 615 +++ auto-claude/spec/requirements.py | 172 + auto-claude/spec/validator.py | 56 + auto-claude/spec/writer.py | 72 + auto-claude/spec_runner.py | 1957 +--------- auto-claude/test_fixed.json | 171 + build_all_modules.py | 187 + build_modules.py | 96 + create_handler_modules.py | 61 + extract_handlers.py | 66 + split_handlers.sh | 25 + test_project_index.json | 12 - 254 files changed, 53924 insertions(+), 35763 deletions(-) delete mode 100644 PRD-graphiti-multi-provider.md delete mode 100644 PRD-per-spec-worktree-architecture.md delete mode 100644 PRD-smart-context-system.md delete mode 100644 PRD_INTELLIGENT_VALIDATION_SYSTEM.md delete mode 100644 RECOVERY_AND_RESUMPTION.md create mode 100644 auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts.bak create mode 100644 auto-claude-ui/src/main/agent-manager.ts.backup create mode 100644 auto-claude-ui/src/main/agent/agent-events.ts create mode 100644 auto-claude-ui/src/main/agent/agent-manager.ts create mode 100644 auto-claude-ui/src/main/agent/agent-process.ts create mode 100644 auto-claude-ui/src/main/agent/agent-queue.ts create mode 100644 auto-claude-ui/src/main/agent/agent-state.ts create mode 100644 auto-claude-ui/src/main/agent/index.ts create mode 100644 auto-claude-ui/src/main/agent/types.ts create mode 100644 auto-claude-ui/src/main/changelog/README.md create mode 100644 auto-claude-ui/src/main/changelog/changelog-service.ts create mode 100644 auto-claude-ui/src/main/changelog/formatter.ts create mode 100644 auto-claude-ui/src/main/changelog/generator.ts create mode 100644 auto-claude-ui/src/main/changelog/git-integration.ts create mode 100644 auto-claude-ui/src/main/changelog/index.ts create mode 100644 auto-claude-ui/src/main/changelog/parser.ts create mode 100644 auto-claude-ui/src/main/changelog/types.ts rename auto-claude-ui/src/main/{ipc-handlers.ts => ipc-handlers.ts.backup} (99%) create mode 100644 auto-claude-ui/src/main/ipc-handlers/README.md create mode 100644 auto-claude-ui/src/main/ipc-handlers/agent-events-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts.bk create mode 100644 auto-claude-ui/src/main/ipc-handlers/context-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/env-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/file-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/github-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/ideation-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/index.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/insights-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/linear-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/project-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/roadmap-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/context-roadmap-section.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/context_extracted.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/ideation-insights-section.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/integration-section.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/roadmap_extracted.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/task-section.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/task_extracted.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/terminal-section.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/sections/terminal_extracted.txt create mode 100644 auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/task-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts create mode 100644 auto-claude-ui/src/main/ipc-handlers/utils.ts create mode 100644 auto-claude-ui/src/main/ipc-setup.ts create mode 100644 auto-claude-ui/src/main/terminal/index.ts create mode 100644 auto-claude-ui/src/main/terminal/output-parser.ts create mode 100644 auto-claude-ui/src/main/terminal/pty-manager.ts create mode 100644 auto-claude-ui/src/main/terminal/session-handler.ts create mode 100644 auto-claude-ui/src/main/terminal/terminal-manager.ts create mode 100644 auto-claude-ui/src/main/terminal/types.ts create mode 100644 auto-claude-ui/src/preload/api/agent-api.ts create mode 100644 auto-claude-ui/src/preload/api/file-api.ts create mode 100644 auto-claude-ui/src/preload/api/index.ts create mode 100644 auto-claude-ui/src/preload/api/project-api.ts create mode 100644 auto-claude-ui/src/preload/api/settings-api.ts create mode 100644 auto-claude-ui/src/preload/api/task-api.ts create mode 100644 auto-claude-ui/src/preload/api/terminal-api.ts create mode 100644 auto-claude-ui/src/renderer/components/changelog/Changelog.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/ChangelogDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/ChangelogEntry.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/ChangelogFilters.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/ChangelogHeader.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/ChangelogList.tsx create mode 100644 auto-claude-ui/src/renderer/components/changelog/hooks/useChangelog.ts create mode 100644 auto-claude-ui/src/renderer/components/changelog/index.ts create mode 100644 auto-claude-ui/src/renderer/components/ideation/GenerationProgressScreen.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeaCard.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeaDetailPanel.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeaSkeletonCard.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/Ideation.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeationDialogs.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeationEmptyState.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeationFilters.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/IdeationHeader.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/TypeIcon.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/TypeStateIcon.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/constants.ts create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/CodeImprovementDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/CodeQualityDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/DocumentationGapDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/PerformanceOptimizationDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/SecurityHardeningDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/details/UIUXDetails.tsx create mode 100644 auto-claude-ui/src/renderer/components/ideation/hooks/useIdeation.ts create mode 100644 auto-claude-ui/src/renderer/components/ideation/index.ts create mode 100644 auto-claude-ui/src/renderer/components/ideation/type-guards.ts create mode 100644 auto-claude-ui/src/renderer/components/project-settings/EnvironmentSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/project-settings/GeneralSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/project-settings/IntegrationSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/project-settings/ProjectSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/project-settings/SecuritySettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/project-settings/hooks/useProjectSettings.ts create mode 100644 auto-claude-ui/src/renderer/components/project-settings/index.ts create mode 100644 auto-claude-ui/src/renderer/components/settings/AdvancedSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/AppSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/GeneralSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/IntegrationSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/SettingsSection.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/ThemeSettings.tsx create mode 100644 auto-claude-ui/src/renderer/components/settings/hooks/useSettings.ts create mode 100644 auto-claude-ui/src/renderer/components/settings/index.ts create mode 100644 auto-claude-ui/src/renderer/components/task-detail/README.md create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskActions.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskDetailPanel.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskHeader.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskLogs.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskMetadata.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskProgress.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskReview.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskSubtasks.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/TaskWarnings.tsx create mode 100644 auto-claude-ui/src/renderer/components/task-detail/hooks/useTaskDetail.ts create mode 100644 auto-claude-ui/src/renderer/components/task-detail/index.ts create mode 100644 auto-claude-ui/src/shared/types/agent.ts create mode 100644 auto-claude-ui/src/shared/types/changelog.ts create mode 100644 auto-claude-ui/src/shared/types/common.ts create mode 100644 auto-claude-ui/src/shared/types/index.ts create mode 100644 auto-claude-ui/src/shared/types/insights.ts create mode 100644 auto-claude-ui/src/shared/types/integrations.ts create mode 100644 auto-claude-ui/src/shared/types/ipc.ts create mode 100644 auto-claude-ui/src/shared/types/project.ts create mode 100644 auto-claude-ui/src/shared/types/roadmap.ts create mode 100644 auto-claude-ui/src/shared/types/settings.ts create mode 100644 auto-claude-ui/src/shared/types/task.ts create mode 100644 auto-claude-ui/src/shared/types/terminal.ts delete mode 100644 auto-claude/VERSION create mode 100644 auto-claude/agents/README.md create mode 100644 auto-claude/agents/__init__.py create mode 100644 auto-claude/agents/base.py create mode 100644 auto-claude/agents/coder.py create mode 100644 auto-claude/agents/memory.py create mode 100644 auto-claude/agents/planner.py create mode 100644 auto-claude/agents/session.py create mode 100644 auto-claude/agents/test_refactoring.py create mode 100644 auto-claude/agents/utils.py create mode 100644 auto-claude/analyzers/__init__.py create mode 100644 auto-claude/analyzers/base.py create mode 100644 auto-claude/analyzers/context_analyzer.py create mode 100644 auto-claude/analyzers/database_detector.py create mode 100644 auto-claude/analyzers/framework_analyzer.py create mode 100644 auto-claude/analyzers/port_detector.py create mode 100644 auto-claude/analyzers/project_analyzer_module.py create mode 100644 auto-claude/analyzers/route_detector.py create mode 100644 auto-claude/analyzers/service_analyzer.py create mode 100644 auto-claude/cli/__init__.py create mode 100644 auto-claude/cli/build_commands.py create mode 100644 auto-claude/cli/main.py create mode 100644 auto-claude/cli/qa_commands.py create mode 100644 auto-claude/cli/spec_commands.py create mode 100644 auto-claude/cli/utils.py create mode 100644 auto-claude/cli/workspace_commands.py create mode 100644 auto-claude/graphiti/__init__.py create mode 100644 auto-claude/graphiti/client.py create mode 100644 auto-claude/graphiti/graphiti.py create mode 100644 auto-claude/graphiti/queries.py create mode 100644 auto-claude/graphiti/schema.py create mode 100644 auto-claude/graphiti/search.py create mode 100644 auto-claude/ideation/__init__.py create mode 100644 auto-claude/ideation/analyzer.py create mode 100644 auto-claude/ideation/formatter.py create mode 100644 auto-claude/ideation/generator.py create mode 100644 auto-claude/ideation/prioritizer.py create mode 100644 auto-claude/ideation/runner.py create mode 100644 auto-claude/ideation/types.py create mode 100644 auto-claude/ideation_runner_old.py.bak create mode 100644 auto-claude/project/__init__.py create mode 100644 auto-claude/project/analyzer.py create mode 100644 auto-claude/project/command_registry.py create mode 100644 auto-claude/project/config_parser.py create mode 100644 auto-claude/project/framework_detector.py create mode 100644 auto-claude/project/models.py create mode 100644 auto-claude/project/stack_detector.py create mode 100644 auto-claude/project/structure_analyzer.py create mode 100644 auto-claude/qa/__init__.py create mode 100644 auto-claude/qa/criteria.py create mode 100644 auto-claude/qa/fixer.py create mode 100644 auto-claude/qa/loop.py create mode 100644 auto-claude/qa/report.py create mode 100644 auto-claude/qa/reviewer.py create mode 100644 auto-claude/review/__init__.py create mode 100644 auto-claude/review/diff_analyzer.py create mode 100644 auto-claude/review/formatters.py create mode 100644 auto-claude/review/reviewer.py create mode 100644 auto-claude/review/state.py create mode 100644 auto-claude/security/__init__.py create mode 100644 auto-claude/security/hooks.py create mode 100644 auto-claude/security/parser.py create mode 100644 auto-claude/security/profile.py create mode 100644 auto-claude/security/validator.py create mode 100644 auto-claude/spec/__init__.py create mode 100644 auto-claude/spec/complexity.py create mode 100644 auto-claude/spec/context.py create mode 100644 auto-claude/spec/discovery.py create mode 100644 auto-claude/spec/phases.py create mode 100644 auto-claude/spec/pipeline.py create mode 100644 auto-claude/spec/requirements.py create mode 100644 auto-claude/spec/validator.py create mode 100644 auto-claude/spec/writer.py create mode 100644 auto-claude/test_fixed.json create mode 100644 build_all_modules.py create mode 100644 build_modules.py create mode 100644 create_handler_modules.py create mode 100644 extract_handlers.py create mode 100755 split_handlers.sh delete mode 100644 test_project_index.json diff --git a/PRD-graphiti-multi-provider.md b/PRD-graphiti-multi-provider.md deleted file mode 100644 index 3ca50a7c..00000000 --- a/PRD-graphiti-multi-provider.md +++ /dev/null @@ -1,1103 +0,0 @@ -# PRD: Graphiti Multi-Provider Support - -> **Status:** Draft v1 -> **Author:** Claude -> **Created:** 2025-12-11 -> **Target:** Auto Claude Graphiti Memory Integration -> **Depends On:** Smart Context System PRD (for Graphiti architecture) - ---- - -## 1. Problem Statement - -### Current Limitation - -The current Graphiti integration **only supports OpenAI** for both embeddings and LLM (graph extraction): - -```python -# Current graphiti_config.py (line 93-101) -def is_valid(self) -> bool: - """ - Graphiti requires: - - GRAPHITI_ENABLED=true - - OPENAI_API_KEY set (for embeddings) # <-- ONLY OpenAI - """ - return self.enabled and bool(self.openai_api_key) -``` - -### Why This Matters - -| User Scenario | Current Support | Impact | -|---------------|-----------------|--------| -| Enterprise with Azure OpenAI only | ❌ Not supported | Can't use Graphiti | -| Privacy-conscious (local-only) | ❌ Not supported | Can't use Graphiti | -| Already using Anthropic Claude | Partial (LLM only, needs OpenAI for embeddings) | Extra API costs | -| Cost-sensitive users | ❌ Locked to OpenAI pricing | Higher costs | - -### Graphiti's Actual Capabilities - -Graphiti supports **many providers** out of the box: - -| Component | Supported Providers | -|-----------|---------------------| -| **Embeddings** | OpenAI, Azure OpenAI, Voyage AI, Gemini, Ollama (local), Sentence Transformers (local) | -| **LLM (Graph Extraction)** | OpenAI, Azure OpenAI, Anthropic, Gemini, Groq, Ollama (local) | -| **Cross-Encoder (Reranking)** | OpenAI, Gemini, Ollama (local) | - -**We're only using a fraction of what's available.** - ---- - -## 2. Goals - -### Primary Goals - -1. **Azure OpenAI Support** - Enable enterprise users with Azure-only policies -2. **Ollama Support** - Enable fully local/offline operation -3. **Anthropic LLM Support** - Use Claude for graph extraction (already using it elsewhere) - -### Secondary Goals - -4. **Voyage AI Support** - Anthropic's recommended embedding provider -5. **Provider flexibility** - Mix-and-match (e.g., Anthropic LLM + Voyage embeddings) - -### Non-Goals - -- Supporting every provider Graphiti supports (Groq, Gemini, etc.) -- Building our own embedding/LLM abstraction layer -- Changing the core memory architecture - ---- - -## 3. Provider Analysis - -### 3.1 Anthropic (LLM Only - NO Embeddings) - -> ⚠️ **IMPORTANT:** Anthropic does NOT offer embedding models. They officially recommend Voyage AI for embeddings. - -| Aspect | Details | -|--------|---------| -| **Use For** | LLM/Graph extraction only | -| **Models** | `claude-sonnet-4-5-latest`, `claude-opus-4-5-latest` | -| **Graphiti Client** | `AnthropicClient` from `graphiti_core.llm_client.anthropic_client` | -| **API Key Env** | `ANTHROPIC_API_KEY` | -| **Install** | `pip install graphiti-core[anthropic]` | - -**Why Use Anthropic for LLM?** -- Already using Claude for agents (consistency) -- Strong reasoning for entity extraction -- Avoids needing OpenAI API key just for graph extraction - -**Pairing Options:** -- Anthropic LLM + OpenAI embeddings -- Anthropic LLM + Voyage AI embeddings (Anthropic's recommendation) -- Anthropic LLM + Ollama embeddings (fully avoid OpenAI) - -### 3.2 Azure OpenAI (Full Support) - -| Aspect | Details | -|--------|---------| -| **Use For** | Both LLM and embeddings | -| **LLM Models** | Your Azure deployment names | -| **Embedding Models** | `text-embedding-3-small`, `text-embedding-3-large` | -| **Graphiti Clients** | `AzureOpenAILLMClient`, `AzureOpenAIEmbedderClient` | -| **Required Config** | Base URL, API key, deployment names | - -**Why Use Azure OpenAI?** -- Enterprise compliance requirements -- Already have Azure infrastructure -- Data residency requirements -- Existing Azure spending commitments - -### 3.3 Ollama (Fully Local) - -| Aspect | Details | -|--------|---------| -| **Use For** | Both LLM and embeddings (local) | -| **LLM Models** | `deepseek-r1:7b`, `llama3:8b`, `mistral:7b`, etc. | -| **Embedding Models** | `nomic-embed-text`, `mxbai-embed-large`, etc. | -| **Graphiti Clients** | `OpenAIGenericClient` (compatible), `OpenAIEmbedder` (compatible) | -| **Base URL** | `http://localhost:11434/v1` | - -**Why Use Ollama?** -- Zero API costs -- Complete privacy (no data leaves machine) -- Works offline -- Good for development/testing - -**Tradeoffs:** -- Requires local GPU for good performance -- Embedding quality may be lower than OpenAI/Voyage -- LLM quality depends on model size - -### 3.4 Voyage AI (Embeddings Only) - -| Aspect | Details | -|--------|---------| -| **Use For** | Embeddings only | -| **Models** | `voyage-3`, `voyage-3-lite`, `voyage-code-3`, `voyage-finance-2` | -| **Graphiti Client** | `VoyageEmbedder` from `graphiti_core.embedder.voyage` | -| **API Key Env** | `VOYAGE_API_KEY` | - -**Why Use Voyage AI?** -- Anthropic's official recommendation -- Specialized models (code, finance, law) -- High quality embeddings -- Reasonable pricing - ---- - -## 4. Proposed Configuration Schema - -### 4.1 Environment Variables - -```bash -# === Provider Selection === -GRAPHITI_ENABLED=true - -# LLM Provider: "openai" | "azure_openai" | "anthropic" | "ollama" -GRAPHITI_LLM_PROVIDER=openai - -# Embedder Provider: "openai" | "azure_openai" | "voyage" | "ollama" -GRAPHITI_EMBEDDER_PROVIDER=openai - -# === OpenAI (default) === -OPENAI_API_KEY=sk-... - -# === Anthropic (LLM only) === -ANTHROPIC_API_KEY=sk-ant-... -GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest - -# === Azure OpenAI === -AZURE_OPENAI_API_KEY=... -AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/openai/v1/ -AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4o # Your deployment name -AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small - -# === Ollama (local) === -OLLAMA_BASE_URL=http://localhost:11434/v1 -OLLAMA_LLM_MODEL=deepseek-r1:7b -OLLAMA_EMBEDDING_MODEL=nomic-embed-text -OLLAMA_EMBEDDING_DIM=768 - -# === Voyage AI (embeddings only) === -VOYAGE_API_KEY=pa-... -VOYAGE_EMBEDDING_MODEL=voyage-3 -``` - -### 4.2 Configuration Combinations - -| Combo | LLM Provider | Embedder Provider | Use Case | -|-------|--------------|-------------------|----------| -| **Default** | OpenAI | OpenAI | Simple setup, good quality | -| **Enterprise** | Azure OpenAI | Azure OpenAI | Corporate compliance | -| **Claude Stack** | Anthropic | Voyage | Consistency with agent stack | -| **Fully Local** | Ollama | Ollama | Privacy, offline, zero cost | -| **Hybrid Local** | Ollama | OpenAI | Local LLM, quality embeddings | -| **Cost Optimized** | Anthropic | Ollama | Cloud LLM, free embeddings | - ---- - -## 5. Technical Design - -### 5.1 Updated GraphitiConfig - -```python -@dataclass -class GraphitiConfig: - """Configuration for Graphiti memory integration.""" - enabled: bool = False - - # Provider selection - llm_provider: str = "openai" # openai, azure_openai, anthropic, ollama - embedder_provider: str = "openai" # openai, azure_openai, voyage, ollama - - # OpenAI - openai_api_key: str = "" - - # Anthropic - anthropic_api_key: str = "" - anthropic_model: str = "claude-sonnet-4-5-latest" - - # Azure OpenAI - azure_api_key: str = "" - azure_base_url: str = "" - azure_llm_deployment: str = "" - azure_embedding_deployment: str = "" - - # Ollama - ollama_base_url: str = "http://localhost:11434/v1" - ollama_llm_model: str = "deepseek-r1:7b" - ollama_embedding_model: str = "nomic-embed-text" - ollama_embedding_dim: int = 768 - - # Voyage - voyage_api_key: str = "" - voyage_embedding_model: str = "voyage-3" - - # FalkorDB (unchanged) - falkordb_host: str = "localhost" - falkordb_port: int = 6380 - falkordb_password: str = "" - database: str = "auto_build_memory" - - def is_valid(self) -> bool: - """Check if config has required values for selected providers.""" - # Check LLM provider - llm_valid = self._validate_llm_provider() - # Check embedder provider - embedder_valid = self._validate_embedder_provider() - - return self.enabled and llm_valid and embedder_valid - - def _validate_llm_provider(self) -> bool: - if self.llm_provider == "openai": - return bool(self.openai_api_key) - elif self.llm_provider == "anthropic": - return bool(self.anthropic_api_key) - elif self.llm_provider == "azure_openai": - return bool(self.azure_api_key and self.azure_base_url and self.azure_llm_deployment) - elif self.llm_provider == "ollama": - return bool(self.ollama_base_url and self.ollama_llm_model) - return False - - def _validate_embedder_provider(self) -> bool: - if self.embedder_provider == "openai": - return bool(self.openai_api_key) - elif self.embedder_provider == "voyage": - return bool(self.voyage_api_key) - elif self.embedder_provider == "azure_openai": - return bool(self.azure_api_key and self.azure_base_url and self.azure_embedding_deployment) - elif self.embedder_provider == "ollama": - return bool(self.ollama_base_url and self.ollama_embedding_model) - return False -``` - -### 5.2 Provider Factory - -```python -# New file: auto-claude/graphiti_providers.py - -from graphiti_core.llm_client.config import LLMConfig - -def create_llm_client(config: GraphitiConfig): - """Create LLM client based on provider selection.""" - - if config.llm_provider == "openai": - from graphiti_core.llm_client.openai_client import OpenAIClient - return OpenAIClient( - config=LLMConfig(api_key=config.openai_api_key) - ) - - elif config.llm_provider == "anthropic": - from graphiti_core.llm_client.anthropic_client import AnthropicClient - return AnthropicClient( - config=LLMConfig( - api_key=config.anthropic_api_key, - model=config.anthropic_model - ) - ) - - elif config.llm_provider == "azure_openai": - from openai import AsyncOpenAI - from graphiti_core.llm_client.azure_openai_client import AzureOpenAILLMClient - - azure_client = AsyncOpenAI( - base_url=config.azure_base_url, - api_key=config.azure_api_key, - ) - return AzureOpenAILLMClient( - azure_client=azure_client, - config=LLMConfig(model=config.azure_llm_deployment) - ) - - elif config.llm_provider == "ollama": - from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient - return OpenAIGenericClient( - config=LLMConfig( - api_key="ollama", # Dummy key required - model=config.ollama_llm_model, - base_url=config.ollama_base_url, - ) - ) - - raise ValueError(f"Unknown LLM provider: {config.llm_provider}") - - -def create_embedder(config: GraphitiConfig): - """Create embedder based on provider selection.""" - - if config.embedder_provider == "openai": - from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig - return OpenAIEmbedder( - config=OpenAIEmbedderConfig(api_key=config.openai_api_key) - ) - - elif config.embedder_provider == "voyage": - from graphiti_core.embedder.voyage import VoyageEmbedder, VoyageAIConfig - return VoyageEmbedder( - config=VoyageAIConfig( - api_key=config.voyage_api_key, - embedding_model=config.voyage_embedding_model - ) - ) - - elif config.embedder_provider == "azure_openai": - from openai import AsyncOpenAI - from graphiti_core.embedder.azure_openai import AzureOpenAIEmbedderClient - - azure_client = AsyncOpenAI( - base_url=config.azure_base_url, - api_key=config.azure_api_key, - ) - return AzureOpenAIEmbedderClient( - azure_client=azure_client, - model=config.azure_embedding_deployment - ) - - elif config.embedder_provider == "ollama": - from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig - return OpenAIEmbedder( - config=OpenAIEmbedderConfig( - api_key="ollama", # Dummy key - embedding_model=config.ollama_embedding_model, - embedding_dim=config.ollama_embedding_dim, - base_url=config.ollama_base_url, - ) - ) - - raise ValueError(f"Unknown embedder provider: {config.embedder_provider}") -``` - -### 5.3 Updated GraphitiMemory Initialization - -```python -# Updated graphiti_memory.py initialize() method - -async def initialize(self) -> bool: - if self._initialized: - return True - - if not self._available: - logger.info("Graphiti not available - skipping initialization") - return False - - try: - from graphiti_core import Graphiti - from graphiti_core.driver.falkordb_driver import FalkorDriver - from graphiti_providers import create_llm_client, create_embedder - - # Initialize FalkorDB driver - self._driver = FalkorDriver( - host=self.config.falkordb_host, - port=self.config.falkordb_port, - password=self.config.falkordb_password or None, - database=self.config.database, - ) - - # Create provider-specific clients - llm_client = create_llm_client(self.config) - embedder = create_embedder(self.config) - - # Initialize Graphiti with configured providers - self._graphiti = Graphiti( - graph_driver=self._driver, - llm_client=llm_client, - embedder=embedder, - ) - - # Build indices (first time only) - if not self.state or not self.state.indices_built: - logger.info("Building Graphiti indices and constraints...") - await self._graphiti.build_indices_and_constraints() - # ... rest unchanged - - self._initialized = True - logger.info( - f"Graphiti initialized for spec: {self.group_id} " - f"(LLM: {self.config.llm_provider}, Embedder: {self.config.embedder_provider})" - ) - return True - - except ImportError as e: - # Handle missing provider packages - logger.warning(f"Graphiti provider packages not installed: {e}") - self._available = False - return False - # ... rest unchanged -``` - ---- - -## 6. Implementation Plan - -### Phase 1: Core Infrastructure - -| Task | File | Effort | -|------|------|--------| -| Update `GraphitiConfig` with provider fields | `graphiti_config.py` | Medium | -| Update `from_env()` to read new env vars | `graphiti_config.py` | Medium | -| Update `is_valid()` for provider validation | `graphiti_config.py` | Small | -| Create `graphiti_providers.py` with factories | New file | Medium | -| Update `initialize()` to use factories | `graphiti_memory.py` | Small | - -### Phase 2: Provider Implementations - -| Task | File | Effort | -|------|------|--------| -| Implement OpenAI provider (refactor existing) | `graphiti_providers.py` | Small | -| Implement Anthropic LLM provider | `graphiti_providers.py` | Small | -| Implement Azure OpenAI providers | `graphiti_providers.py` | Medium | -| Implement Ollama providers | `graphiti_providers.py` | Medium | -| Implement Voyage embedder | `graphiti_providers.py` | Small | - -### Phase 3: Testing & Documentation - -| Task | File | Effort | -|------|------|--------| -| Add provider unit tests | `tests/test_graphiti_providers.py` | Medium | -| Add integration tests per provider | `tests/test_graphiti_integration.py` | Large | -| Update `.env.example` with all options | `.env.example` | Small | -| Update CLAUDE.md with provider docs | `CLAUDE.md` | Small | -| Add troubleshooting guide | `docs/graphiti-providers.md` | Medium | - -### Phase 4: UI Integration (Required) - -| Task | File | Effort | -|------|------|--------| -| Add Graphiti section to AppSettings | `AppSettings.tsx` | Medium | -| Add provider selection dropdowns | `AppSettings.tsx` | Medium | -| Add conditional API key fields | `AppSettings.tsx` | Medium | -| Add Ollama connection test button | `AppSettings.tsx` | Small | -| Update AppSettings types | `shared/types.ts` | Small | -| Update settings store | `settings-store.ts` | Small | -| Add IPC handlers for provider validation | `ipc-handlers.ts` | Medium | - ---- - -## 6.5 UI Design: Application Settings - -The Graphiti provider configuration lives in **Application Settings** (`AppSettings.tsx`), NOT per-project settings. This ensures consistent memory configuration across all projects. - -### 6.5.1 Location in UI - -``` -Application Settings Dialog -├── Appearance (existing) -│ └── Theme -├── Default Agent Settings (existing) -│ ├── Default Model -│ └── Default Parallelism -├── Paths (existing) -│ ├── Python Path -│ └── Auto Claude Path -├── Global API Keys (existing - EXTEND) -│ ├── Claude OAuth Token -│ ├── OpenAI API Key -│ └── [NEW] Additional provider keys (conditional) -├── [NEW] Graphiti Memory Settings ◄────────────────── -│ ├── Enable Graphiti Memory (toggle) -│ ├── LLM Provider (dropdown) -│ ├── Embedder Provider (dropdown) -│ ├── Provider-specific configuration (conditional) -│ └── Connection test button -├── Auto Claude Framework (existing) -└── Notifications (existing) -``` - -### 6.5.2 New Section: Graphiti Memory Settings - -```tsx -{/* Graphiti Memory Settings - NEW SECTION */} -
-
- -

Graphiti Memory

-
- - {/* Info banner */} -
-
- -

- Graphiti provides semantic memory for cross-session learning. - Requires FalkorDB running locally (Docker). -

-
-
- - {/* Enable toggle */} -
-
- -

- Enables semantic search across sessions (requires Docker) -

-
- - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, enabled: checked } - }) - } - /> -
- - {/* Provider settings - only shown when enabled */} - {settings.graphiti.enabled && ( -
- - {/* LLM Provider */} -
- - -
- - {/* Embedder Provider */} -
- - -

- Note: Anthropic does not provide embeddings. Use Voyage AI (recommended) or another provider. -

-
- - {/* Conditional: Anthropic settings */} - {settings.graphiti.llmProvider === 'anthropic' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, anthropicApiKey: e.target.value } - }) - } - /> -
- )} - - {/* Conditional: Voyage settings */} - {settings.graphiti.embedderProvider === 'voyage' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, voyageApiKey: e.target.value } - }) - } - /> -
- )} - - {/* Conditional: Azure OpenAI settings */} - {(settings.graphiti.llmProvider === 'azure_openai' || - settings.graphiti.embedderProvider === 'azure_openai') && ( -
-

Azure OpenAI Configuration

-
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, azureBaseUrl: e.target.value } - }) - } - /> -
-
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, azureApiKey: e.target.value } - }) - } - /> -
- {settings.graphiti.llmProvider === 'azure_openai' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, azureLlmDeployment: e.target.value } - }) - } - /> -
- )} - {settings.graphiti.embedderProvider === 'azure_openai' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, azureEmbeddingDeployment: e.target.value } - }) - } - /> -
- )} -
- )} - - {/* Conditional: Ollama settings */} - {(settings.graphiti.llmProvider === 'ollama' || - settings.graphiti.embedderProvider === 'ollama') && ( -
-

Ollama Configuration (Local)

-
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, ollamaBaseUrl: e.target.value } - }) - } - /> -
- {settings.graphiti.llmProvider === 'ollama' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, ollamaLlmModel: e.target.value } - }) - } - /> -
- )} - {settings.graphiti.embedderProvider === 'ollama' && ( -
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, ollamaEmbeddingModel: e.target.value } - }) - } - /> -
- )} - -
- )} - - {/* FalkorDB settings */} -
-

FalkorDB Connection

-
-
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, falkordbHost: e.target.value } - }) - } - /> -
-
- - - setSettings({ - ...settings, - graphiti: { ...settings.graphiti, falkordbPort: parseInt(e.target.value) } - }) - } - /> -
-
- -
-
- )} -
-``` - -### 6.5.3 Types Update (`shared/types.ts`) - -```typescript -// Add to AppSettings interface -export interface AppSettings { - // ... existing fields ... - - graphiti: GraphitiUISettings; -} - -export interface GraphitiUISettings { - enabled: boolean; - - // Provider selection - llmProvider: 'openai' | 'anthropic' | 'azure_openai' | 'ollama'; - embedderProvider: 'openai' | 'azure_openai' | 'voyage' | 'ollama'; - - // Provider-specific API keys - anthropicApiKey?: string; - voyageApiKey?: string; - - // Azure OpenAI - azureBaseUrl?: string; - azureApiKey?: string; - azureLlmDeployment?: string; - azureEmbeddingDeployment?: string; - - // Ollama - ollamaBaseUrl?: string; - ollamaLlmModel?: string; - ollamaEmbeddingModel?: string; - - // FalkorDB - falkordbHost?: string; - falkordbPort?: number; - falkordbPassword?: string; -} -``` - -### 6.5.4 Settings to Environment Variables - -When saving settings, the UI writes to the appropriate environment variables: - -```typescript -// In ipc-handlers.ts or settings-store.ts - -function graphitiSettingsToEnv(settings: GraphitiUISettings): Record { - const env: Record = { - GRAPHITI_ENABLED: settings.enabled ? 'true' : 'false', - GRAPHITI_LLM_PROVIDER: settings.llmProvider, - GRAPHITI_EMBEDDER_PROVIDER: settings.embedderProvider, - GRAPHITI_FALKORDB_HOST: settings.falkordbHost || 'localhost', - GRAPHITI_FALKORDB_PORT: String(settings.falkordbPort || 6380), - }; - - // Anthropic - if (settings.anthropicApiKey) { - env.ANTHROPIC_API_KEY = settings.anthropicApiKey; - } - - // Voyage - if (settings.voyageApiKey) { - env.VOYAGE_API_KEY = settings.voyageApiKey; - } - - // Azure OpenAI - if (settings.azureBaseUrl) { - env.AZURE_OPENAI_BASE_URL = settings.azureBaseUrl; - } - if (settings.azureApiKey) { - env.AZURE_OPENAI_API_KEY = settings.azureApiKey; - } - if (settings.azureLlmDeployment) { - env.AZURE_OPENAI_LLM_DEPLOYMENT = settings.azureLlmDeployment; - } - if (settings.azureEmbeddingDeployment) { - env.AZURE_OPENAI_EMBEDDING_DEPLOYMENT = settings.azureEmbeddingDeployment; - } - - // Ollama - if (settings.ollamaBaseUrl) { - env.OLLAMA_BASE_URL = settings.ollamaBaseUrl; - } - if (settings.ollamaLlmModel) { - env.OLLAMA_LLM_MODEL = settings.ollamaLlmModel; - } - if (settings.ollamaEmbeddingModel) { - env.OLLAMA_EMBEDDING_MODEL = settings.ollamaEmbeddingModel; - } - - // FalkorDB password - if (settings.falkordbPassword) { - env.GRAPHITI_FALKORDB_PASSWORD = settings.falkordbPassword; - } - - return env; -} -``` - -### 6.5.5 Validation Rules - -| Provider Combo | Required Fields | -|----------------|-----------------| -| LLM: OpenAI | `globalOpenAIApiKey` (existing) | -| LLM: Anthropic | `anthropicApiKey` | -| LLM: Azure | `azureBaseUrl`, `azureApiKey`, `azureLlmDeployment` | -| LLM: Ollama | `ollamaBaseUrl`, `ollamaLlmModel` | -| Embedder: OpenAI | `globalOpenAIApiKey` (existing) | -| Embedder: Voyage | `voyageApiKey` | -| Embedder: Azure | `azureBaseUrl`, `azureApiKey`, `azureEmbeddingDeployment` | -| Embedder: Ollama | `ollamaBaseUrl`, `ollamaEmbeddingModel` | - -### 6.5.6 UI States - -| State | Appearance | -|-------|------------| -| Graphiti disabled | Only enable toggle shown | -| Graphiti enabled, no FalkorDB | Warning banner: "FalkorDB not running" | -| Graphiti enabled, connected | Success indicator | -| Provider misconfigured | Red border on missing fields, save disabled | -| Testing connection | Spinner on test button | -| Connection test passed | Green check, "Connected" message | -| Connection test failed | Red X, error message | - ---- - -## 7. Files to Modify - -### Backend (auto-claude/) - -| File | Changes | -|------|---------| -| `graphiti_config.py` | Add provider fields, validation logic, env var parsing | -| `graphiti_memory.py` | Update `initialize()` to use provider factories | -| `.env.example` | Add all provider environment variables | -| `CLAUDE.md` | Document provider options | -| `requirements.txt` | Add optional provider extras | - -### Frontend (auto-claude-ui/) - -| File | Changes | -|------|---------| -| `src/renderer/components/AppSettings.tsx` | Add Graphiti Memory section with provider dropdowns, conditional fields, test buttons | -| `src/shared/types.ts` | Add `GraphitiUISettings` interface, extend `AppSettings` | -| `src/renderer/stores/settings-store.ts` | Add graphiti settings handling, env conversion | -| `src/main/ipc-handlers.ts` | Add handlers for connection tests (`testOllamaConnection`, `testFalkorDBConnection`) | -| `src/preload/index.ts` | Expose new IPC methods | - -### New Files - -| File | Purpose | -|------|---------| -| `auto-claude/graphiti_providers.py` | Provider factory functions | -| `tests/test_graphiti_providers.py` | Unit tests for provider creation | -| `docs/graphiti-providers.md` | Provider setup documentation | - ---- - -## 8. Dependencies - -### Required Packages by Provider - -```txt -# Base (always needed) -graphiti-core[falkordb] - -# Optional - install based on provider selection -graphiti-core[anthropic] # For Anthropic LLM -# Note: Azure, Ollama, Voyage use base graphiti-core -``` - -### Ollama Setup (Local) - -```bash -# Install Ollama -curl -fsSL https://ollama.com/install.sh | sh - -# Pull required models -ollama pull deepseek-r1:7b # LLM -ollama pull nomic-embed-text # Embeddings - -# Verify running -curl http://localhost:11434/v1/models -``` - ---- - -## 9. Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Provider API differences | Medium | Medium | Graphiti abstracts this; test each provider | -| Ollama quality issues | Medium | Low | Document as "development/testing" option | -| Azure config complexity | Low | Medium | Provide clear examples, validation errors | -| Missing provider packages | Medium | Low | Clear error messages, graceful degradation | -| Embedding dimension mismatches | Low | High | Validate dimensions at init time | - ---- - -## 10. Success Criteria - -### MVP - -- [ ] Azure OpenAI works for both LLM and embeddings -- [ ] Ollama works for both LLM and embeddings -- [ ] Anthropic works for LLM (with OpenAI/Voyage embeddings) -- [ ] Provider selection via environment variables -- [ ] Clear error messages for misconfiguration - -### Complete - -- [ ] All MVP criteria -- [ ] Voyage AI embeddings supported -- [ ] All providers have unit tests -- [ ] Integration tests pass for each provider combo -- [ ] Documentation complete -- [ ] UI supports provider selection (optional) - ---- - -## 11. Appendix - -### A. Provider Comparison - -| Provider | LLM | Embeddings | Cost | Quality | Privacy | -|----------|-----|------------|------|---------|---------| -| OpenAI | ✅ | ✅ | $$ | High | Cloud | -| Azure OpenAI | ✅ | ✅ | $$ | High | Enterprise | -| Anthropic | ✅ | ❌ | $$ | High | Cloud | -| Voyage AI | ❌ | ✅ | $ | High | Cloud | -| Ollama | ✅ | ✅ | Free | Medium | Local | - -### B. Recommended Combinations - -| User Type | LLM | Embedder | Rationale | -|-----------|-----|----------|-----------| -| **Default** | OpenAI | OpenAI | Simple, good quality | -| **Enterprise** | Azure | Azure | Compliance, data residency | -| **Claude-first** | Anthropic | Voyage | Anthropic's recommendation | -| **Privacy-focused** | Ollama | Ollama | Fully local | -| **Budget dev** | Ollama | Ollama | Zero API costs | - -### C. Embedding Dimensions by Model - -| Provider | Model | Dimensions | -|----------|-------|------------| -| OpenAI | text-embedding-3-small | 1536 | -| OpenAI | text-embedding-3-large | 3072 | -| Voyage | voyage-3 | 1024 | -| Voyage | voyage-3-lite | 512 | -| Ollama | nomic-embed-text | 768 | -| Ollama | mxbai-embed-large | 1024 | - -### D. Error Messages - -```python -ERROR_MESSAGES = { - "anthropic_no_embeddings": ( - "Anthropic does not provide embedding models. " - "Please use a different embedder provider (openai, voyage, azure_openai, ollama). " - "Anthropic recommends Voyage AI: https://docs.anthropic.com/en/docs/build-with-claude/embeddings" - ), - "missing_api_key": ( - "Missing API key for {provider}. " - "Set {env_var} in your environment or .env file." - ), - "ollama_not_running": ( - "Cannot connect to Ollama at {url}. " - "Ensure Ollama is running: ollama serve" - ), - "azure_missing_deployment": ( - "Azure OpenAI requires deployment names. " - "Set AZURE_OPENAI_LLM_DEPLOYMENT and AZURE_OPENAI_EMBEDDING_DEPLOYMENT." - ), -} -``` - - diff --git a/PRD-per-spec-worktree-architecture.md b/PRD-per-spec-worktree-architecture.md deleted file mode 100644 index 37ea9c49..00000000 --- a/PRD-per-spec-worktree-architecture.md +++ /dev/null @@ -1,2070 +0,0 @@ -# PRD: Per-Spec Worktree Architecture - -## Executive Summary - -Redesign the worktree system to support **one worktree per spec**, enabling: -- Multiple specs can be worked on simultaneously -- Each spec has its own isolated branch that persists until merge -- Clear mapping: spec → worktree → branch -- UI can show all pending branches ready for review/merge - -## Current Problems - -### Bug #1: Worktree Path Mismatch -- **Python:** Creates `.worktrees/auto-claude/` -- **UI:** Looks for `.worktrees/auto-claude-staging/` -- **Impact:** UI never finds worktrees - -### Bug #2: Spec Name Ignored -- `get_existing_build_worktree(spec_name)` ignores the spec_name parameter -- All specs share ONE worktree -- **Impact:** Working on spec-003 would corrupt spec-002's work - -### Bug #3: Single Worktree Design -- Only one worktree exists at a time -- Can only work on one spec at a time -- **Impact:** No parallel spec development - -### Bug #4: Branches Deleted on Merge -- `merge_staging(delete_after=True)` deletes the branch -- **Impact:** Can't see which specs have work ready for review - -### Bug #5: Process Hangs on Interactive Menu (Critical) -- `finalize_workspace()` displays interactive menu after build completes -- UI spawns process with **no TTY** - cannot receive keyboard input -- Process waits indefinitely for input that will never come -- **Impact:** - - Process hangs forever → UI shows "Running" indefinitely - - Status never transitions to `human_review` - - Task stuck in `in_progress` state - -**Evidence from terminal output:** -``` -[1] ▶ Test the feature ([2] ✓ Add to my project now[3] 📄 Review what changed[4] ⏸ Decide later -``` -Process is waiting for keyboard input that will never come. - ---- - -## New Architecture - -### Directory Structure - -``` -project/ -├── .worktrees/ -│ ├── 002-implement-memory/ # Worktree for spec 002 -│ │ └── (full project copy) -│ ├── 003-fix-bug/ # Worktree for spec 003 -│ │ └── (full project copy) -│ └── 004-improve-ui/ # Worktree for spec 004 -│ └── (full project copy) -├── auto-claude/ -│ └── specs/ -│ ├── 002-implement-memory/ -│ ├── 003-fix-bug/ -│ └── 004-improve-ui/ -└── (rest of project) -``` - -### Branch Naming Convention - -``` -auto-claude/{spec-id} - -Examples: -- auto-claude/002-implement-memory -- auto-claude/003-fix-bug -- auto-claude/004-improve-ui -``` - -### Parallel Worker Branches (Temporary) - -When running in parallel mode, workers create temporary branches: -``` -worker-{id}/{chunk-id} - -Examples: -- worker-1/chunk-001 -- worker-2/chunk-002 -``` - -These branch FROM the spec's worktree branch and merge back into it, then are deleted. - -### Worktree-to-Spec Mapping - -Each worktree directory name **matches** the spec folder name: -- Spec: `auto-claude/specs/002-implement-memory/` -- Worktree: `.worktrees/002-implement-memory/` -- Branch: `auto-claude/002-implement-memory` - -This creates a **1:1:1 mapping** that's easy to reason about. - ---- - -## Data Model Changes - -### WorktreeInfo (Enhanced) - -```python -@dataclass -class WorktreeInfo: - """Information about a spec's worktree.""" - path: Path # .worktrees/{spec-name}/ - branch: str # auto-claude/{spec-name} - spec_name: str # The spec folder name (e.g., "002-implement-memory") - base_branch: str # Branch it was created from (e.g., "main") - is_active: bool = True # Whether worktree exists - - # Statistics (computed on demand) - commit_count: int = 0 - files_changed: int = 0 - additions: int = 0 - deletions: int = 0 -``` - -### WorktreeStatus (for UI) - -```typescript -interface WorktreeStatus { - exists: boolean; - specId: string; // Which spec this worktree is for - worktreePath?: string; - branch?: string; - baseBranch?: string; - commitCount?: number; - filesChanged?: number; - additions?: number; - deletions?: number; -} -``` - ---- - -## Python Backend Changes - -### 1. worktree.py - Complete Rewrite - -```python -#!/usr/bin/env python3 -""" -Git Worktree Manager - Per-Spec Architecture -============================================= - -Each spec gets its own worktree: -- Worktree path: .worktrees/{spec-name}/ -- Branch name: auto-claude/{spec-name} - -This allows: -1. Multiple specs to be worked on simultaneously -2. Each spec's changes are isolated -3. Branches persist until explicitly merged -4. Clear 1:1:1 mapping: spec → worktree → branch -""" - -import asyncio -import re -import shutil -import subprocess -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - - -class WorktreeError(Exception): - """Error during worktree operations.""" - pass - - -@dataclass -class WorktreeInfo: - """Information about a spec's worktree.""" - path: Path - branch: str - spec_name: str - base_branch: str - is_active: bool = True - commit_count: int = 0 - files_changed: int = 0 - additions: int = 0 - deletions: int = 0 - - -class WorktreeManager: - """ - Manages per-spec Git worktrees. - - Each spec gets its own worktree in .worktrees/{spec-name}/ with - a corresponding branch auto-claude/{spec-name}. - """ - - def __init__(self, project_dir: Path, base_branch: Optional[str] = None): - self.project_dir = project_dir - self.base_branch = base_branch or self._get_current_branch() - self.worktrees_dir = project_dir / ".worktrees" - self._merge_lock = asyncio.Lock() - - def _get_current_branch(self) -> str: - """Get the current git branch.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=self.project_dir, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise WorktreeError(f"Failed to get current branch: {result.stderr}") - return result.stdout.strip() - - def _run_git(self, args: list[str], cwd: Optional[Path] = None) -> subprocess.CompletedProcess: - """Run a git command and return the result.""" - return subprocess.run( - ["git"] + args, - cwd=cwd or self.project_dir, - capture_output=True, - text=True, - ) - - def setup(self) -> None: - """Create worktrees directory if needed.""" - self.worktrees_dir.mkdir(exist_ok=True) - - # ==================== Per-Spec Worktree Methods ==================== - - def get_worktree_path(self, spec_name: str) -> Path: - """Get the worktree path for a spec.""" - return self.worktrees_dir / spec_name - - def get_branch_name(self, spec_name: str) -> str: - """Get the branch name for a spec.""" - return f"auto-claude/{spec_name}" - - def worktree_exists(self, spec_name: str) -> bool: - """Check if a worktree exists for a spec.""" - return self.get_worktree_path(spec_name).exists() - - def get_worktree_info(self, spec_name: str) -> Optional[WorktreeInfo]: - """Get info about a spec's worktree.""" - worktree_path = self.get_worktree_path(spec_name) - if not worktree_path.exists(): - return None - - # Verify the branch exists in the worktree - result = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=worktree_path) - if result.returncode != 0: - return None - - actual_branch = result.stdout.strip() - - # Get statistics - stats = self._get_worktree_stats(spec_name) - - return WorktreeInfo( - path=worktree_path, - branch=actual_branch, - spec_name=spec_name, - base_branch=self.base_branch, - is_active=True, - **stats - ) - - def _get_worktree_stats(self, spec_name: str) -> dict: - """Get diff statistics for a worktree.""" - worktree_path = self.get_worktree_path(spec_name) - - stats = { - "commit_count": 0, - "files_changed": 0, - "additions": 0, - "deletions": 0, - } - - if not worktree_path.exists(): - return stats - - # Commit count - result = self._run_git( - ["rev-list", "--count", f"{self.base_branch}..HEAD"], - cwd=worktree_path - ) - if result.returncode == 0: - stats["commit_count"] = int(result.stdout.strip() or "0") - - # Diff stats - result = self._run_git( - ["diff", "--shortstat", f"{self.base_branch}...HEAD"], - cwd=worktree_path - ) - if result.returncode == 0 and result.stdout.strip(): - # Parse: "3 files changed, 50 insertions(+), 10 deletions(-)" - match = re.search(r"(\d+) files? changed", result.stdout) - if match: - stats["files_changed"] = int(match.group(1)) - match = re.search(r"(\d+) insertions?", result.stdout) - if match: - stats["additions"] = int(match.group(1)) - match = re.search(r"(\d+) deletions?", result.stdout) - if match: - stats["deletions"] = int(match.group(1)) - - return stats - - def create_worktree(self, spec_name: str) -> WorktreeInfo: - """ - Create a worktree for a spec. - - Args: - spec_name: The spec folder name (e.g., "002-implement-memory") - - Returns: - WorktreeInfo for the created worktree - """ - worktree_path = self.get_worktree_path(spec_name) - branch_name = self.get_branch_name(spec_name) - - # Remove existing if present (from crashed previous run) - if worktree_path.exists(): - self._run_git(["worktree", "remove", "--force", str(worktree_path)]) - - # Delete branch if it exists (from previous attempt) - self._run_git(["branch", "-D", branch_name]) - - # Create worktree with new branch from base - result = self._run_git([ - "worktree", "add", "-b", branch_name, - str(worktree_path), self.base_branch - ]) - - if result.returncode != 0: - raise WorktreeError(f"Failed to create worktree for {spec_name}: {result.stderr}") - - print(f"Created worktree: {worktree_path.name} on branch {branch_name}") - - return WorktreeInfo( - path=worktree_path, - branch=branch_name, - spec_name=spec_name, - base_branch=self.base_branch, - is_active=True, - ) - - def get_or_create_worktree(self, spec_name: str) -> WorktreeInfo: - """ - Get existing worktree or create a new one for a spec. - - Args: - spec_name: The spec folder name - - Returns: - WorktreeInfo for the worktree - """ - existing = self.get_worktree_info(spec_name) - if existing: - print(f"Using existing worktree: {existing.path}") - return existing - - return self.create_worktree(spec_name) - - def remove_worktree(self, spec_name: str, delete_branch: bool = False) -> None: - """ - Remove a spec's worktree. - - Args: - spec_name: The spec folder name - delete_branch: Whether to also delete the branch - """ - worktree_path = self.get_worktree_path(spec_name) - branch_name = self.get_branch_name(spec_name) - - if worktree_path.exists(): - result = self._run_git(["worktree", "remove", "--force", str(worktree_path)]) - if result.returncode == 0: - print(f"Removed worktree: {worktree_path.name}") - else: - print(f"Warning: Could not remove worktree: {result.stderr}") - shutil.rmtree(worktree_path, ignore_errors=True) - - if delete_branch: - self._run_git(["branch", "-D", branch_name]) - print(f"Deleted branch: {branch_name}") - - self._run_git(["worktree", "prune"]) - - def merge_worktree(self, spec_name: str, delete_after: bool = False) -> bool: - """ - Merge a spec's worktree branch back to base branch. - - Args: - spec_name: The spec folder name - delete_after: Whether to remove worktree and branch after merge - - Returns: - True if merge succeeded - """ - info = self.get_worktree_info(spec_name) - if not info: - print(f"No worktree found for spec: {spec_name}") - return False - - print(f"Merging {info.branch} into {self.base_branch}...") - - # Switch to base branch in main project - result = self._run_git(["checkout", self.base_branch]) - if result.returncode != 0: - print(f"Error: Could not checkout base branch: {result.stderr}") - return False - - # Merge the spec branch - result = self._run_git([ - "merge", "--no-ff", info.branch, - "-m", f"auto-claude: Merge {info.branch}" - ]) - - if result.returncode != 0: - print(f"Merge conflict! Aborting merge...") - self._run_git(["merge", "--abort"]) - return False - - print(f"Successfully merged {info.branch}") - - if delete_after: - self.remove_worktree(spec_name, delete_branch=True) - - return True - - def commit_in_worktree(self, spec_name: str, message: str) -> bool: - """Commit all changes in a spec's worktree.""" - worktree_path = self.get_worktree_path(spec_name) - if not worktree_path.exists(): - return False - - self._run_git(["add", "."], cwd=worktree_path) - result = self._run_git(["commit", "-m", message], cwd=worktree_path) - - if result.returncode == 0: - return True - elif "nothing to commit" in result.stdout + result.stderr: - return True - else: - print(f"Commit failed: {result.stderr}") - return False - - # ==================== Listing & Discovery ==================== - - def list_all_worktrees(self) -> list[WorktreeInfo]: - """List all spec worktrees.""" - worktrees = [] - - if not self.worktrees_dir.exists(): - return worktrees - - for item in self.worktrees_dir.iterdir(): - if item.is_dir() and not item.name.startswith("worker-"): - info = self.get_worktree_info(item.name) - if info: - worktrees.append(info) - - return worktrees - - def list_all_spec_branches(self) -> list[str]: - """List all auto-claude branches (even if worktree removed).""" - result = self._run_git(["branch", "--list", "auto-claude/*"]) - if result.returncode != 0: - return [] - - branches = [] - for line in result.stdout.strip().split("\n"): - branch = line.strip().lstrip("* ") - if branch: - branches.append(branch) - - return branches - - def get_changed_files(self, spec_name: str) -> list[tuple[str, str]]: - """Get list of changed files in a spec's worktree.""" - worktree_path = self.get_worktree_path(spec_name) - if not worktree_path.exists(): - return [] - - result = self._run_git( - ["diff", "--name-status", f"{self.base_branch}...HEAD"], - cwd=worktree_path - ) - - files = [] - for line in result.stdout.strip().split("\n"): - if not line: - continue - parts = line.split("\t", 1) - if len(parts) == 2: - files.append((parts[0], parts[1])) - - return files - - def get_change_summary(self, spec_name: str) -> dict: - """Get a summary of changes in a worktree.""" - files = self.get_changed_files(spec_name) - - new_files = sum(1 for status, _ in files if status == "A") - modified_files = sum(1 for status, _ in files if status == "M") - deleted_files = sum(1 for status, _ in files if status == "D") - - return { - "new_files": new_files, - "modified_files": modified_files, - "deleted_files": deleted_files, - } - - def cleanup_all(self) -> None: - """Remove all worktrees and their branches.""" - for worktree in self.list_all_worktrees(): - self.remove_worktree(worktree.spec_name, delete_branch=True) - - def cleanup_stale_worktrees(self) -> None: - """Remove worktrees that aren't registered with git.""" - if not self.worktrees_dir.exists(): - return - - # Get list of registered worktrees - result = self._run_git(["worktree", "list", "--porcelain"]) - registered_paths = set() - for line in result.stdout.split("\n"): - if line.startswith("worktree "): - registered_paths.add(Path(line.split(" ", 1)[1])) - - # Remove unregistered directories - for item in self.worktrees_dir.iterdir(): - if item.is_dir() and item not in registered_paths: - print(f"Removing stale worktree directory: {item.name}") - shutil.rmtree(item, ignore_errors=True) - - self._run_git(["worktree", "prune"]) - - # ==================== Parallel Worker Support ==================== - - def create_worker_worktree(self, spec_name: str, worker_id: str, chunk_id: str) -> tuple[Path, str]: - """ - Create a temporary worker worktree that branches from a spec's worktree. - - Args: - spec_name: The spec this worker is working on - worker_id: Unique worker identifier - chunk_id: The chunk being worked on - - Returns: - Tuple of (worktree_path, branch_name) - """ - spec_info = self.get_worktree_info(spec_name) - if not spec_info: - raise WorktreeError(f"Spec worktree does not exist: {spec_name}") - - worker_name = f"worker-{worker_id}" - branch_name = f"worker-{worker_id}/{chunk_id}" - worktree_path = self.worktrees_dir / worker_name - - # Clean up any existing worker worktree - if worktree_path.exists(): - self._run_git(["worktree", "remove", "--force", str(worktree_path)]) - self._run_git(["branch", "-D", branch_name]) - - # Create worker worktree branching FROM the spec's branch - result = self._run_git([ - "worktree", "add", "-b", branch_name, - str(worktree_path), spec_info.branch - ]) - - if result.returncode != 0: - raise WorktreeError(f"Failed to create worker worktree: {result.stderr}") - - print(f"Created worker worktree: {worker_name} (from {spec_info.branch})") - return worktree_path, branch_name - - def merge_worker_to_spec(self, spec_name: str, worker_branch: str) -> bool: - """ - Merge a worker's branch back into the spec's worktree. - - Args: - spec_name: The spec to merge into - worker_branch: The worker branch to merge - - Returns: - True if merge succeeded - """ - spec_path = self.get_worktree_path(spec_name) - if not spec_path.exists(): - print(f"Spec worktree does not exist: {spec_name}") - return False - - print(f"Merging {worker_branch} into {spec_name}...") - - result = self._run_git( - ["merge", "--no-ff", worker_branch, "-m", f"auto-claude: Merge {worker_branch}"], - cwd=spec_path - ) - - if result.returncode != 0: - print(f"Merge conflict! Aborting...") - self._run_git(["merge", "--abort"], cwd=spec_path) - return False - - print(f"Successfully merged {worker_branch}") - return True - - def cleanup_worker_worktree(self, worker_id: str, branch_name: str) -> None: - """Clean up a worker's temporary worktree and branch.""" - worker_name = f"worker-{worker_id}" - worktree_path = self.worktrees_dir / worker_name - - if worktree_path.exists(): - self._run_git(["worktree", "remove", "--force", str(worktree_path)]) - - self._run_git(["branch", "-D", branch_name]) - self._run_git(["worktree", "prune"]) - - def cleanup_all_workers(self) -> None: - """Remove all worker worktrees (preserves spec worktrees).""" - if not self.worktrees_dir.exists(): - return - - for item in self.worktrees_dir.iterdir(): - if item.is_dir() and item.name.startswith("worker-"): - self._run_git(["worktree", "remove", "--force", str(item)]) - - # Clean up worker branches - result = self._run_git(["branch", "--list", "worker-*/*"]) - if result.returncode == 0: - for line in result.stdout.strip().split("\n"): - branch = line.strip().lstrip("* ") - if branch: - self._run_git(["branch", "-D", branch]) - - self._run_git(["worktree", "prune"]) - - def get_test_commands(self, spec_name: str) -> list[str]: - """Detect likely test/run commands for the project.""" - worktree_path = self.get_worktree_path(spec_name) - commands = [] - - if (worktree_path / "package.json").exists(): - commands.append("npm install && npm run dev") - commands.append("npm test") - - if (worktree_path / "requirements.txt").exists(): - commands.append("pip install -r requirements.txt") - - if (worktree_path / "Cargo.toml").exists(): - commands.append("cargo run") - commands.append("cargo test") - - if (worktree_path / "go.mod").exists(): - commands.append("go run .") - commands.append("go test ./...") - - if not commands: - commands.append("# Check the project's README for run instructions") - - return commands -``` - -### 2. workspace.py - Updated Functions - -```python -#!/usr/bin/env python3 -""" -Workspace Selection and Management - Per-Spec Architecture -=========================================================== - -Each spec gets its own isolated worktree at .worktrees/{spec-name}/ -""" - -import shutil -import subprocess -import sys -from enum import Enum -from pathlib import Path -from typing import Optional - -from worktree import WorktreeManager, WorktreeInfo -from ui import ( - Icons, - icon, - box, - success, - error, - warning, - info, - muted, - highlight, - bold, - print_header, - print_status, - print_key_value, - select_menu, - MenuOption, -) - - -class WorkspaceMode(Enum): - """How auto-claude should work.""" - ISOLATED = "isolated" # Work in a separate worktree (safe) - DIRECT = "direct" # Work directly in user's project - - -class WorkspaceChoice(Enum): - """User's choice after build completes.""" - MERGE = "merge" # Add changes to project - REVIEW = "review" # Show what changed - TEST = "test" # Test the feature in the worktree - LATER = "later" # Decide later - - -def has_uncommitted_changes(project_dir: Path) -> bool: - """Check if user has unsaved work.""" - result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=project_dir, - capture_output=True, - text=True, - ) - return bool(result.stdout.strip()) - - -def get_current_branch(project_dir: Path) -> str: - """Get the current branch name.""" - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) - return result.stdout.strip() - - -def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Optional[Path]: - """ - Check if there's an existing worktree FOR THIS SPECIFIC SPEC. - - Args: - project_dir: The project directory - spec_name: The spec folder name (e.g., "002-implement-memory") - - Returns: - Path to worktree if it exists for this spec, None otherwise - """ - worktree_path = project_dir / ".worktrees" / spec_name - - if worktree_path.exists(): - # Verify it's a valid git worktree - git_dir = worktree_path / ".git" - if git_dir.exists(): - return worktree_path - - return None - - -def copy_spec_to_worktree( - source_spec_dir: Path, - worktree_path: Path, - spec_name: str, -) -> Path: - """ - Copy spec files into the worktree so the AI can access them. - - Args: - source_spec_dir: Original spec directory (may be outside worktree) - worktree_path: Path to the worktree - spec_name: Name of the spec folder - - Returns: - Path to the spec directory inside the worktree - """ - target_spec_dir = worktree_path / "auto-claude" / "specs" / spec_name - - target_spec_dir.parent.mkdir(parents=True, exist_ok=True) - - if target_spec_dir.exists(): - shutil.rmtree(target_spec_dir) - - shutil.copytree(source_spec_dir, target_spec_dir) - - return target_spec_dir - - -def setup_workspace( - project_dir: Path, - spec_name: str, - mode: WorkspaceMode, - source_spec_dir: Optional[Path] = None, -) -> tuple[Path, Optional[WorktreeManager], Optional[Path]]: - """ - Set up the workspace for a specific spec. - - Uses per-spec worktrees - each spec gets its own isolated worktree. - - Args: - project_dir: The project directory - spec_name: Name of the spec being built - mode: The workspace mode to use - source_spec_dir: Optional source spec directory to copy to worktree - - Returns: - Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None) - """ - if mode == WorkspaceMode.DIRECT: - return project_dir, None, source_spec_dir - - print() - print_status(f"Setting up workspace for {spec_name}...", "progress") - - manager = WorktreeManager(project_dir) - manager.setup() - - # Get or create worktree FOR THIS SPECIFIC SPEC - info = manager.get_or_create_worktree(spec_name) - - # Copy spec files to worktree if provided - localized_spec_dir = None - if source_spec_dir and source_spec_dir.exists(): - localized_spec_dir = copy_spec_to_worktree( - source_spec_dir, info.path, spec_name - ) - print_status(f"Spec files copied to workspace", "success") - - print_status(f"Workspace ready: {info.path.name}", "success") - print() - - return info.path, manager, localized_spec_dir - - -def choose_workspace( - project_dir: Path, - spec_name: str, - force_isolated: bool = False, - force_direct: bool = False, - auto_continue: bool = False, -) -> WorkspaceMode: - """Let user choose where auto-claude should work.""" - if force_isolated: - return WorkspaceMode.ISOLATED - if force_direct: - return WorkspaceMode.DIRECT - if auto_continue: - print("Auto-continue: Using isolated workspace for safety.") - return WorkspaceMode.ISOLATED - - has_unsaved = has_uncommitted_changes(project_dir) - - if has_unsaved: - content = [ - success(f"{icon(Icons.SHIELD)} YOUR WORK IS PROTECTED"), - "", - "You have unsaved work in your project.", - "", - "To keep your work safe, the AI will build in a", - "separate workspace. Your current files won't be", - "touched until you're ready.", - ] - print() - print(box(content, width=60, style="heavy")) - print() - - try: - input(f"Press Enter to continue...") - except KeyboardInterrupt: - print() - print_status("Cancelled.", "info") - sys.exit(0) - - return WorkspaceMode.ISOLATED - - options = [ - MenuOption( - key="isolated", - label="Separate workspace (Recommended)", - icon=Icons.SHIELD, - description="Your current files stay untouched. Easy to review and undo.", - ), - MenuOption( - key="direct", - label="Right here in your project", - icon=Icons.LIGHTNING, - description="Changes happen directly. Best if you're not working on anything else.", - ), - ] - - choice = select_menu( - title="Where should the AI build your feature?", - options=options, - allow_quit=True, - ) - - if choice is None: - print() - print_status("Cancelled.", "info") - sys.exit(0) - - if choice == "direct": - print() - print_status("Working directly in your project.", "info") - return WorkspaceMode.DIRECT - else: - print() - print_status("Using a separate workspace for safety.", "success") - return WorkspaceMode.ISOLATED - - -def show_build_summary(manager: WorktreeManager, spec_name: str) -> None: - """Show a summary of what was built.""" - summary = manager.get_change_summary(spec_name) - - total = summary["new_files"] + summary["modified_files"] + summary["deleted_files"] - - if total == 0: - print_status("No changes were made.", "info") - return - - print() - print(bold("What was built:")) - if summary["new_files"] > 0: - print(success(f" + {summary['new_files']} new file{'s' if summary['new_files'] != 1 else ''}")) - if summary["modified_files"] > 0: - print(info(f" ~ {summary['modified_files']} modified file{'s' if summary['modified_files'] != 1 else ''}")) - if summary["deleted_files"] > 0: - print(error(f" - {summary['deleted_files']} deleted file{'s' if summary['deleted_files'] != 1 else ''}")) - - -def show_changed_files(manager: WorktreeManager, spec_name: str) -> None: - """Show detailed list of changed files.""" - files = manager.get_changed_files(spec_name) - - if not files: - print_status("No changes.", "info") - return - - print() - print(bold("Changed files:")) - for status, filepath in files: - if status == "A": - print(success(f" + {filepath}")) - elif status == "M": - print(info(f" ~ {filepath}")) - elif status == "D": - print(error(f" - {filepath}")) - else: - print(f" {status} {filepath}") - - -def finalize_workspace( - project_dir: Path, - spec_name: str, - manager: Optional[WorktreeManager], - auto_continue: bool = False, # NEW: Skip interactive menu when True -) -> WorkspaceChoice: - """Handle post-build workflow. - - Args: - project_dir: The project directory - spec_name: Name of the spec - manager: WorktreeManager instance (None if direct mode) - auto_continue: If True, skip interactive menu (for UI/non-TTY mode) - """ - if manager is None: - content = [ - success(f"{icon(Icons.SUCCESS)} BUILD COMPLETE!"), - "", - "Changes were made directly to your project.", - muted("Use 'git status' to see what changed."), - ] - print() - print(box(content, width=60, style="heavy")) - return WorkspaceChoice.MERGE - - content = [ - success(f"{icon(Icons.SUCCESS)} BUILD COMPLETE!"), - "", - "The AI built your feature in a separate workspace.", - ] - print() - print(box(content, width=60, style="heavy")) - - show_build_summary(manager, spec_name) - - # CRITICAL: Skip interactive menu in non-TTY mode (UI spawned process) - # This fixes Bug #5 - process hanging on interactive menu - if auto_continue: - worktree_path = manager.get_worktree_path(spec_name) - print() - print(muted("Build saved. Use the UI or CLI to merge/review/discard:")) - print(muted(f" --merge : Add changes to your project")) - print(muted(f" --review : See what was built")) - print(muted(f" --discard : Delete the build")) - print() - print(f"Worktree: {highlight(str(worktree_path))}") - return WorkspaceChoice.LATER # Let UI handle the decision - - worktree_path = manager.get_worktree_path(spec_name) - - options = [ - MenuOption( - key="test", - label="Test the feature (Recommended)", - icon=Icons.PLAY, - description="Run the app and try it out before adding to your project", - ), - MenuOption( - key="merge", - label="Add to my project now", - icon=Icons.SUCCESS, - description="Merge the changes into your files immediately", - ), - MenuOption( - key="review", - label="Review what changed", - icon=Icons.FILE, - description="See exactly what files were modified", - ), - MenuOption( - key="later", - label="Decide later", - icon=Icons.PAUSE, - description="Your build is saved - you can come back anytime", - ), - ] - - print() - choice = select_menu( - title="What would you like to do?", - options=options, - allow_quit=False, - ) - - if choice == "test": - return WorkspaceChoice.TEST - elif choice == "merge": - return WorkspaceChoice.MERGE - elif choice == "review": - return WorkspaceChoice.REVIEW - else: - return WorkspaceChoice.LATER - - -def handle_workspace_choice( - choice: WorkspaceChoice, - project_dir: Path, - spec_name: str, - manager: WorktreeManager, -) -> None: - """Execute the user's choice.""" - worktree_path = manager.get_worktree_path(spec_name) - - if choice == WorkspaceChoice.TEST: - content = [ - bold(f"{icon(Icons.PLAY)} TEST YOUR FEATURE"), - "", - "Your feature is ready to test in a separate workspace.", - ] - print() - print(box(content, width=60, style="heavy")) - - print() - print("To test it, open a NEW terminal and run:") - print() - print(highlight(f" cd {worktree_path}")) - - commands = manager.get_test_commands(spec_name) - print() - print("Then run your project:") - for cmd in commands[:2]: - print(f" {cmd}") - - print() - print(muted("-" * 60)) - print() - print("When you're done testing:") - print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge")) - print() - print("To discard (if you don't like it):") - print(muted(f" python auto-claude/run.py --spec {spec_name} --discard")) - print() - - elif choice == WorkspaceChoice.MERGE: - print() - print_status("Adding changes to your project...", "progress") - success_result = manager.merge_worktree(spec_name, delete_after=True) - - if success_result: - print() - print_status("Your feature has been added to your project.", "success") - else: - print() - print_status("There was a conflict merging the changes.", "error") - print(muted("Your build is still saved in the separate workspace.")) - print(muted("You may need to merge manually or ask for help.")) - - elif choice == WorkspaceChoice.REVIEW: - show_changed_files(manager, spec_name) - print() - print(muted("-" * 60)) - print() - print("To see full details of changes:") - info_obj = manager.get_worktree_info(spec_name) - if info_obj: - print(muted(f" git diff {info_obj.base_branch}...{info_obj.branch}")) - print() - print("To test the feature:") - print(highlight(f" cd {worktree_path}")) - print() - print("To add these changes to your project:") - print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge")) - print() - - else: # LATER - print() - print_status("No problem! Your build is saved.", "success") - print() - print("To test the feature:") - print(highlight(f" cd {worktree_path}")) - print() - print("When you're ready to add it:") - print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge")) - print() - print("To see what was built:") - print(muted(f" python auto-claude/run.py --spec {spec_name} --review")) - print() - - -def merge_existing_build(project_dir: Path, spec_name: str) -> bool: - """Merge an existing build into the project.""" - worktree_path = get_existing_build_worktree(project_dir, spec_name) - - if not worktree_path: - print() - print_status(f"No existing build found for '{spec_name}'.", "warning") - print() - print("To start a new build:") - print(highlight(f" python auto-claude/run.py --spec {spec_name}")) - return False - - content = [ - bold(f"{icon(Icons.SUCCESS)} ADDING BUILD TO YOUR PROJECT"), - ] - print() - print(box(content, width=60, style="heavy")) - - manager = WorktreeManager(project_dir) - - show_build_summary(manager, spec_name) - print() - - success_result = manager.merge_worktree(spec_name, delete_after=True) - - if success_result: - print() - print_status("Your feature has been added to your project.", "success") - return True - else: - print() - print_status("There was a conflict merging the changes.", "error") - print(muted("You may need to merge manually.")) - return False - - -def review_existing_build(project_dir: Path, spec_name: str) -> bool: - """Show what an existing build contains.""" - worktree_path = get_existing_build_worktree(project_dir, spec_name) - - if not worktree_path: - print() - print_status(f"No existing build found for '{spec_name}'.", "warning") - print() - print("To start a new build:") - print(highlight(f" python auto-claude/run.py --spec {spec_name}")) - return False - - content = [ - bold(f"{icon(Icons.FILE)} BUILD CONTENTS"), - ] - print() - print(box(content, width=60, style="heavy")) - - manager = WorktreeManager(project_dir) - info_obj = manager.get_worktree_info(spec_name) - - show_build_summary(manager, spec_name) - show_changed_files(manager, spec_name) - - print() - print(muted("-" * 60)) - print() - print("To test the feature:") - print(highlight(f" cd {worktree_path}")) - print() - print("To add these changes to your project:") - print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge")) - print() - print("To see full diff:") - if info_obj: - print(muted(f" git diff {info_obj.base_branch}...{info_obj.branch}")) - print() - - return True - - -def discard_existing_build(project_dir: Path, spec_name: str) -> bool: - """Discard an existing build (with confirmation).""" - worktree_path = get_existing_build_worktree(project_dir, spec_name) - - if not worktree_path: - print() - print_status(f"No existing build found for '{spec_name}'.", "warning") - return False - - content = [ - warning(f"{icon(Icons.WARNING)} DELETE BUILD RESULTS?"), - "", - "This will permanently delete all work for this build.", - ] - print() - print(box(content, width=60, style="heavy")) - - manager = WorktreeManager(project_dir) - - show_build_summary(manager, spec_name) - - print() - print(f"Are you sure? Type {highlight('delete')} to confirm: ", end="") - - try: - confirmation = input().strip().lower() - except KeyboardInterrupt: - print() - print_status("Cancelled. Your build is still saved.", "info") - return False - - if confirmation != "delete": - print() - print_status("Cancelled. Your build is still saved.", "info") - return False - - manager.remove_worktree(spec_name, delete_branch=True) - - print() - print_status("Build deleted.", "success") - return True - - -def check_existing_build(project_dir: Path, spec_name: str) -> bool: - """Check if there's an existing build and offer options.""" - worktree_path = get_existing_build_worktree(project_dir, spec_name) - - if not worktree_path: - return False - - content = [ - info(f"{icon(Icons.INFO)} EXISTING BUILD FOUND"), - "", - "There's already a build in progress for this spec.", - ] - print() - print(box(content, width=60, style="heavy")) - - options = [ - MenuOption( - key="continue", - label="Continue where it left off", - icon=Icons.PLAY, - description="Resume building from the last checkpoint", - ), - MenuOption( - key="review", - label="Review what was built", - icon=Icons.FILE, - description="See the files that were created/modified", - ), - MenuOption( - key="merge", - label="Add to my project now", - icon=Icons.SUCCESS, - description="Merge the existing build into your project", - ), - MenuOption( - key="fresh", - label="Start fresh", - icon=Icons.ERROR, - description="Discard current build and start over", - ), - ] - - print() - choice = select_menu( - title="What would you like to do?", - options=options, - allow_quit=True, - ) - - if choice is None: - print() - print_status("Cancelled.", "info") - sys.exit(0) - - if choice == "continue": - return True - elif choice == "review": - review_existing_build(project_dir, spec_name) - print() - input(f"Press Enter to continue building...") - return True - elif choice == "merge": - merge_existing_build(project_dir, spec_name) - return False - elif choice == "fresh": - discarded = discard_existing_build(project_dir, spec_name) - return not discarded - else: - return True -``` - -### 3. coordinator.py - Parallel Execution Changes - -Update the `SwarmCoordinator` class to use per-spec worktrees: - -```python -#!/usr/bin/env python3 -""" -Multi-Agent Parallelism Coordinator - Per-Spec Architecture -============================================================ - -Workers create temporary worktrees that branch FROM the spec's worktree, -then merge back into it when complete. - -Flow: -1. Spec worktree exists at .worktrees/{spec-name}/ -2. Worker creates .worktrees/worker-{id}/ branching from spec's branch -3. Worker completes chunk -4. Worker merges into spec's worktree -5. Worker worktree is deleted -""" - -# ... imports ... - -class SwarmCoordinator: - """Coordinates parallel execution using per-spec worktrees.""" - - def __init__( - self, - spec_dir: Path, - project_dir: Path, - max_workers: int = 3, - model: str = "claude-opus-4-5-20251101", - verbose: bool = False, - ): - self.spec_dir = spec_dir - self.project_dir = project_dir - self.max_workers = max_workers - self.model = model - self.verbose = verbose - self.spec_name = spec_dir.name - - # State tracking - self.workers: dict[str, WorkerAssignment] = {} - self.claimed_files: dict[str, str] = {} - self.plan: Optional[ImplementationPlan] = None - self.worktree_manager: Optional[WorktreeManager] = None - - self.progress_file = spec_dir / "parallel_progress.json" - self._merge_lock = asyncio.Lock() - self.status_manager = StatusManager(project_dir) - - async def run_worker( - self, - worker_id: str, - phase: Phase, - chunk: Chunk, - ) -> bool: - """Run a single worker on a chunk using a dedicated worktree.""" - - # Create worker worktree branching FROM spec's worktree - try: - worktree_path, branch_name = self.worktree_manager.create_worker_worktree( - self.spec_name, worker_id, chunk.id - ) - except WorktreeError as e: - print_status(f"Worker {worker_id}: Failed to create worktree: {e}", "error") - return False - - # Claim the chunk - if not self.claim_chunk(worker_id, phase, chunk, worktree_path, branch_name): - print(f"Worker {worker_id}: Failed to claim chunk {chunk.id}") - self.worktree_manager.cleanup_worker_worktree(worker_id, branch_name) - return False - - try: - # ... run agent session in worktree_path ... - - chunk_success = True # Set based on agent result - - # Commit changes - if chunk_success: - self.worktree_manager.commit_in_worktree(self.spec_name, - f"auto-claude: Complete {chunk.id}\n\n{chunk.description}") - - # Release the chunk - self.release_chunk(worker_id, chunk.id, chunk_success, None) - - # Merge to spec worktree if successful - if chunk_success: - async with self._merge_lock: - merge_success = self.worktree_manager.merge_worker_to_spec( - self.spec_name, branch_name - ) - if not merge_success: - print_status(f"Worker {worker_id}: Merge failed for {chunk.id}", "error") - chunk_success = False - - # Clean up worker worktree - self.worktree_manager.cleanup_worker_worktree(worker_id, branch_name) - - return chunk_success - - except Exception as e: - print_status(f"Worker {worker_id}: Error: {e}", "error") - self.release_chunk(worker_id, chunk.id, False, str(e)) - self.worktree_manager.cleanup_worker_worktree(worker_id, branch_name) - return False - - async def run_parallel(self) -> Path: - """Main coordination loop.""" - - # Initialize worktree manager - self.worktree_manager = WorktreeManager(self.project_dir) - self.worktree_manager.setup() - - # Get or create worktree FOR THIS SPECIFIC SPEC - spec_info = self.worktree_manager.get_or_create_worktree(self.spec_name) - print_key_value("Spec worktree", str(spec_info.path)) - print_key_value("Spec branch", spec_info.branch) - - # ... rest of coordination loop ... - - # Cleanup: remove worker worktrees, preserve spec worktree - self.worktree_manager.cleanup_all_workers() - - return spec_info.path -``` - -### 4. run.py - Integration Updates - -Key changes needed in `run.py`: - -```python -# In list_specs() function - update the build check -def list_specs(project_dir: Path, spec_dir: Path): - """List all available specs with their status.""" - # ... - for spec_folder in sorted(spec_dir.iterdir()): - # ... - # Check for existing build (uses spec-specific path now) - has_build = get_existing_build_worktree(project_dir, folder_name) is not None - # ... - -# CRITICAL FIX for Bug #5: Pass auto_continue to finalize_workspace -# This prevents the process from hanging on interactive menu when spawned by UI -# Around line 745 in current run.py: -if worktree_manager: - choice = finalize_workspace( - project_dir, - spec_dir.name, - worktree_manager, - auto_continue=args.auto_continue # PASS THE FLAG - fixes hang! - ) - # Only handle choice if interactive (not auto_continue) - if not args.auto_continue: - handle_workspace_choice(choice, project_dir, spec_dir.name, worktree_manager) - -# The --cleanup-worktrees command (NEW) -parser.add_argument( - "--cleanup-worktrees", - action="store_true", - help="Remove all worktrees and their branches", -) - -# Handle cleanup command -if args.cleanup_worktrees: - manager = WorktreeManager(project_dir) - worktrees = manager.list_all_worktrees() - if not worktrees: - print_status("No worktrees to clean up.", "info") - else: - print(f"Found {len(worktrees)} worktree(s):") - for wt in worktrees: - print(f" - {wt.spec_name} ({wt.branch})") - print() - confirm = input("Delete all? Type 'delete' to confirm: ") - if confirm.strip().lower() == 'delete': - manager.cleanup_all() - print_status("All worktrees removed.", "success") - else: - print_status("Cancelled.", "info") - return -``` - ---- - -## UI Backend Changes - -### ipc-handlers.ts - Fixed Paths & Per-Spec Support - -```typescript -import { execSync } from 'child_process'; -import { existsSync, readdirSync, statSync } from 'fs'; -import * as path from 'path'; - -/** - * Get the worktree path for a specific spec - */ -function getWorktreePath(projectPath: string, specId: string): string { - return path.join(projectPath, '.worktrees', specId); -} - -/** - * Get the branch name for a specific spec - */ -function getBranchName(specId: string): string { - return `auto-claude/${specId}`; -} - -/** - * Get the worktree status for a task - */ -ipcMain.handle( - IPC_CHANNELS.TASK_WORKTREE_STATUS, - async (_, taskId: string): Promise> => { - try { - const { task, project } = findTaskAndProject(taskId); - if (!task || !project) { - return { success: false, error: 'Task not found' }; - } - - // Per-spec worktree path - const worktreePath = getWorktreePath(project.path, task.specId); - - if (!existsSync(worktreePath)) { - return { - success: true, - data: { - exists: false, - specId: task.specId - } - }; - } - - try { - // Get current branch in worktree - const branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - // Get base branch - let baseBranch = 'main'; - try { - const result = execSync('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null', { - cwd: project.path, - encoding: 'utf-8' - }).trim(); - baseBranch = result.replace('refs/remotes/origin/', ''); - } catch { - baseBranch = 'main'; - } - - // Get commit count - let commitCount = 0; - try { - const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD`, { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - commitCount = parseInt(countOutput, 10) || 0; - } catch { - commitCount = 0; - } - - // Get diff stats - let filesChanged = 0; - let additions = 0; - let deletions = 0; - - try { - const diffStat = execSync(`git diff --shortstat ${baseBranch}...HEAD`, { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - const filesMatch = diffStat.match(/(\d+) files? changed/); - const addMatch = diffStat.match(/(\d+) insertions?\(\+\)/); - const delMatch = diffStat.match(/(\d+) deletions?\(-\)/); - - if (filesMatch) filesChanged = parseInt(filesMatch[1], 10); - if (addMatch) additions = parseInt(addMatch[1], 10); - if (delMatch) deletions = parseInt(delMatch[1], 10); - } catch { - // Ignore diff errors - } - - return { - success: true, - data: { - exists: true, - specId: task.specId, - worktreePath, - branch, - baseBranch, - commitCount, - filesChanged, - additions, - deletions - } - }; - } catch (gitError) { - console.error('Git error getting worktree status:', gitError); - return { - success: true, - data: { exists: true, specId: task.specId, worktreePath } - }; - } - } catch (error) { - console.error('Failed to get worktree status:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get worktree status' - }; - } - } -); - -/** - * Get the diff for a task's worktree - */ -ipcMain.handle( - IPC_CHANNELS.TASK_WORKTREE_DIFF, - async (_, taskId: string): Promise> => { - try { - const { task, project } = findTaskAndProject(taskId); - if (!task || !project) { - return { success: false, error: 'Task not found' }; - } - - const worktreePath = getWorktreePath(project.path, task.specId); - - if (!existsSync(worktreePath)) { - return { success: false, error: 'No worktree found for this task' }; - } - - // Get base branch - let baseBranch = 'main'; - try { - const result = execSync('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null', { - cwd: project.path, - encoding: 'utf-8' - }).trim(); - baseBranch = result.replace('refs/remotes/origin/', ''); - } catch { - baseBranch = 'main'; - } - - const files: WorktreeDiffFile[] = []; - - try { - const numstat = execSync(`git diff --numstat ${baseBranch}...HEAD`, { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - const nameStatus = execSync(`git diff --name-status ${baseBranch}...HEAD`, { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - // Parse name-status - const statusMap: Record = {}; - for (const line of nameStatus.split('\n')) { - if (!line) continue; - const [status, filePath] = line.split('\t'); - if (status === 'A') statusMap[filePath] = 'added'; - else if (status === 'M') statusMap[filePath] = 'modified'; - else if (status === 'D') statusMap[filePath] = 'deleted'; - else if (status.startsWith('R')) statusMap[filePath] = 'renamed'; - } - - // Parse numstat - for (const line of numstat.split('\n')) { - if (!line) continue; - const [add, del, filePath] = line.split('\t'); - files.push({ - path: filePath, - status: statusMap[filePath] || 'modified', - additions: parseInt(add, 10) || 0, - deletions: parseInt(del, 10) || 0 - }); - } - } catch { - // Ignore parse errors - } - - const summary = `${files.length} file(s) changed`; - - return { - success: true, - data: { files, summary } - }; - } catch (error) { - console.error('Failed to get worktree diff:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to get worktree diff' - }; - } - } -); - -/** - * Merge the worktree changes into the main branch - */ -ipcMain.handle( - IPC_CHANNELS.TASK_WORKTREE_MERGE, - async (_, taskId: string): Promise> => { - try { - const { task, project } = findTaskAndProject(taskId); - if (!task || !project) { - return { success: false, error: 'Task not found' }; - } - - const worktreePath = getWorktreePath(project.path, task.specId); - - if (!existsSync(worktreePath)) { - return { - success: false, - error: 'No worktree found for this task' - }; - } - - // Run Python merge command - return new Promise((resolve) => { - const proc = spawn('python', [ - path.join(project.path, 'auto-claude', 'run.py'), - '--spec', task.specId, - '--merge' - ], { - cwd: project.path, - env: process.env - }); - - let output = ''; - proc.stdout.on('data', (data) => { output += data.toString(); }); - proc.stderr.on('data', (data) => { output += data.toString(); }); - - proc.on('close', (code) => { - if (code === 0) { - resolve({ - success: true, - data: { - success: true, - specId: task.specId, - message: 'Worktree merged successfully' - } - }); - } else { - resolve({ - success: false, - error: `Merge failed: ${output}` - }); - } - }); - }); - } catch (error) { - console.error('Failed to merge worktree:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to merge worktree' - }; - } - } -); - -/** - * Discard the worktree changes - */ -ipcMain.handle( - IPC_CHANNELS.TASK_WORKTREE_DISCARD, - async (_, taskId: string): Promise> => { - try { - const { task, project } = findTaskAndProject(taskId); - if (!task || !project) { - return { success: false, error: 'Task not found' }; - } - - const worktreePath = getWorktreePath(project.path, task.specId); - - if (!existsSync(worktreePath)) { - return { - success: true, - data: { - success: true, - message: 'No worktree to discard' - } - }; - } - - try { - // Get the branch name - const branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - // Remove the worktree - execSync(`git worktree remove --force "${worktreePath}"`, { - cwd: project.path, - encoding: 'utf-8' - }); - - // Delete the branch - try { - execSync(`git branch -D "${branch}"`, { - cwd: project.path, - encoding: 'utf-8' - }); - } catch { - // Branch might already be deleted - } - - // Prune worktrees - execSync('git worktree prune', { - cwd: project.path, - encoding: 'utf-8' - }); - - return { - success: true, - data: { - success: true, - message: 'Worktree discarded successfully' - } - }; - } catch (gitError) { - console.error('Git error discarding worktree:', gitError); - return { - success: false, - error: `Failed to discard worktree: ${gitError instanceof Error ? gitError.message : 'Unknown error'}` - }; - } - } catch (error) { - console.error('Failed to discard worktree:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to discard worktree' - }; - } - } -); - -/** - * List all worktrees in a project (for dashboard) - */ -ipcMain.handle( - 'task:listWorktrees', - async (_, projectId: string): Promise> => { - try { - const project = projectStore.getProject(projectId); - if (!project) { - return { success: false, error: 'Project not found' }; - } - - const worktreesDir = path.join(project.path, '.worktrees'); - if (!existsSync(worktreesDir)) { - return { success: true, data: [] }; - } - - const worktrees: WorktreeInfo[] = []; - - for (const specId of readdirSync(worktreesDir)) { - // Skip worker worktrees - if (specId.startsWith('worker-')) continue; - - const worktreePath = path.join(worktreesDir, specId); - const stat = statSync(worktreePath); - - if (!stat.isDirectory()) continue; - - try { - const branch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - encoding: 'utf-8' - }).trim(); - - worktrees.push({ - specId, - worktreePath, - branch, - exists: true - }); - } catch { - // Skip invalid worktrees - } - } - - return { success: true, data: worktrees }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to list worktrees' - }; - } - } -); -``` - ---- - -## Type Definitions (shared/types.ts) - -Add/update these interfaces: - -```typescript -/** - * Worktree status for a specific spec - */ -export interface WorktreeStatus { - exists: boolean; - specId: string; // Which spec this worktree is for - worktreePath?: string; - branch?: string; - baseBranch?: string; - commitCount?: number; - filesChanged?: number; - additions?: number; - deletions?: number; -} - -/** - * Summary of a worktree in a project (for listing) - */ -export interface WorktreeInfo { - specId: string; - worktreePath: string; - branch: string; - exists: boolean; -} - -/** - * Result of a worktree merge operation - */ -export interface WorktreeMergeResult { - success: boolean; - specId: string; - message: string; - conflicts?: string[]; -} - -/** - * Result of a worktree discard operation - */ -export interface WorktreeDiscardResult { - success: boolean; - message: string; -} -``` - ---- - -## Summary of Changes - -| Component | Old Behavior | New Behavior | -|-----------|--------------|--------------| -| **Worktree Path** | `.worktrees/auto-claude/` | `.worktrees/{spec-name}/` | -| **Branch Name** | `auto-claude/{spec-name}` | Same (unchanged) | -| **Worktree Count** | ONE for all specs | ONE per spec | -| **UI Path** | `.worktrees/auto-claude-staging/` (wrong!) | `.worktrees/{spec-name}/` | -| **Branch on Merge** | Deleted by default | Preserved by default | -| **Parallel Specs** | Not supported | Fully supported | -| **Worker Worktrees** | Branch from staging | Branch from spec's worktree | -| **Post-Build Menu** | Always interactive (hangs UI) | Skipped when `--auto-continue` | - -## Task Status Flow - -When UI spawns task execution with `--auto-continue`: - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ in_progress │───▶│ ai_review │───▶│ human_review │───▶│ done │ -│ │ │ │ │ │ │ │ -│ Chunks building │ │ QA agent runs │ │ Process exits │ │ User merges │ -│ │ │ │ │ with code 0 │ │ via UI/CLI │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ -``` - -**Key Fix:** When `--auto-continue` is set: -1. `finalize_workspace()` skips interactive menu -2. Process exits cleanly with code 0 -3. UI detects exit → sets status to `human_review` -4. User can merge/review/discard via UI buttons - ---- - -## Benefits - -1. **Isolation**: Each spec's work is completely isolated -2. **Parallel Work**: Can work on multiple specs simultaneously -3. **Clear Mapping**: spec → worktree → branch (1:1:1) -4. **Persistence**: Branches remain until explicitly merged -5. **Discoverability**: Easy to list all in-progress specs -6. **No More Bugs**: Fixes path mismatch, spec confusion, and branch deletion issues - ---- - -## CLI Commands - -```bash -# Build a spec (creates per-spec worktree) -python auto-claude/run.py --spec 002-feature - -# Parallel execution (workers branch from spec worktree) -python auto-claude/run.py --spec 002-feature --parallel 3 - -# Review build -python auto-claude/run.py --spec 002-feature --review - -# Merge build to project -python auto-claude/run.py --spec 002-feature --merge - -# Discard build -python auto-claude/run.py --spec 002-feature --discard - -# Clean up ALL worktrees (NEW) -python auto-claude/run.py --cleanup-worktrees -``` - ---- - -## Implementation Checklist - -### Python Backend -- [ ] Replace `worktree.py` with per-spec implementation -- [ ] Replace `workspace.py` with per-spec implementation (includes Bug #5 fix) -- [ ] Update `coordinator.py` for per-spec parallel execution -- [ ] Update `run.py`: - - [ ] Add `--cleanup-worktrees` command - - [ ] Pass `auto_continue` to `finalize_workspace()` (Bug #5 fix) - -### UI Backend (TypeScript) -- [ ] Fix `ipc-handlers.ts` worktree paths (use `getWorktreePath(projectPath, specId)`) -- [ ] Update TypeScript types in `shared/types.ts` -- [ ] Add `task:listWorktrees` IPC handler - -### Testing -- [ ] Test single spec workflow (CLI) -- [ ] Test single spec workflow (UI) - verify no hang on completion -- [ ] Test parallel spec workflow -- [ ] Test multiple specs simultaneously -- [ ] Test merge/discard operations -- [ ] Verify status transitions: `in_progress` → `human_review` → `done` diff --git a/PRD-smart-context-system.md b/PRD-smart-context-system.md deleted file mode 100644 index 6b258678..00000000 --- a/PRD-smart-context-system.md +++ /dev/null @@ -1,3406 +0,0 @@ -# PRD: Smart Context System for Spec Creation - -> **Status:** Draft v2 (Revised) -> **Author:** Claude -> **Created:** 2025-12-11 -> **Revised:** 2025-12-11 -> **Target:** Auto Claude Spec Creation Pipeline - ---- - -## 0. System Context - -### Auto Claude Architecture Overview - -Auto Claude is a multi-agent autonomous coding framework with four main entry points: - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ AUTO CLAUDE SYSTEM │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ ROADMAP │ │ IDEATION │ │ SPEC CREATION │ │ -│ │ roadmap_runner │ │ ideation_runner │ │ spec_runner │ │ -│ │ │ │ │ │ │ │ -│ │ • Discovery │ │ • Low Hanging │ │ • Complexity │ │ -│ │ • Features │ │ • UI/UX │ │ • Requirements │ │ -│ │ │ │ • High Value │ │ • Research │ │ -│ │ │ │ • Security │ │ • Context │ │ -│ │ │ │ • Performance │ │ • Spec Writing │ │ -│ │ │ │ • Code Quality │ │ • Critique │ │ -│ │ │ │ • Documentation │ │ • Planning │ │ -│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ -│ │ │ │ │ -│ └────────────────────┴────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────┐ │ -│ │ IMPLEMENTATION │ │ -│ │ run.py → agent.py │ │ -│ │ │ │ -│ │ • Coordinator │ │ -│ │ • Coder Sessions │ │ -│ │ • QA Loop │ │ -│ │ • Worktree Mgmt │ │ -│ └────────┬────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────┐ │ -│ │ MEMORY LAYER │ │ -│ │ │ │ -│ │ • Graphiti │◄── PRIMARY (semantic memory) │ -│ │ graphiti_memory │ │ -│ │ │ │ -│ │ • File-based │◄── LITE MODE (optional fallback) │ -│ │ memory.py │ │ -│ └─────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### Current Memory Flow (The Gap) - -``` - CURRENT STATE -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ROADMAP IDEATION SPEC CREATION IMPLEMENTATION │ -│ │ │ │ │ │ -│ ▼ ▼ ▼ ▼ │ -│ [No memory] [No memory] [No memory] ┌──────────────┐ │ -│ │ MEMORY LAYER │ │ -│ │ │ │ -│ ════════════════════════════════════════════════════│ • Patterns │ │ -│ These phases analyze the codebase from scratch │ • Gotchas │ │ -│ every time, missing valuable historical learnings │ • Insights │ │ -│ ════════════════════════════════════════════════════│ • Outcomes │ │ -│ └──────────────┘ │ -│ ▲ │ -│ │ │ -│ Only coder sessions write/read │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -### User Journey (End-to-End) - -``` -1. PROJECT CONNECTION - └── User runs: python auto-claude/analyzer.py - └── Creates: project_index.json (services, tech stack, ports) - -2. ROADMAP (Optional) - └── User runs: python auto-claude/roadmap_runner.py - ├── Phase 1: Project Analysis (reuses project_index.json) - ├── Phase 2: Discovery (interactive - understand audience, vision) - └── Phase 3: Feature Generation (prioritized feature list) - └── Creates: roadmap.json, roadmap_discovery.json - -3. IDEATION (Optional) - └── User runs: python auto-claude/ideation_runner.py - ├── Phase 1: Project Analysis (reuses project_index.json) - ├── Phase 2: Context Gathering (tech stack, planned features) - └── Phase 3+: Parallel Ideation Agents (7 types) - └── Creates: ideation.json, *_ideas.json files - -4. SPEC CREATION - └── User runs: python auto-claude/spec_runner.py --task "..." - ├── Phase 1: Complexity Assessment → complexity_assessment.json - ├── Phase 2: Requirements Gathering → requirements.json - ├── Phase 3: Research (conditional) → research.json - ├── Phase 4: Context Discovery → context.json - ├── Phase 5: Spec Writing → spec.md - ├── Phase 6: Self-Critique (complex only) → critique_report.json - ├── Phase 7: Planning → implementation_plan.json - └── Phase 8: Validation - -5. IMPLEMENTATION - └── User runs: python auto-claude/run.py --spec 001 - ├── Workspace Setup (worktree isolation) - ├── Coder Sessions (implement chunks) - │ ├── Read spec, plan, memory - │ ├── Implement chunk - │ ├── Self-critique - │ ├── Verify - │ ├── Commit - │ └── Write session insights to memory/ - ├── QA Validation Loop - │ ├── Run tests (unit, integration, e2e) - │ ├── Browser verification - │ ├── Code review - │ └── Approve or Reject (→ QA Fix Loop) - └── User Decision: --merge, --review, or --discard - -6. TASK COMPLETE - └── Code merged into project - └── Memory preserved for future tasks -``` - -### Files Created Per Phase - -| Phase | Files Created | Consumed By | -|-------|---------------|-------------| -| **Analyzer** | `project_index.json` | All phases | -| **Roadmap** | `roadmap.json`, `roadmap_discovery.json` | Ideation, Spec | -| **Ideation** | `ideation.json`, `*_ideas.json` | User review | -| **Spec: Complexity** | `complexity_assessment.json` | Spec orchestrator | -| **Spec: Requirements** | `requirements.json` | Spec writer, Planner | -| **Spec: Research** | `research.json` | Spec writer, Critic | -| **Spec: Context** | `context.json` | Spec writer, Planner, Coder | -| **Spec: Writer** | `spec.md` | All implementation phases | -| **Spec: Critic** | `critique_report.json` | Spec validation | -| **Spec: Planner** | `implementation_plan.json`, `init.sh` | Coder, QA | -| **Implementation** | Code changes, commits | QA | -| **Implementation** | `memory/session_*.json` | Next session | -| **Implementation** | `memory/codebase_map.json` | Next session | -| **Implementation** | `memory/patterns.md`, `gotchas.md` | Next session | -| **QA** | `qa_report.md`, `QA_FIX_REQUEST.md` | QA Fixer | - -### Proposed Memory Flow (After This PRD) - -``` - PROPOSED STATE -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ROADMAP IDEATION SPEC CREATION IMPLEMENTATION │ -│ │ │ │ │ │ -│ ▼ ▼ ▼ ▼ │ -│ ┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────────┐ │ -│ │ Graph │ │ Graph │ │ Full │ │ MEMORY LAYER │ │ -│ │ Hints │ │ Hints │ │ Historical│ │ │ │ -│ │ (500t) │ │ (500t) │ │ Context │ │ • Patterns │ │ -│ └────┬───┘ └────┬───┘ │ (2000t) │ │ • Gotchas │ │ -│ │ │ └────┬─────┘ │ • Insights │ │ -│ │ │ │ │ • Outcomes │ │ -│ └───────────────┴─────────────────┴─────────────┤ │ │ -│ └──────────────┘ │ -│ ▲ │ │ -│ │ │ │ -│ ◄────────────────────────────────────┘ │ │ -│ Bidirectional: All phases read AND write│ │ -│ ────────────────────────────────────────► │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ - -Key Changes: -• Roadmap gets lightweight graph hints (constraints, existing features) -• Ideation gets graph hints (patterns to extend, features to avoid) -• Spec Creation gets full historical context (similar tasks, gotchas) -• QA outcomes feed back into memory for future specs -• Project-level group_id enables cross-spec learning -``` - -### Memory Data Types - -| Data Type | Written By | Read By | Storage | -|-----------|------------|---------|---------| -| **Session Insights** | Coder | Coder, Spec | Graphiti (primary) + File (lite mode) | -| **Codebase Map** | Coder | Coder, Spec | Graphiti (primary) + File (lite mode) | -| **Patterns** | Coder | Coder, Spec, Ideation | Graphiti (primary) + File (lite mode) | -| **Gotchas** | Coder, QA | Coder, Spec, Ideation | Graphiti (primary) + File (lite mode) | -| **QA Outcomes** | QA Loop | Spec (historical) | Graphiti (primary) + File (lite mode) | -| **Dismissed Ideas** | User (via ideation_runner) | Ideation | Graphiti (primary) + File (lite mode) | -| **Discarded Specs** | User (via run.py --discard) | Spec Creation, Ideation | Graphiti (primary) + File (lite mode) | -| **Complexity Calibration** | QA Loop (on complete) | Complexity Assessor | Graphiti (primary) + File (lite mode) | -| **Context Accuracy** | Coder (on complete) | Context Discovery | Graphiti (primary) + File (lite mode) | -| **Cross-Spec Index** | All writers | Historical Context | Graphiti (primary) + File (lite mode) | - -### Complete Learning Loops Summary - -The self-improving system relies on **15 feedback loops** that compound over time: - -| Loop | Signal Source | What's Learned | Benefit | -|------|---------------|----------------|---------| -| **1. Direct Learning** | Coder sessions | Patterns, gotchas, file purposes | Next session starts smarter | -| **2. QA Feedback** | QA rejections | Critical issues, missing patterns | Prevents repeated failures | -| **2.5. Ideation Preferences** | User dismissals | Feature types to avoid | More relevant suggestions | -| **3. Cross-Spec Learning** | Similar past tasks | Successful approaches | Faster implementation | -| **4. Statistical Learning** | 200+ specs data | Chunk complexity patterns | Better planning | -| **5. Architectural Learning** | File change tracking | Dependency graph | Impact prediction | -| **6. Discard Learning** | User --discard | Why specs fail | Earlier warnings | -| **7. Complexity Calibration** | Actual vs predicted | This codebase's complexity profile | Accurate estimates | -| **8. Spec Edit Learning** | User edits to spec.md | Spec writer gaps | Better specs | -| **9. Context Accuracy** | Files used vs included | What's actually relevant | Less noise, faster discovery | -| **10. Research Value** | Research used vs ignored | Valuable research queries | Focused research | -| **11. Recovery Learning** | Stuck chunk recovery | What fixes work here | Faster recovery | -| **12. Time Estimation** | Actual time spent | This project's pace | Realistic estimates | -| **13. Roadmap Priority** | User feature selection | Unstated priorities | Better prioritization | -| **14. Requirements Learning** | Q&A during gathering | Project conventions | Fewer clarifications needed | -| **15. Merge Follow-up** | Post-merge issues | Long-term regressions | Safer patterns | - -**Implementation Status:** -- ✅ Loops 1-5: Documented in this PRD (core implementation) -- 🆕 Loop 2.5: Added via ideation dismissal tracking -- 📋 Loops 6-15: Documented as future enhancements (Section 12.6.1) - -### Memory Storage Policy - -**Graphiti is the primary memory store.** File storage is an optional "lite mode" fallback for users who don't want Graphiti, or when Graphiti is unavailable. - -This ensures: -- Rich semantic retrieval is the default path (higher quality context) -- System can still function in a "lite mode" without Graphiti -- Clear operational fallback when Graphiti is unavailable (startup, outage, misconfig) - ---- - -## 1. Problem Statement - -### Current State - -The Auto Claude spec creation pipeline has **three disconnected context-gathering mechanisms**: - -1. **Project Discovery** (`analyzer.py`) - Static project structure analysis -2. **Context Discovery** (`context.py`) - Keyword-based file search -3. **Graphiti Memory** (`graphiti_memory.py`) - Semantic graph storage - -**Critical Gap:** Graphiti is only used during implementation (`agent.py`), NOT during spec creation when context matters most. Historical learnings (patterns that worked, gotchas to avoid, similar past tasks) are never surfaced to inform new specs. - -### Impact - -- Specs don't benefit from past project learnings -- Same mistakes get repeated across specs -- No semantic search during the most context-sensitive phase -- Cross-spec knowledge is siloed and inaccessible - -### Evidence - -| File | Current Behavior | -|------|------------------| -| `spec_runner.py` | No historical context phase | -| `context.py:227-279` | Pure keyword matching, no semantic search | -| `graphiti_memory.py:116-118` | Scoped to single spec via `group_id` | -| `prompts/spec_writer.md` | Only reads project_index, requirements, context - no history | - ---- - -## 2. Goals & Success Metrics - -### Goals - -1. **Primary:** Surface historical learnings during spec creation -2. **Secondary:** Enable cross-spec pattern discovery -3. **Tertiary:** Improve context.json quality with semantic hints - -### Success Metrics - -| Metric | Current | Target | -|--------|---------|--------| -| Specs using historical context | 0% | 100% (when history exists) | -| Cross-spec learning available | No | Yes | -| Context discovery keywords | Task-only | Task + historical hints | -| QA first-pass rate | Baseline | +15% improvement | -| Ideation acceptance rate | ~40% | +30% improvement (via dismissal learning) | - -### Non-Goals - -- Making file-based memory the primary system (it becomes a simpler fallback) -- Removing file-based storage entirely (lite mode remains available for users who opt out of Graphiti) -- Changing implementation phase behavior -- Auto-generating specs without user input - ---- - -## 2.5 Extended Scope: Ideation Phase Integration - -### Problem - -The Ideation system (`ideation_runner.py`) generates feature ideas across 7 categories: -- Low-Hanging Fruit -- UI/UX Improvements -- High-Value Features -- Documentation Gaps -- Security Hardening -- Performance Optimizations -- Code Quality - -**Current limitation:** Ideation agents analyze the codebase from scratch each time. They don't benefit from: -- Past implementation patterns that worked -- Known gotchas in this codebase -- Similar features that were already built -- What approaches failed before - -### Proposed Solution: Lightweight Graph Queries for Ideation - -Instead of loading full historical context (expensive, token-heavy), ideation agents should use **targeted graph queries** via Graphiti's semantic search. - -#### Why Graph Queries Work for Ideation - -1. **Token-Efficient:** Return only relevant nodes, not full documents -2. **Semantic Matching:** "payment flow" finds "Stripe checkout" even without keyword match -3. **Relationship Traversal:** Find connected patterns (auth → middleware → error handling) -4. **Pre-filtered:** Graph queries return ranked results, no post-processing needed - -#### Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ IDEATION PIPELINE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Phase 1: Project Analysis (existing) │ -│ └── project_index.json │ -│ │ -│ Phase 2: Context Gathering (existing) │ -│ └── ideation_context.json │ -│ │ -│ Phase 2.5: Graph Context Retrieval (NEW) ◄─────────────────────────┐ │ -│ │ │ │ -│ │ Query: "What patterns exist for {ideation_type}?" │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ Graphiti Graph Query (if enabled) │ │ │ -│ │ │ │ │ │ -│ │ │ • Search: patterns, gotchas, outcomes │ │ │ -│ │ │ • Filter: by ideation_type relevance │ │ │ -│ │ │ • Limit: 5 nodes max (token budget: 500) │ │ │ -│ │ │ • Format: bullet points, not full context │ │ │ -│ │ └─────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ └── ideation_hints.json (NEW, lightweight) │ │ -│ │ -│ Phase 3+: Ideation Agents (ENHANCED) │ -│ └── Now receive ideation_hints.json as optional input │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -#### Implementation: `ideation_hints.json` - -**Token Budget:** 500 tokens max (strict - ideation is already context-heavy) - -**Schema:** -```json -{ - "ideation_type": "low_hanging_fruit", - "relevant_patterns": [ - "CLI tools follow scripts/claude-tools/*.js pattern", - "API endpoints use middleware chain in routes/" - ], - "avoid_duplicating": [ - "Task filtering already exists (see 003-task-filters)", - "Dashboard refresh is implemented" - ], - "known_constraints": [ - "No direct database access from frontend", - "All API calls must be authenticated" - ], - "query_used": "low hanging fruit patterns existing CLI", - "sources": 3, - "token_count": 287 -} -``` - -#### Integration Points: Parallel Hint Retrieval - -**CRITICAL:** Ideation currently runs 7 agents in PARALLEL. The graph hints phase must preserve this parallelism. - -**File:** `ideation_runner.py` - -```python -async def phase_graph_hints_all(self) -> dict[str, IdeationHints]: - """ - Retrieve hints for ALL ideation types in parallel. - This runs ONCE before ideation agents, not per-agent. - """ - if not is_graphiti_enabled(): - return {} # Empty hints - agents proceed without - - # Type-specific queries - type_queries = { - "low_hanging_fruit": "quick wins existing patterns CLI tools utilities", - "ui_ux_improvements": "UI components styling accessibility user experience", - "high_value_features": "major features integrations user value", - "security_hardening": "security authentication authorization vulnerabilities", - "performance_optimizations": "performance caching optimization slow queries", - "code_quality": "refactoring patterns code smells technical debt", - "documentation_gaps": "documentation comments API docs missing", - } - - # PARALLEL queries - all 7 run simultaneously - async with asyncio.TaskGroup() as tg: - tasks = { - itype: tg.create_task(self._get_type_hints(query)) - for itype, query in type_queries.items() - } - - # Collect results - all_hints = {} - for itype, task in tasks.items(): - hints = await task - if hints: # Only include if we got relevant results - all_hints[itype] = hints - # Write per-type hints file - self._write_hints_file(itype, hints) - - return all_hints - - -async def _get_type_hints(self, query: str) -> IdeationHints | None: - """Get hints for a single ideation type.""" - results = await self.graphiti.search_project_learnings(query) - - # Filter by relevance (0.5 threshold, same as spec creation) - relevant = [r for r in results if r.get("relevance", 0) >= 0.5] - - if not relevant: - return None - - return IdeationHints( - relevant_patterns=[r["content"] for r in relevant if r["type"] == "pattern"], - avoid_duplicating=[r["content"] for r in relevant if r["type"] == "feature"], - known_constraints=[r["content"] for r in relevant if r["type"] == "constraint"], - ) -``` - -**Execution Flow (Preserving Parallelism):** - -``` -Phase 1: Project Analysis - │ - ▼ -Phase 2: Context Gathering - │ - ▼ -Phase 2.5: Graph Hints (PARALLEL queries, ~1.5s total) - │ - ├── Query: low_hanging_fruit ────┐ - ├── Query: ui_ux ────────────────┤ - ├── Query: high_value ───────────┤ - ├── Query: security ─────────────┼──→ All 7 queries run simultaneously - ├── Query: performance ──────────┤ - ├── Query: code_quality ─────────┤ - └── Query: documentation ────────┘ - │ - ▼ -Phase 3+: Ideation Agents (PARALLEL, unchanged) - │ - ├── low_hanging_fruit_agent (reads its hints file) - ├── ui_ux_agent (reads its hints file) - ├── high_value_agent (reads its hints file) - ├── security_agent (reads its hints file) - ├── performance_agent (reads its hints file) - ├── code_quality_agent (reads its hints file) - └── documentation_agent (reads its hints file) -``` - -**Latency Impact:** -- Sequential approach (WRONG): 7 × 1-2s = 7-14s -- Parallel approach (CORRECT): ~1.5s (queries run simultaneously) - -**Prompt Updates:** - -Add to each ideation prompt (e.g., `ideation_low_hanging_fruit.md`): - -```markdown -## OPTIONAL: Graph Hints - -If `ideation_hints_{type}.json` exists, read it first: -- **relevant_patterns**: Patterns that worked in this codebase -- **avoid_duplicating**: Features that already exist (don't re-suggest) -- **known_constraints**: Technical limitations to respect - -This is OPTIONAL context - if the file doesn't exist, proceed without it. -``` - -#### Ideation Retrieval: Relevance-Based (Not Token-Limited) - -Like spec creation, ideation uses **relevance-based retrieval** with the 0.5 threshold: - -| Scenario | Behavior | -|----------|----------| -| No relevant patterns | Empty hints file - agent proceeds fresh | -| 3 relevant patterns | Returns those 3 - no padding | -| 20 relevant patterns | Returns all 20 - doesn't truncate | -| Graphiti disabled | No hints files created - agents proceed without | - -#### Ideation Dismissal Learning (Self-Improvement Loop) - -**Problem:** When a user dismisses an ideation suggestion, that valuable feedback is lost. The ideation_runner will potentially suggest similar ideas again in future runs. - -**Solution:** Track dismissed ideas in memory so ideation agents learn from user preferences over time. - -##### How It Works - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ IDEATION DISMISSAL FEEDBACK LOOP │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. User reviews ideation output │ -│ └── Dismisses idea: "Add GraphQL API layer" │ -│ └── Optional: Provides reason (too complex, not needed, etc.) │ -│ │ -│ 2. Ideation Runner records dismissal │ -│ └── Stores in Graphiti (primary) or file (lite mode): │ -│ • dismissed_idea: "Add GraphQL API layer" │ -│ • ideation_type: "high_value_features" │ -│ • reason: "too_complex" (optional) │ -│ • timestamp: 2025-12-11T10:30:00Z │ -│ • dismissed_count: 1 (increments if similar dismissed again) │ -│ │ -│ 3. Next ideation run │ -│ └── Phase 2.5 (Graph Hints) queries dismissed ideas │ -│ └── ideation_hints.json includes "previously_dismissed" field │ -│ │ -│ 4. Ideation agents receive dismissal context │ -│ └── Avoid suggesting: │ -│ • Exact matches to dismissed ideas │ -│ • Similar ideas (semantic match > 0.8) │ -│ • Ideas in same category if repeatedly dismissed │ -│ │ -│ 5. Result: Increasingly personalized suggestions │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -##### Implementation: Dismissal Recording - -**File:** `ideation_runner.py` - -```python -async def record_dismissed_idea( - self, - idea_title: str, - idea_description: str, - ideation_type: str, - reason: str | None = None -) -> None: - """ - Record a dismissed idea to memory for future ideation runs. - - Args: - idea_title: The title of the dismissed idea - idea_description: Full description of the idea - ideation_type: Category (e.g., "low_hanging_fruit", "security_hardening") - reason: Optional user-provided reason for dismissal - """ - dismissal = { - "title": idea_title, - "description": idea_description, - "ideation_type": ideation_type, - "reason": reason, - "timestamp": datetime.now().isoformat(), - } - - if is_graphiti_enabled(): - await self.graphiti.add_episode( - name=f"Dismissed Idea: {idea_title}", - episode_body=json.dumps(dismissal), - source=EpisodeType.text, - group_id=self.project_group_id, # Project-level for cross-run learning - metadata={ - "type": "dismissed_idea", - "ideation_type": ideation_type, - "reason": reason or "not_specified" - } - ) - - # Also append to lite mode file for fallback - dismissed_file = self.spec_dir / "dismissed_ideas.json" - existing = json.loads(dismissed_file.read_text()) if dismissed_file.exists() else [] - existing.append(dismissal) - dismissed_file.write_text(json.dumps(existing, indent=2)) -``` - -##### Updated `ideation_hints.json` Schema - -```json -{ - "ideation_type": "low_hanging_fruit", - "relevant_patterns": [ - "CLI tools follow scripts/claude-tools/*.js pattern" - ], - "avoid_duplicating": [ - "Task filtering already exists (see 003-task-filters)" - ], - "previously_dismissed": [ - { - "title": "Add keyboard shortcuts everywhere", - "reason": "too_complex", - "dismissed_at": "2025-12-01", - "times_dismissed": 2 - }, - { - "title": "Migrate to TypeScript strict mode", - "reason": "breaking_change", - "dismissed_at": "2025-12-05", - "times_dismissed": 1 - } - ], - "known_constraints": [ - "No direct database access from frontend" - ], - "query_used": "low hanging fruit patterns existing CLI", - "sources": 3, - "token_count": 342 -} -``` - -##### Dismissal Reasons (Standardized) - -| Reason Code | Meaning | Ideation Should Learn | -|-------------|---------|----------------------| -| `too_complex` | Too much effort for now | Suggest simpler alternatives | -| `not_needed` | Feature not valuable for this project | Avoid similar suggestions | -| `breaking_change` | Would break existing functionality | Avoid disruptive ideas | -| `already_planned` | Already in roadmap elsewhere | Check roadmap.json first | -| `out_of_scope` | Doesn't fit project goals | Learn project boundaries | -| `duplicate` | Already exists (missed by system) | Improve duplicate detection | -| `deferred` | Good idea, but not now | Re-suggest after N months | -| `other` | Unspecified | Record but don't over-weight | - -##### Dismissal Query in Graph Hints - -```python -async def _get_type_hints(self, query: str, ideation_type: str) -> IdeationHints | None: - """Get hints for a single ideation type, including dismissed ideas.""" - - # Get positive hints (patterns, existing features) - pattern_results = await self.graphiti.search_project_learnings(query) - - # Get dismissed ideas for this type - dismissed_query = f"dismissed ideas {ideation_type}" - dismissed_results = await self.graphiti.search( - query=dismissed_query, - group_ids=[self.project_group_id], - num_results=10 - ) - - dismissed_ideas = [ - { - "title": r.get("title"), - "reason": r.get("reason"), - "dismissed_at": r.get("timestamp"), - "times_dismissed": r.get("dismissed_count", 1) - } - for r in dismissed_results - if r.get("metadata", {}).get("type") == "dismissed_idea" - ] - - return IdeationHints( - relevant_patterns=[...], - avoid_duplicating=[...], - previously_dismissed=dismissed_ideas, # NEW - known_constraints=[...] - ) -``` - -##### Prompt Update for Ideation Agents - -Add to all 7 ideation prompts (`ideation_*.md`): - -```markdown -### previously_dismissed -Ideas the user has dismissed in past ideation runs. - -**Rules:** -1. **Never suggest exact matches** - User already said no -2. **Avoid similar ideas** - If "Add GraphQL API" was dismissed, don't suggest "Implement GraphQL endpoints" -3. **Learn from reasons:** - - `too_complex` → Suggest simpler approaches to same problem - - `not_needed` → This category may not fit the project - - `breaking_change` → Prefer non-breaking improvements - - `deferred` → Can mention as "previously considered for later" -4. **Weight by times_dismissed** - Ideas dismissed 2+ times are strong signals -5. **Respect recency** - Ideas dismissed >6 months ago may be worth reconsidering with fresh framing -``` - -##### CLI Integration - -```bash -# Interactive ideation review with dismissal tracking -python auto-claude/ideation_runner.py --review - -# Output: -# [1] Add dark mode toggle (UI/UX) -# > Accept (a) | Dismiss (d) | Skip (s): d -# > Reason (optional): too_complex -# > ✓ Recorded dismissal - will avoid similar suggestions - -# View dismissal history -python auto-claude/ideation_runner.py --dismissed - -# Output: -# Dismissed Ideas (12 total): -# - "Add GraphQL API" (high_value) - dismissed 2x, reason: too_complex -# - "Migrate to TypeScript strict" (code_quality) - dismissed 1x, reason: breaking_change -# ... - -# Clear dismissal history (fresh start) -python auto-claude/ideation_runner.py --clear-dismissed -``` - -##### Token Budget Update - -The `previously_dismissed` field should stay within the 500-token budget for ideation hints: -- Max 5 dismissed ideas per type (most recent/most dismissed) -- Truncate descriptions to 50 chars -- Only include reason and times_dismissed - -**Key Insight:** Ideation doesn't need full context. It needs: -1. What patterns exist (so ideas extend them) -2. What's already done (so ideas don't duplicate) -3. What constraints exist (so ideas are feasible) - -These are **pointer queries**, not **document retrievals**. - ---- - -## 3. Proposed Solution - -### Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ SPEC CREATION PIPELINE │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Phase 1: Discovery (existing) │ -│ └── project_index.json │ -│ │ -│ Phase 2: Requirements (existing) │ -│ └── requirements.json │ -│ │ -│ Phase 3: Historical Context (NEW) ◄───────────────────────────────┐ │ -│ ├── File-based memory search (fast, always available) │ │ -│ ├── Graphiti semantic search (if enabled) │ │ -│ └── historical_context.json (NEW output) │ │ -│ │ │ -│ Phase 4: Context Discovery (ENHANCED) │ │ -│ ├── Keywords from task (existing) │ │ -│ ├── Keywords from historical_context (NEW) ◄──────────────────┘ │ -│ └── context.json (enhanced) │ -│ │ -│ Phase 5+: Spec Writing, Planning, Validation (existing) │ -│ └── Now receives historical_context.json as input │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - - FEEDBACK LOOP -┌─────────────────────────────────────────────────────────────────────────┐ -│ Implementation completes → QA validates → Outcome recorded │ -│ ↓ │ -│ ┌────────────────────────────────┐ │ -│ │ Project-Level Memory Store │ │ -│ │ - Patterns that worked │ │ -│ │ - Gotchas encountered │ │ -│ │ - QA pass/fail outcomes │ │ -│ └────────────────────────────────┘ │ -│ ↓ │ -│ Available for NEXT spec creation │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### Key Components - -#### 3.1 Historical Context Phase (NEW) - -**Location:** `spec_runner.py` - new phase between requirements and context - -**Behavior:** -1. Search file-based memory across ALL specs in project -2. If Graphiti enabled, perform semantic search -3. Merge, deduplicate, and truncate to token budget -4. Output `historical_context.json` - -**Output Schema:** -```json -{ - "similar_past_tasks": [ - { - "spec_name": "003-user-auth", - "task_description": "Add JWT authentication", - "outcome": "passed", - "relevance_score": 0.85 - } - ], - "patterns_that_worked": [ - "Use middleware for auth checks, not inline validation", - "Always hash passwords with bcrypt, never store plain" - ], - "gotchas_to_avoid": [ - "Database connections must be closed in worker threads", - "API rate limits: 100 req/min per IP" - ], - "recommended_approach": "Based on similar task 003-user-auth, start with middleware setup before routes", - "token_count": 1847 -} -``` - -#### 3.2 Project-Level Memory Scope (ENHANCEMENT) - -**Location:** `graphiti_memory.py` - extend existing class - -##### Dual-Level Group ID Strategy - -##### Project Identity (REQUIRED) - -**Problem:** `project_root.name` is not globally unique (two repos can both be named `app`), which risks cross-project contamination. - -**Decision:** Each project gets a stable `project_id` (UUID) stored in `project_index.json` and reused across all runs. - -**Implementation Notes:** -- `analyzer.py` should write `project_id` to `project_index.json` if missing. -- `analyzer.py` must preserve an existing `project_id` if it already exists. -- `project_id` is the canonical identifier for Graphiti group IDs and on-disk lite-mode caches. - -**Group ID Structure:** -```python -# Project level - for cross-spec queries (historical context, ideation) -project_group_id = f"project:{project_id}" -# Example: "project:2f2d5bd8-55a3-4c33-9f4d-48f7d0b3d0a7" - -# Spec level - for spec-specific queries (coder sessions) -spec_group_id = f"spec:{project_id}:{spec_dir.name}" -# Example: "spec:2f2d5bd8-55a3-4c33-9f4d-48f7d0b3d0a7:005-payment-flow" -``` - -**Dual-Write Pattern:** -Every memory write includes BOTH group IDs to enable both scopes: -```python -await graphiti.add_episode( - content=learning, - group_ids=[self.project_group_id, self.spec_group_id], - metadata={"project_id": project_id, "spec": spec_name, "session": session_num} -) -``` - -**Query Patterns:** -| Use Case | Group ID | Example Query | -|----------|----------|---------------| -| Historical context (new spec) | `project_group_id` | "What patterns exist for auth?" | -| Coder reading current spec | `spec_group_id` | "What did session 2 learn?" | -| QA checking similar failures | `project_group_id` | "How did similar specs fail?" | -| Ideation avoiding duplicates | `project_group_id` | "What features already exist?" | -| Cross-spec pattern discovery | `project_group_id` | "What middleware patterns work?" | - -**Implementation:** -```python -class GraphitiMemory: - @property - def project_group_id(self) -> str: - """Project-level group for cross-spec searches.""" - return f"project:{self.project_id}" - - @property - def spec_group_id(self) -> str: - """Spec-level group for current spec searches.""" - return f"spec:{self.project_id}:{self.spec_dir.name}" - - async def search_project_learnings(self, query: str) -> list[dict]: - """Search across ALL specs in the project. No artificial limit.""" - results = await self._graphiti.search( - query=query, - group_ids=[self.project_group_id], - num_results=50, # Get many, then filter by relevance - ) - # Filter by relevance threshold (0.5) - see Section 4.1 - return [r for r in results if r.get("relevance", 0) >= 0.5] - - async def save_learning(self, content: str, learning_type: str, metadata: dict): - """Save to BOTH project and spec level.""" - await self._graphiti.add_episode( - content=content, - group_ids=[self.project_group_id, self.spec_group_id], - metadata={**metadata, "type": learning_type} - ) -``` - -#### 3.3 File-Based Cross-Spec Search (NEW) - -**Location:** `memory.py` - new functions - -**Purpose:** Enable a "lite mode" experience WITHOUT Graphiti (simpler + dumber fallback). - -##### Lite Mode Scope (INTENTIONALLY LIMITED) - -Lite mode should ONLY read a small set of canonical files: -- `memory/patterns.md` -- `memory/gotchas.md` -- `memory/qa_outcomes/*.json` -- `memory/session_insights/*.json` (optional, last N only) - -Lite mode MUST NOT attempt full semantic recall. It is keyword-only and best-effort. - -**Implementation:** -```python -def search_project_memory( - project_dir: Path, - query: str, - limit: int = 10, -) -> dict: - """ - Search memory across all specs in the project. - - Returns aggregated patterns, gotchas, and insights - that match the query keywords. - """ - specs_dir = project_dir / "auto-claude" / "specs" - results = { - "patterns": [], - "gotchas": [], - "similar_tasks": [], - } - - for spec_dir in specs_dir.iterdir(): - if not spec_dir.is_dir(): - continue - # Search this spec's memory - # Aggregate results - - return results -``` - -#### 3.4 Outcome Recording (NEW) - -**Location:** `qa_loop.py`, `memory.py` - -**Purpose:** Record QA pass/fail to inform future specs - -**Trigger:** After QA validation completes (either pass or final rejection) - -##### Extraction Process - -QA outcome recording happens in TWO stages: - -**Stage 1: Automatic Extraction (MVP)** -```python -# In qa_loop.py - after QA completes -async def record_qa_outcome(spec_dir: Path, qa_result: QAResult): - outcome = { - "spec_name": spec_dir.name, - "outcome": qa_result.status, # "passed" | "failed" | "partial" - "qa_attempts": qa_result.attempt_count, - "timestamp": datetime.now(timezone.utc).isoformat(), - - # Automatic extraction from QA artifacts: - "issues_found": parse_issues_from_fix_request(spec_dir), # From QA_FIX_REQUEST.md - "files_changed": get_changed_files(spec_dir), # From git diff - "tests_status": qa_result.test_results, # From test runs - } - - # Write to Graphiti (primary) - if graphiti_enabled(): - await graphiti.save_learning( - content=format_outcome_for_graphiti(outcome), - learning_type="qa_outcome", - metadata=outcome - ) - - # Optional: write to file ("lite mode") for users who opt out of Graphiti - if lite_mode_enabled() or not graphiti_enabled(): - write_qa_outcome(spec_dir, outcome) -``` - -**Stage 2: Pattern Extraction (Enhanced - Phase 4)** - -After automatic recording, extract learnable patterns: -```python -def extract_learnings_from_outcome(outcome: dict, qa_report: str) -> dict: - """Extract patterns and gotchas from QA outcome.""" - - learnings = { - "patterns_validated": [], # What worked - "gotchas_discovered": [], # What failed - } - - if outcome["outcome"] == "passed": - # Extract patterns from successful implementation - # Look at what approaches the coder used - learnings["patterns_validated"] = extract_patterns_from_commits(outcome) - - if outcome["issues_found"]: - # Convert issues to gotchas for future specs - for issue in outcome["issues_found"]: - gotcha = { - "description": issue["description"], - "file_pattern": issue.get("file"), - "prevention": issue.get("fix_description"), - } - learnings["gotchas_discovered"].append(gotcha) - - return learnings -``` - -##### Data Captured - -**File: `memory/qa_outcomes/{spec_name}.json`** -```json -{ - "spec_name": "005-payment-flow", - "outcome": "passed", - "qa_attempts": 2, - "timestamp": "2025-12-11T10:30:00Z", - - "issues_found": [ - { - "description": "Missing auth middleware on /api/payments", - "file": "routes/payments.py", - "severity": "critical", - "fix_description": "Added @require_auth decorator" - } - ], - - "patterns_validated": [ - "Webhook handler pattern from spec-007", - "Idempotency key usage for Stripe calls" - ], - - "gotchas_discovered": [ - { - "description": "API routes need @require_auth decorator", - "file_pattern": "routes/*.py", - "prevention": "Check all new routes for auth middleware" - } - ], - - "files_changed": ["routes/payments.py", "services/stripe.py"], - "tests_passed": 45, - "tests_failed": 0 -} -``` - -##### Feedback Loop - -``` -QA Rejection → Issue extracted → Gotcha created → Future coder warned - ↓ - "Missing auth on /api/payments" - ↓ - gotchas.md += "API routes need @require_auth" - ↓ - Graphiti += Episode("QA: auth missing", confidence=HIGH) - ↓ - NEXT SPEC: historical_context includes this gotcha - ↓ - NEXT CODER: Pre-implementation checklist warns about auth -``` - -#### 3.5 Enhanced Prompt Templates - -**Files to Update:** -- `prompts/spec_writer.md` - Add historical_context section -- `prompts/spec_gatherer.md` - Surface relevant learnings to user -- `prompts/planner.md` - Consider past approaches - -**Example Addition to spec_writer.md:** -```markdown -## PHASE 0: LOAD ALL CONTEXT (MANDATORY) - -```bash -# Read all input files -cat project_index.json -cat requirements.json -cat context.json -cat historical_context.json # NEW -``` - -Extract from historical_context.json: -- **Similar past tasks**: What specs solved similar problems? -- **Patterns that worked**: What approaches succeeded? -- **Gotchas to avoid**: What pitfalls were discovered? -- **Recommended approach**: Suggested starting point -``` - -#### 3.6 Memory Operations Matrix - -This matrix defines WHO reads/writes WHAT memory and WHEN: - -| Agent/Phase | READS | WRITES | Timing | -|-------------|-------|--------|--------| -| **Spec Runner: Historical Context** | All `memory/` across specs | `historical_context.json` | Sync (before context phase) | -| **Spec Gatherer** | `historical_context.json` | `requirements.json` | Sync | -| **Spec Writer** | `requirements.json`, `context.json`, `historical_context.json` | `spec.md` | Sync | -| **Planner** | `spec.md`, `context.json`, `historical_context.json` | `implementation_plan.json` | Sync | -| **Coder Session** | `memory/*`, `spec.md`, `implementation_plan.json` | `memory/session_*.json`, `codebase_map.json`, `patterns.md`, `gotchas.md` | End of session | -| **QA Reviewer** | `spec.md`, code changes, `memory/*` | `qa_report.md` | Sync | -| **QA Loop (on complete)** | `qa_report.md`, `QA_FIX_REQUEST.md` | `memory/qa_outcomes/*.json`, `gotchas.md` (append) | On QA resolution | -| **Ideation Agent** | `ideation_hints.json` (if exists) | `*_ideas.json` | Sync | -| **Ideation Review (User)** | `*_ideas.json` | `dismissed_ideas.json`, Graphiti (dismissed_idea nodes) | On user dismissal | - -**Write Timing Definitions:** -- `Sync`: Written before phase completes, blocks next phase -- `End of session`: Written after coder commits changes -- `On QA resolution`: Written when QA loop resolves (pass or final fail) - -**Deduplication Rules:** -Before appending to `patterns.md` or `gotchas.md`: -1. Check if similar entry exists (fuzzy match > 80% similarity) -2. If similar: Update timestamp and increment `reference_count` -3. If new: Append with `reference_count: 1` - -#### 3.7 Prompt Integration Specifications - -Each prompt must know HOW to use historical context, not just THAT it exists. - -##### For `spec_writer.md`: - -```markdown -## Historical Context Integration - -If `historical_context.json` exists and has content: - -### Using Relevance Scores -Each item has a `relevance` score (0.0 - 1.0): -- **>= 0.7**: Directly applicable. Follow this pattern/avoid this gotcha explicitly. -- **0.5 - 0.7**: Related context. Consider adapting, mention in spec if relevant. -- **< 0.5**: Not included (filtered out by retrieval system). - -### When Patterns Conflict with Requirements -If a historical pattern conflicts with current requirements: -1. **Requirements win** - they represent current user intent -2. **Document the deviation**: Add to spec: "NOTE: Deviating from pattern [X] because [requirement Y]..." -3. This becomes a learning opportunity - future specs may reference both - -### Incorporating Gotchas -For each gotcha in `gotchas_to_avoid`: -1. Add to "Acceptance Criteria" if testable -2. Add to "Testing Requirements" with specific test case -3. Add to "Implementation Notes" with mitigation approach - -### Using Similar Past Tasks -Reference similar specs when: -- Explaining architectural decisions -- Justifying technology choices -- Providing implementation examples -``` - -##### For `coder.md`: - -```markdown -## Session Memory Protocol (MANDATORY) - -Before implementing ANY chunk, read in this order: - -1. **gotchas.md** - CRITICAL: Known landmines in this codebase - - If ANY gotcha mentions files you're touching → address EXPLICITLY - - Add code comment: `// Gotcha from spec-XXX: [description]` - -2. **patterns.md** - Established patterns that work here - - Follow these unless requirements explicitly conflict - - If you deviate, document WHY in commit message - -3. **codebase_map.json** - File purposes and relationships - - Check before creating new files (might already exist) - - Understand dependencies before modifying - -4. **session_insights/*.json** - What previous sessions learned - - Especially read the most recent session - - Look for "blockers" and "discoveries" - -### Writing Session Insights - -At session end, write to `memory/session_{N}.json`: -```json -{ - "session_number": N, - "chunks_completed": ["chunk-1-2"], - "patterns_discovered": ["Description of new pattern"], - "gotchas_found": ["Description of gotcha + file"], - "codebase_map_updates": {"new_file.py": "Purpose description"}, - "blockers_hit": ["What blocked you and how you resolved"], - "next_session_should_know": "Key insight for handoff" -} -``` -``` - -##### For `planner.md`: - -```markdown -## Historical Context in Planning - -If `historical_context.json` exists: - -### Chunk Ordering -- If similar past task exists with `outcome: passed`, consider similar chunk order -- If gotchas mention specific files, front-load chunks that touch those files -- High-relevance patterns should inform chunk dependencies - -### Estimating Complexity -For each chunk, check: -- Does it touch files mentioned in gotchas? → Higher complexity -- Does it follow a validated pattern? → Lower complexity -- Is it similar to a failed past approach? → Document risk - -### patterns_from Field -Use historical context to populate `patterns_from`: -- If pattern exists for this type of chunk → reference it -- If similar spec succeeded → reference its key files -``` - -##### For Ideation Prompts (all 7): - -```markdown -## Optional: Graph Hints Integration - -If `ideation_hints.json` exists, read it FIRST: - -### avoid_duplicating -Features that already exist. DO NOT suggest these again. -Cross-reference your ideas against this list. - -### relevant_patterns -Patterns established in this codebase. Your ideas should: -- EXTEND these patterns (not contradict them) -- BUILD ON existing infrastructure -- FOLLOW established conventions - -### known_constraints -Technical limitations. Filter out ideas that violate these. -Example: "No direct database access from frontend" → Don't suggest frontend DB queries - -### If hints file doesn't exist -Proceed without it - this is a cold start or Graphiti is disabled. -``` - ---- - -## 4. Technical Design - -### 4.1 Relevance-Based Context Retrieval - -**Core Principle:** The memory system doesn't decide how much context to include. It retrieves what's relevant based on semantic similarity, and **stops when relevance naturally drops off**. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ RELEVANCE-BASED RETRIEVAL │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Query: "Add payment processing with Stripe" │ -│ │ -│ Results (ranked by relevance): │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 0.92 │ Spec-007: Stripe checkout implementation │ ✓ │ -│ │ 0.87 │ Pattern: Use idempotency keys for payment APIs │ ✓ │ -│ │ 0.81 │ Gotcha: Webhook signatures must be verified │ ✓ │ -│ │ 0.74 │ Spec-012: Subscription billing with Stripe │ ✓ │ -│ │ 0.68 │ Pattern: All financial operations need audit logs │ ✓ │ -│ │ 0.52 │ Gotcha: Rate limiting on third-party APIs │ ✓ │ -│ │─────────────────────────────────────────────────────────────────│ │ -│ │ 0.41 │ Spec-003: User authentication (weak relation) │ ✗ │ -│ │ 0.35 │ Pattern: API error handling (generic) │ ✗ │ -│ │ 0.22 │ Gotcha: Database connection pooling │ ✗ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ -│ Cutoff at 0.5 → Natural stopping point based on relevance drop-off │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -**Why This Works:** -- No arbitrary token limits that might cut off important context -- No padding with irrelevant content when little history exists -- Natural adaptation: rich history → rich context; sparse history → sparse context -- The consuming agent receives WHAT'S RELEVANT, not a fixed quota - -#### 4.1.1 Relevance Scoring - -**Score Components (0.0 - 1.0):** - -| Component | Weight | Source | Rationale | -|-----------|--------|--------|-----------| -| Semantic similarity | 0.5 | Graphiti embedding distance | Core relevance signal | -| Outcome success | 0.25 | 1.0=passed, 0.6=partial, 0.2=failed | Successful patterns more valuable | -| Recency | 0.15 | Decay over 180 days, floor at 0.3 | Recent learnings more applicable | -| Reference count | 0.10 | How often this was useful before | Social proof from past use | - -```python -def calculate_relevance(result: MemoryResult, query: str) -> float: - semantic = result.embedding_similarity # From Graphiti - outcome = {"passed": 1.0, "partial": 0.6, "failed": 0.2}.get(result.outcome, 0.5) - days_old = (now() - result.timestamp).days - recency = max(0.3, 1.0 - (days_old / 180)) - references = min(1.0, result.reference_count / 10) - - return (0.5 * semantic + 0.25 * outcome + 0.15 * recency + 0.10 * references) -``` - -#### 4.1.1.1 Confidence Scoring (for conflict resolution) - -Relevance answers: "Is this about the current query?" - -Confidence answers: "Should we trust/recommend this as a default?" - -**Confidence Components (0.0 - 1.0):** - -| Component | Weight | Source | Rationale | -|-----------|--------|--------|-----------| -| Outcome success rate | 0.5 | Aggregated from `qa_outcome` episodes | Patterns validated by passing QA are more reliable | -| Recency | 0.2 | Decay over 365 days, floor at 0.2 | Old patterns may no longer match code | -| Reference count | 0.2 | How often referenced/used | Frequently reused patterns are likely stable | -| Severity penalty | 0.1 | Gotchas severity (critical/high/med/low) | High severity gotchas should surface with high confidence | - -**Use in output:** -- Include `confidence` on pattern/gotcha items. -- When two items conflict, select winner by confidence (Section 8) but keep both when close. - -#### 4.1.2 Relevance Thresholds - -| Threshold | Action | Rationale | -|-----------|--------|-----------| -| >= 0.7 | Include in full | Highly relevant, direct applicability | -| 0.5 - 0.7 | Include with summary | Related, useful as reference | -| < 0.5 | Exclude | Below noise floor, not worth context space | - -**The 0.5 threshold is the only "magic number"** - and it's based on the principle that below 50% similarity, content is more noise than signal. - -#### 4.1.3 Adaptive Behavior - -The system naturally adapts without hardcoded limits: - -| Scenario | Behavior | -|----------|----------| -| First spec (no history) | Returns empty context - nothing above threshold | -| 5 specs, 2 relevant | Returns those 2 - no padding with irrelevant content | -| 50 specs, 15 relevant | Returns all 15 - doesn't artificially truncate | -| Generic task, few matches | Sparse context - system acknowledges limited history | -| Specific task, many matches | Rich context - full historical depth available | - -#### 4.1.4 Graphiti Native Search Capabilities - -Graphiti provides built-in hybrid search and reranking. The system MUST leverage these capabilities rather than implementing redundant retrieval logic. - -**Built-in Capabilities (used by default `search()` method):** -- Hybrid search: Combines semantic similarity (embeddings) + BM25 keyword matching -- RRF (Reciprocal Rank Fusion): Fuses scores from both retrieval methods - -**Advanced Search Options (via `search_()` method with config recipes):** - -| Recipe | Method | Use Case | Latency | -|--------|--------|----------|---------| -| `NODE_HYBRID_SEARCH_RRF` | RRF reranking | Node/entity search | Fast | -| `EDGE_HYBRID_SEARCH_RRF` | RRF reranking | Relationship/fact search | Fast | -| `COMBINED_HYBRID_SEARCH_CROSS_ENCODER` | Neural cross-encoder | High-accuracy retrieval | Slower | - -**Node Distance Reranking (via `center_node_uuid` parameter):** -- Reranks results by graph proximity to a focal entity -- Use case: "Find patterns related to THIS specific file/component" - -**Search Filters (via `SearchFilters` class):** -- `entity_labels`: Filter by node type (e.g., "Pattern", "Gotcha", "SessionInsight") -- `valid_after` / `valid_before`: Date range filtering -- Use case: "Recent patterns only" or "Patterns for authentication components" - -##### 4.1.4.1 Search Method Selection - -| Context | Search Method | Rationale | -|---------|---------------|-----------| -| **Spec Creation: Historical Context** | `COMBINED_HYBRID_SEARCH_CROSS_ENCODER` | Quality > speed; runs once per spec | -| **Ideation: Graph Hints** | `NODE_HYBRID_SEARCH_RRF` | Speed matters; 7 parallel agents | -| **Implementation: Chunk Context** | `search()` with `center_node_uuid` | Find patterns related to current file | -| **QA: Similar Issues** | `EDGE_HYBRID_SEARCH_RRF` | Find related facts/outcomes | - -##### 4.1.4.2 Implementation Requirements - -**Required imports for `graphiti_memory.py`:** - -```python -from graphiti_core.search.search_config_recipes import ( - COMBINED_HYBRID_SEARCH_CROSS_ENCODER, - NODE_HYBRID_SEARCH_RRF, - EDGE_HYBRID_SEARCH_RRF, -) -from graphiti_core.search.search_filters import SearchFilters -``` - -**New methods to add:** - -```python -async def search_high_quality( - self, - query: str, - num_results: int = 15, -) -> list[dict]: - """ - High-accuracy search using cross-encoder reranking. - Use for spec creation historical context where quality > speed. - """ - config = COMBINED_HYBRID_SEARCH_CROSS_ENCODER.model_copy(deep=True) - config.limit = num_results - - results = await self._graphiti.search_( - query=query, - config=config, - group_ids=[self.group_id] - ) - return self._process_results(results) - -async def search_related_to_entity( - self, - query: str, - center_node_uuid: str, - num_results: int = 10, -) -> list[dict]: - """ - Search with node distance reranking from a focal entity. - Use during implementation to find patterns related to current file. - """ - results = await self._graphiti.search( - query=query, - center_node_uuid=center_node_uuid, - group_ids=[self.group_id], - num_results=num_results - ) - return self._process_results(results) - -async def search_with_filters( - self, - query: str, - entity_labels: list[str] | None = None, - days_back: int | None = None, - num_results: int = 10, -) -> list[dict]: - """ - Search with type and/or date filters. - Use for targeted retrieval (e.g., "only patterns", "recent gotchas"). - """ - filters = SearchFilters() - if entity_labels: - filters.entity_labels = entity_labels - if days_back: - filters.valid_after = datetime.now(timezone.utc) - timedelta(days=days_back) - - results = await self._graphiti.search( - query=query, - group_ids=[self.group_id], - num_results=num_results, - search_filter=filters - ) - return self._process_results(results) -``` - -##### 4.1.4.3 Relevance Scoring: Graphiti + Domain Signals - -Graphiti handles the core retrieval and reranking. Our relevance formula **augments** Graphiti's scores with domain-specific signals: - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ RETRIEVAL PIPELINE │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Step 1: Graphiti Native Search (handles hybrid + reranking) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ • Semantic similarity (embeddings) │ │ -│ │ • BM25 keyword matching │ │ -│ │ • RRF or Cross-Encoder fusion │ │ -│ │ • Returns: results with embedding_similarity scores │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ Step 2: Domain Signal Augmentation (our layer) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ final_relevance = ( │ │ -│ │ 0.5 * graphiti_score + # Already fused/reranked │ │ -│ │ 0.25 * outcome_success + # Did this pattern work? │ │ -│ │ 0.15 * recency + # How recent? │ │ -│ │ 0.10 * reference_count # Social proof │ │ -│ │ ) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ Step 3: Threshold Filter (0.5 cutoff) │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -**Key Insight:** We do NOT add another reranking layer. Graphiti already handles that. We only add domain signals (outcome, recency, references) that Graphiti doesn't know about. - -### 4.2 Graceful Degradation - -| Scenario | Behavior | -|----------|----------| -| No historical data exists | Create placeholder file with `status: cold_start` | -| Graphiti disabled (user opt-out) | Use file-based keyword search only ("lite mode") | -| Graphiti connection fails | Fall back to file-based ("lite mode"), log warning | -| Graphiti timeout (5s) | Return partial Graphiti results + file-based fallback | -| All results below threshold | Return empty context with `status: no_relevant_history` | - -**Placeholder for Cold Start:** -```json -{ - "status": "cold_start", - "message": "No historical data available. This is spec #1 for this project.", - "similar_past_tasks": [], - "patterns_that_worked": [], - "gotchas_to_avoid": [], - "recommended_approach": null, - "metadata": { - "specs_searched": 0, - "results_above_threshold": 0 - } -} -``` - -This allows prompts to read the file without special-casing "file doesn't exist". - -**Graphiti Failure Handling:** -- Timeout: 5 seconds per query -- Retries: 1 (immediate) -- On failure: Log warning, continue with file-based only -- Circuit breaker: After 3 failures in 10 minutes, skip Graphiti for 5 minutes - -### 4.3 Data Flow - -``` -┌──────────────┐ -│ Task │ -│ Description │ -└──────┬───────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ PARALLEL RETRIEVAL │ -│ ┌────────────────────┐ ┌────────────────────┐ │ -│ │ Graphiti Semantic │ │ File-Based Search │ │ -│ │ (embedding search) │ │ (keyword matching) │ │ -│ │ │ │ │ │ -│ │ Rich, PRIMARY │ │ Lite, optional │ │ -│ └─────────┬──────────┘ └─────────┬──────────┘ │ -│ │ │ │ -│ └────────────┬─────────────┘ │ -│ ▼ │ -│ ┌────────────────────────┐ │ -│ │ Merge & Score │ │ -│ │ • Deduplicate │ │ -│ │ • Calculate relevance │ │ -│ │ • Sort by score │ │ -│ └────────────┬───────────┘ │ -│ ▼ │ -│ ┌────────────────────────┐ │ -│ │ Apply Threshold (0.5) │ │ -│ │ • Include >= 0.5 │ │ -│ │ • Exclude < 0.5 │ │ -│ └────────────┬───────────┘ │ -│ ▼ │ -└──────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌──────────────────────┐ - │ historical_context │ - │ .json │ - │ │ - │ Contains only what's │ - │ actually relevant │ - └──────────────────────┘ -``` - -### 4.4 Output Schema - -```json -{ - "status": "ok", - "query": "Add payment processing with Stripe", - "project_id": "2f2d5bd8-55a3-4c33-9f4d-48f7d0b3d0a7", - "similar_past_tasks": [ - { - "spec_name": "007-stripe-checkout", - "task_description": "Implement Stripe checkout flow", - "outcome": "passed", - "relevance": 0.92, - "key_learnings": ["Used webhook for confirmation", "Stored customer_id"] - } - ], - "patterns_that_worked": [ - { - "pattern": "Use idempotency keys for all Stripe API calls", - "source_spec": "007-stripe-checkout", - "relevance": 0.87, - "confidence": 0.83 - } - ], - "gotchas_to_avoid": [ - { - "gotcha": "Webhook endpoint must return 200 within 30 seconds", - "source_spec": "007-stripe-checkout", - "relevance": 0.81, - "confidence": 0.79 - } - ], - "recommended_approach": "Start with webhook handler (see 007-stripe-checkout), then checkout session. Use idempotency keys throughout.", - "conflicts": [ - { - "type": "pattern_conflict", - "topic": "auth", - "items": [ - { - "content": "Use middleware for auth checks, not inline validation", - "source_spec": "003-user-auth", - "relevance": 0.74, - "confidence": 0.81 - }, - { - "content": "Prefer inline auth checks for edge endpoints only", - "source_spec": "019-edge-auth", - "relevance": 0.72, - "confidence": 0.78 - } - ], - "resolution": { - "strategy": "highest_confidence", - "winner_index": 0, - "delta": 0.03, - "note": "Winner selected by confidence; runner-up kept for visibility because delta < 0.1" - } - } - ], - "metadata": { - "specs_searched": 15, - "results_above_threshold": 6, - "relevance_threshold": 0.5, - "graphiti_enabled": true, - "lite_mode": false, - "retrieval_time_ms": 1250 - } -} -``` - -**Note:** Each item includes its `relevance` score so consuming agents can prioritize appropriately. - ---- - -## 5. Implementation Plan - -### Phase 1: Foundation - -| Task | File | Effort | -|------|------|--------| -| Add dual-level group_id (project + spec) | `graphiti_memory.py` | Small | -| Add `search_project_learnings()` with relevance scoring | `graphiti_memory.py` | Medium | -| **Add advanced search methods (Section 4.1.4)** | `graphiti_memory.py` | Medium | -| - `search_high_quality()` with cross-encoder | `graphiti_memory.py` | - | -| - `search_related_to_entity()` with node distance | `graphiti_memory.py` | - | -| - `search_with_filters()` for type/date filtering | `graphiti_memory.py` | - | -| **Update docstrings: Graphiti = PRIMARY** | `graphiti_memory.py`, `graphiti_config.py` | Small | -| Add file-based cross-spec search | `memory.py` | Medium | -| Add `calculate_relevance()` utility | `memory.py` | Small | -| Add deduplication logic for patterns/gotchas | `memory.py` | Small | - -### Phase 2: Historical Context Phase - -| Task | File | Effort | -|------|------|--------| -| Create `phase_historical_context()` | `spec_runner.py` | Medium | -| **Use `search_high_quality()` (cross-encoder) for spec context** | `spec_runner.py` | Small | -| Implement relevance-based retrieval (0.5 threshold) | `spec_runner.py` | Medium | -| Implement merge/dedupe from file + Graphiti sources | `spec_runner.py` | Medium | -| Wire into pipeline after requirements | `spec_runner.py` | Small | -| Create cold-start placeholder handling | `spec_runner.py` | Small | - -### Phase 3: Prompt Updates - -| Task | File | Effort | -|------|------|--------| -| Update spec_writer.md with integration specs | `prompts/spec_writer.md` | Medium | -| Update spec_gatherer.md | `prompts/spec_gatherer.md` | Small | -| Update planner.md with historical context guidance | `prompts/planner.md` | Medium | -| Update coder.md with session memory protocol | `prompts/coder.md` | Medium | -| Update context.py keyword extraction with historical hints | `context.py` | Small | - -### Phase 4: QA Feedback Loop (P0 from Section 11) - -| Task | File | Effort | -|------|------|--------| -| Add automatic outcome recording after QA | `qa_loop.py` | Medium | -| Implement pattern/gotcha extraction from QA results | `qa_loop.py` | Medium | -| Store outcomes in file + Graphiti | `memory.py` | Small | -| Add gotchas.md auto-append on QA rejection | `qa_loop.py` | Small | -| Wire QA learnings into historical context retrieval | `spec_runner.py` | Small | - -### Phase 5: Ideation Integration - -| Task | File | Effort | -|------|------|--------| -| Add `phase_graph_hints_all()` (parallel queries) | `ideation_runner.py` | Medium | -| **Use `NODE_HYBRID_SEARCH_RRF` for fast parallel hints** | `ideation_runner.py` | Small | -| Add `_get_type_hints()` helper | `ideation_runner.py` | Small | -| Update all 7 ideation prompts | `prompts/ideation_*.md` | Medium | -| Add ideation-specific relevance queries | `graphiti_memory.py` | Small | -| **Add `record_dismissed_idea()` for user feedback** | `ideation_runner.py` | Small | -| **Add `--review` CLI mode with dismissal tracking** | `ideation_runner.py` | Medium | -| **Query dismissed ideas in `_get_type_hints()`** | `ideation_runner.py` | Small | -| **Add `previously_dismissed` section to ideation prompts** | `prompts/ideation_*.md` | Small | -| **Add `--dismissed` and `--clear-dismissed` CLI commands** | `ideation_runner.py` | Small | - -### Phase 6: Testing & Polish - -| Task | File | Effort | -|------|------|--------| -| Unit tests for relevance scoring | `tests/` | Medium | -| Unit tests for cross-spec search | `tests/` | Medium | -| Integration test: cold start → warm start | `tests/` | Medium | -| Integration test: QA feedback loop | `tests/` | Medium | -| Documentation updates | `CLAUDE.md` | Small | - -### Phase 7: Extended Learning Loops (Quick Wins) - -These are additional feedback loops (see Section 12.6.1) that can be added with minimal effort: - -| Task | File | Effort | Loop | -|------|------|--------|------| -| **Add discard reason prompt to `--discard`** | `run.py` | Small | Loop 6 | -| **Record discard outcomes to memory** | `run.py`, `graphiti_memory.py` | Small | Loop 6 | -| **Add complexity calibration recording** | `qa_loop.py` | Small | Loop 7 | -| **Query calibration data in complexity assessor** | `spec_runner.py` | Small | Loop 7 | -| **Track context.json files vs actual files used** | `agent.py` | Small | Loop 9 | -| **Feed context accuracy back to context discovery** | `context.py` | Medium | Loop 9 | -| **Add `--stats` command to show learning metrics** | `run.py` | Medium | All | - -### Implementation Order Rationale - -``` -Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6 → Phase 7 - │ │ │ │ │ - │ │ │ │ └── Can be parallel with Phase 4 - │ │ │ │ - │ │ │ └── QA Feedback is P0 - critical for the flywheel - │ │ │ - │ │ └── Prompts must be updated AFTER infrastructure exists - │ │ - │ └── Depends on Phase 1 (search functions) - │ - └── Foundation - everything depends on this -``` - ---- - -## 6. Files to Modify - -| File | Changes | -|------|---------| -| `spec_runner.py` | Add `phase_historical_context()`, relevance-based retrieval, wire into pipeline | -| `graphiti_memory.py` | Add dual-level group_id, `search_project_learnings()`, `save_learning()` with dual-write, **add advanced search methods** (`search_high_quality()`, `search_related_to_entity()`, `search_with_filters()`), import search config recipes, **update docstrings to reflect Graphiti-as-primary architecture** | -| `memory.py` | Add `search_project_memory()`, `calculate_relevance()`, outcome storage, deduplication | -| `context.py` | Enhance `_extract_keywords()` with historical hints | -| `qa_loop.py` | Record outcome after QA, extract patterns/gotchas, auto-append to gotchas.md, **record complexity calibration** | -| `run.py` | **Add discard reason prompt, record discard outcomes, add `--stats` command** | -| `agent.py` | **Track files actually used vs context.json for accuracy feedback** | -| `prompts/spec_writer.md` | Add historical_context integration (Section 3.7 spec) | -| `prompts/spec_gatherer.md` | Surface relevant learnings | -| `prompts/planner.md` | Historical context in planning (Section 3.7 spec) | -| `prompts/coder.md` | Session memory protocol (Section 3.7 spec) | -| `ideation_runner.py` | Add `phase_graph_hints_all()` (parallel), integrate before ideation agents, **add `record_dismissed_idea()`, `--review` mode with dismissal tracking, query dismissed ideas in hints** | -| `prompts/ideation_*.md` (7 files) | Add optional graph hints section, **add `previously_dismissed` handling rules** | - -### New Files - -| File | Purpose | -|------|---------| -| `tests/test_relevance_scoring.py` | Unit tests for relevance calculation | -| `tests/test_historical_context.py` | Unit tests for historical context phase | -| `tests/test_qa_feedback.py` | Unit tests for QA outcome recording | -| `tests/test_ideation_hints.py` | Unit tests for ideation graph hints | -| `tests/test_ideation_dismissals.py` | Unit tests for dismissal recording and retrieval | -| `tests/test_graphiti_advanced_search.py` | Unit tests for advanced search methods (cross-encoder, node distance, filters) | -| `tests/test_discard_learning.py` | Unit tests for discard outcome recording | -| `tests/test_complexity_calibration.py` | Unit tests for complexity prediction vs actual | -| `tests/test_context_accuracy.py` | Unit tests for context accuracy tracking | - -### New Directories/Files in Spec Structure - -| Path | Purpose | -|------|---------| -| `memory/qa_outcomes/` | QA outcome JSON files per spec | -| `historical_context.json` | Historical context for spec creation | -| `ideation_hints_{type}.json` | Per-type hints for ideation agents | -| `dismissed_ideas.json` | Project-level dismissed ideation tracking (lite mode fallback) | -| `discarded_specs.json` | Project-level spec discard tracking with reasons | -| `complexity_calibration.json` | Predicted vs actual complexity data for calibration | -| `context_accuracy.json` | Context discovery accuracy tracking | - ---- - -## 7. Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|------------|--------|------------| -| Graphiti adds latency | Medium | Medium | 5-second timeout, file-based fallback, circuit breaker | -| Wrong patterns propagate | Low | High | Outcome-weighted relevance (failed patterns score lower) | -| Context overwhelms agent | Low | Medium | Relevance-based cutoff (0.5 threshold) ensures only useful content | -| Cold start (no history) | Certain | Low | Graceful degradation with placeholder file | -| FalkorDB unavailable | Medium | Medium | File-based "lite mode" fallback, clear warning, no hard failure | -| Prompt changes break agents | Low | High | Incremental rollout, test on dev specs | -| Duplicate patterns accumulate | Medium | Low | Deduplication with 80% similarity matching | - -### 7.5 Performance Impact Estimates - -#### 7.5.1 Graphiti Search Method Latencies - -| Search Method | Latency | Use Case | -|---------------|---------|----------| -| Basic `search()` (hybrid + RRF default) | 500ms-1s | General queries | -| `NODE_HYBRID_SEARCH_RRF` | 400-800ms | Fast node search (ideation) | -| `EDGE_HYBRID_SEARCH_RRF` | 400-800ms | Fast edge search (QA) | -| `COMBINED_HYBRID_SEARCH_CROSS_ENCODER` | 2-4s | High-accuracy (spec creation) | -| `search()` with `center_node_uuid` | 600ms-1.2s | Node distance reranking (implementation) | - -#### 7.5.2 Phase Latencies - -| Operation | Latency | When It Runs | -|-----------|---------|--------------| -| File-based cross-spec search | 200-500ms | Historical context phase (lite mode only) | -| Graphiti cross-encoder search | 2-4s | Historical context phase | -| Historical context phase (total) | 2-4s | Once per spec creation | -| Ideation graph hints (all 7 parallel with RRF) | 800ms-1.5s | Once before ideation agents | -| QA outcome recording | 100-200ms | After QA resolution | -| Relevance calculation | <10ms | Per result (in-memory) | - -**Total Impact on Spec Creation:** -| Scenario | Added Latency | Notes | -|----------|---------------|-------| -| Cold start (0 specs) | ~500ms | Placeholder creation only | -| Warm start (5-20 specs) | 2-4s | Full retrieval with cross-encoder | -| Rich history (50+ specs) | 3-5s | More results to score | - -**Acceptable?** Yes. Spec creation currently takes 2-5 minutes. Adding 3-5s is <5% overhead for significant quality improvement. Using cross-encoder here is worth the extra latency for better accuracy. - -**Ideation Impact:** -| Scenario | Added Latency | Notes | -|----------|---------------|-------| -| Graphiti disabled | 0s | No hints phase | -| Graphiti enabled (RRF) | 800ms-1.5s | Parallel queries for all 7 types | - -**Acceptable?** Yes. Ideation runs 7 agents which takes 30-60s each. <1.5s is negligible. Using RRF instead of cross-encoder keeps it fast. - ---- - -## 8. Open Questions - -### Resolved - -| Question | Decision | -|----------|----------| -| Separate phase or merged? | Separate, before context discovery | -| Token budget? | 2000 tokens max | -| Automatic or manual outcome recording? | Automatic with signal extraction | - -### Unresolved - -| Question | Options | Recommendation | -|----------|---------|----------------| -| How to handle conflicting patterns? | A) Most recent wins B) Highest confidence wins C) Show both | B - Confidence-based (with "close-call show both") | -| Should users be able to edit historical context? | A) No B) CLI flag C) Interactive prompt | C - Ask during requirements if relevant | -| Cross-project learning? | A) Never B) Opt-in C) Same-stack only | A - Start with single project | - -### Conflict Resolution Policy (SPECIFIED) - -When the retrieval system finds two (or more) patterns/gotchas that conflict: - -1. **Pick a winner by confidence** (highest `confidence` wins). -2. **If close** (absolute delta < 0.1), **keep both** in `historical_context.json` under `conflicts[]` so prompts can mention the tradeoff explicitly. -3. **If not close** (delta >= 0.1), keep only the winner, but record a short note in metadata (optional) for debugging. - -This avoids silent "pattern drift" while still giving consuming agents a default recommendation. - ---- - -## 9. Success Criteria - -### MVP (Phase 1-2) - -- [ ] Historical context phase exists and runs -- [ ] File-based cross-spec search works (lite mode fallback) -- [ ] Output file `historical_context.json` created -- [ ] Graceful degradation when no history - -### Complete (Phase 1-6) - -- [ ] All MVP criteria -- [ ] Graphiti advanced search methods implemented (Section 4.1.4): - - [ ] `search_high_quality()` with cross-encoder - - [ ] `search_related_to_entity()` with node distance - - [ ] `search_with_filters()` for type/date filtering -- [ ] Spec creation uses cross-encoder for best accuracy -- [ ] Ideation uses RRF for parallel speed -- [ ] Code docstrings updated (Graphiti = PRIMARY) -- [ ] All spec prompts updated -- [ ] QA outcomes recorded -- [ ] Token budget enforced -- [ ] Ideation graph hints phase working -- [ ] All ideation prompts updated (7 files) -- [ ] Tests passing (including `test_graphiti_advanced_search.py`) -- [ ] Documentation updated - ---- - -## 10. Appendix - -### A. Current Context Flow (Before) - -``` -Discovery → Requirements → Context → Spec → Plan → Validate - │ │ │ - ▼ ▼ ▼ -project_ requirements context.json -index.json .json │ - │ - (keyword search only, - no historical data) -``` - -### B. Proposed Context Flow (After) - -``` -Discovery → Requirements → Historical → Context → Spec → Plan → Validate - │ │ Context │ - ▼ ▼ │ ▼ -project_ requirements │ context.json -index.json .json │ (enhanced with - │ historical keywords) - ▼ - historical_context.json - (patterns, gotchas, - similar tasks) -``` - -### C. Example historical_context.json - -```json -{ - "query": "Add payment processing with Stripe", - "similar_past_tasks": [ - { - "spec_name": "003-stripe-checkout", - "task_description": "Implement Stripe checkout flow", - "outcome": "passed", - "relevance_score": 0.92, - "key_learnings": [ - "Used webhook for payment confirmation", - "Stored customer_id for recurring payments" - ] - } - ], - "patterns_that_worked": [ - "Validate webhook signatures before processing", - "Use idempotency keys for all Stripe API calls", - "Store payment intent ID, not card details" - ], - "gotchas_to_avoid": [ - "Stripe API version must match SDK version", - "Webhook endpoint must return 200 within 30 seconds", - "Test mode keys start with sk_test_, not sk_live_" - ], - "recommended_approach": "Start with webhook handler setup (see 003-stripe-checkout pattern), then build checkout session creation. Use existing PaymentService as base class.", - "sources": { - "file_based": 2, - "graphiti": 3 - }, - "token_count": 1456, - "generated_at": "2025-12-11T10:30:00Z" -} -``` - -### D. Ideation Pipeline Flow (Before vs After) - -**Before:** -``` -Project Analysis → Context Gathering → [Parallel Ideation Agents] → Merge - │ - ▼ - (each agent analyzes - codebase from scratch, - may duplicate existing - features or miss patterns) -``` - -**After:** -``` -Project Analysis → Context Gathering → Graph Hints → [Parallel Ideation Agents] → Merge - │ │ - ▼ ▼ - ideation_hints.json (agents receive hints: - (500 tokens max) patterns to extend, - features to avoid, - constraints to respect) -``` - -### E. Example ideation_hints.json - -```json -{ - "ideation_type": "security_hardening", - "relevant_patterns": [ - "Auth middleware in src/middleware/auth.ts validates JWT", - "Rate limiting implemented via express-rate-limit", - "Input validation uses Zod schemas in src/validators/" - ], - "avoid_duplicating": [ - "JWT authentication (spec 002-auth)", - "CSRF protection (already in middleware)", - "Password hashing with bcrypt (UserService)" - ], - "known_constraints": [ - "Cannot use session-based auth (stateless API requirement)", - "Must support API keys for service-to-service calls", - "Audit logging must not block request processing" - ], - "successful_approaches": [ - "Middleware chain pattern for layered security", - "Centralized error handling masks internal errors" - ], - "query_used": "security authentication authorization vulnerabilities", - "sources": 4, - "token_count": 423, - "generated_at": "2025-12-11T15:30:00Z" -} -``` - -### F. Token Budget Summary - -| Context Type | Budget | Use Case | -|--------------|--------|----------| -| `historical_context.json` (spec creation) | 2000 tokens | Full context for spec writer | -| `ideation_hints.json` (ideation) | 500 tokens | Lightweight hints for ideation agents | -| Per-chunk Graphiti context (implementation) | 1000 tokens | Existing behavior, unchanged | - ---- - -### G. Integration Points by Runner - -#### spec_runner.py Integration - -```python -# Current phases (simplified): -async def run(self): - await self.phase_complexity() # → complexity_assessment.json - await self.phase_requirements() # → requirements.json - await self.phase_research() # → research.json (conditional) - await self.phase_context() # → context.json - await self.phase_spec_writing() # → spec.md - await self.phase_critic() # → critique_report.json (conditional) - await self.phase_planning() # → implementation_plan.json - await self.phase_validation() - -# NEW: Insert after requirements, before context -async def run(self): - await self.phase_complexity() - await self.phase_requirements() - await self.phase_historical_context() # ◄── NEW: historical_context.json - await self.phase_research() - await self.phase_context() # ◄── ENHANCED: uses historical keywords - await self.phase_spec_writing() # ◄── ENHANCED: reads historical_context - ... -``` - -#### ideation_runner.py Integration - -```python -# Current phases: -async def run(self): - await self.phase_project_index() # → project_index.json - await self.phase_context() # → ideation_context.json - # Then parallel ideation agents... - -# NEW: Insert graph hints before each ideation type -async def run(self): - await self.phase_project_index() - await self.phase_context() - - for ideation_type in self.enabled_types: - await self.phase_graph_hints(ideation_type) # ◄── NEW: ideation_hints.json - await self.phase_ideation_type(ideation_type) -``` - -#### roadmap_runner.py Integration (Optional Future Work) - -```python -# Current phases: -async def run(self): - await self.phase_project_index() # → project_index.json - await self.phase_discovery() # → roadmap_discovery.json - await self.phase_features() # → roadmap.json - -# FUTURE: Add graph hints for discovery phase -async def run(self): - await self.phase_project_index() - await self.phase_graph_hints() # ◄── FUTURE: existing features, constraints - await self.phase_discovery() # ◄── ENHANCED: aware of past work - await self.phase_features() -``` - -#### qa_loop.py Integration - -```python -# Current: QA validates and creates reports -async def run_qa_validation_loop(...): - # ... validation logic ... - if approved: - # Write qa_report.md - pass - else: - # Write QA_FIX_REQUEST.md - pass - -# NEW: Record outcome to memory after QA completes -async def run_qa_validation_loop(...): - # ... validation logic ... - - # Record outcome for future specs - await record_qa_outcome( # ◄── NEW - spec_dir=spec_dir, - outcome="passed" if approved else "failed", - patterns_validated=[...], - issues_found=[...], - ) -``` - ---- - -### H. Prompt Integration Matrix - -| Prompt File | New Input | Changes Needed | -|-------------|-----------|----------------| -| `spec_gatherer.md` | `historical_context.json` | Surface relevant learnings during requirements | -| `spec_writer.md` | `historical_context.json` | Add section for historical context extraction | -| `spec_critic.md` | `historical_context.json` | Validate against past learnings | -| `planner.md` | `historical_context.json` | Consider past approaches when chunking | -| `ideation_low_hanging_fruit.md` | `ideation_hints.json` | Avoid duplicating, extend patterns | -| `ideation_ui_ux.md` | `ideation_hints.json` | Avoid duplicating, respect constraints | -| `ideation_high_value.md` | `ideation_hints.json` | Avoid duplicating, respect constraints | -| `ideation_security.md` | `ideation_hints.json` | Aware of existing security measures | -| `ideation_performance.md` | `ideation_hints.json` | Aware of existing optimizations | -| `ideation_code_quality.md` | `ideation_hints.json` | Aware of existing patterns | -| `ideation_documentation.md` | `ideation_hints.json` | Aware of existing docs | - ---- - -### I. Example: Complete Data Flow for a Second Spec - -``` -SPEC 001-USER-AUTH (Completed Previously) -├── QA Outcome: PASSED -├── Patterns Learned: -│ • "JWT tokens stored in httpOnly cookies" -│ • "Auth middleware in src/middleware/auth.ts" -├── Gotchas Discovered: -│ • "Token refresh must happen before expiry, not after" -│ • "Rate limiting on /auth/* endpoints" -└── Stored in: memory/ + Graphiti (project:myapp group) - - ↓ - [Time passes] - ↓ - -SPEC 002-PAYMENT-FLOW (New Task) -├── User: "Add Stripe payment processing" -│ -├── Phase: Historical Context (NEW) -│ ├── Query Graphiti: "payment stripe authentication security" -│ ├── Finds: Auth patterns from 001, no payment history -│ └── Output: historical_context.json -│ { -│ "similar_past_tasks": [], # No payment tasks yet -│ "patterns_that_worked": [ -│ "Auth middleware pattern can be reused for payment auth" -│ ], -│ "gotchas_to_avoid": [ -│ "Rate limiting exists - payment endpoints need exemption?" -│ ], -│ "recommended_approach": "Follow auth middleware pattern for payment guards" -│ } -│ -├── Phase: Context Discovery (ENHANCED) -│ ├── Keywords from task: "stripe", "payment", "checkout" -│ ├── Keywords from history: "middleware", "auth", "tokens" # ◄── NEW -│ └── Finds: More relevant files due to expanded keywords -│ -├── Phase: Spec Writing (ENHANCED) -│ ├── Reads: requirements.json, context.json, historical_context.json -│ └── spec.md includes: -│ • Reference to auth middleware pattern -│ • Warning about rate limiting -│ • Note: No prior payment implementations to reference -│ -└── Implementation & QA - └── On completion: Patterns/gotchas added to project memory - └── Available for SPEC 003 and beyond -``` - ---- - -## 11. Future Enhancements: Memory-Powered Capabilities - -Beyond the core Smart Context System, here are additional memory-powered enhancements that would significantly improve Auto Claude's success rate. - -### 11.1 Predictive Bug Prevention (Enhancement to prediction.py) - -**Current State:** `prediction.py` generates static checklists based on chunk type. - -**Enhancement:** Use historical data to predict SPECIFIC bugs for THIS codebase. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PREDICTIVE BUG PREVENTION │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Chunk: "Add payment endpoint" │ -│ │ -│ STATIC PREDICTIONS (current): │ -│ • "Check for SQL injection" │ -│ • "Validate input parameters" │ -│ │ -│ LEARNED PREDICTIONS (proposed): │ -│ • "This codebase: Auth middleware missing in 3/5 past API chunks" │ -│ • "This codebase: CORS errors occurred in chunk-002, chunk-007" │ -│ • "File routes/api.py: Last 2 changes caused import errors" │ -│ • "Pattern: Payment endpoints need rate limiting (from spec-003)" │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -**Data Sources:** -- `memory/gotchas.md` - Known pitfalls in this codebase -- `memory/attempt_history.json` - What failed before and why -- Graphiti - Similar chunks that failed across specs - -**Implementation:** -```python -# prediction.py enhancement -async def generate_learned_predictions( - spec_dir: Path, - chunk: dict, - graphiti_memory: GraphitiMemory | None, -) -> list[str]: - """Generate predictions based on historical failures.""" - - predictions = [] - - # 1. Local memory: gotchas for this file - gotchas = load_gotchas(spec_dir) - for file in chunk.get("files_to_modify", []): - file_gotchas = [g for g in gotchas if file in g] - predictions.extend(file_gotchas) - - # 2. Attempt history: what failed before - history = load_attempt_history(spec_dir) - for chunk_id, attempts in history.get("chunks", {}).items(): - failed = [a for a in attempts if not a["success"]] - if failed and is_similar_chunk(chunk, chunk_id): - predictions.append(f"Similar chunk {chunk_id} failed: {failed[-1]['error']}") - - # 3. Graphiti: cross-spec learnings - if graphiti_memory: - query = f"failures bugs errors {chunk.get('description', '')}" - results = await graphiti_memory.search_project_learnings(query, limit=5) - for r in results: - predictions.append(f"From past spec: {r['content']}") - - return predictions -``` - -**Impact:** Prevents repeating the same mistakes across sessions and specs. - ---- - -### 11.2 Adaptive Chunk Complexity Scoring - -**Current State:** All chunks treated equally. No signal about which are risky. - -**Enhancement:** Score chunks by predicted difficulty based on historical data. - -```json -{ - "id": "chunk-3-2", - "description": "Add WebSocket connection handler", - "complexity_score": 0.85, - "complexity_factors": [ - {"factor": "file_churn", "score": 0.9, "reason": "websocket.py modified 8 times in past specs"}, - {"factor": "similar_failures", "score": 0.8, "reason": "2 similar chunks failed before"}, - {"factor": "dependencies", "score": 0.7, "reason": "Depends on 3 other files"} - ], - "recommended_approach": "Start with connection test before implementing handlers", - "estimated_sessions": 2 -} -``` - -**Benefits:** -- Planner can front-load risky chunks -- Coordinator can allocate more time to complex chunks -- User sees realistic progress estimates - ---- - -### 11.3 Cross-Session Codebase Map Evolution - -**Current State:** `codebase_map.json` grows but never prunes. Becomes stale. - -**Enhancement:** Intelligent map maintenance with confidence decay. - -```json -{ - "src/auth/middleware.py": { - "description": "JWT validation middleware", - "confidence": 0.95, - "last_verified": "2025-12-10T10:00:00Z", - "sessions_referenced": 5, - "changes_since_mapped": 0 - }, - "src/utils/old_helper.py": { - "description": "Legacy string utilities", - "confidence": 0.3, - "last_verified": "2025-11-01T10:00:00Z", - "sessions_referenced": 0, - "changes_since_mapped": 12, - "flag": "STALE - consider removing" - } -} -``` - -**Maintenance Logic:** -```python -def update_codebase_map(map_data: dict, project_dir: Path) -> dict: - """Update confidence scores based on file changes.""" - - for file_path, info in map_data.items(): - # Check if file still exists - if not (project_dir / file_path).exists(): - info["flag"] = "DELETED" - info["confidence"] = 0 - continue - - # Check git history for changes since last verified - changes = count_git_changes_since(file_path, info["last_verified"]) - info["changes_since_mapped"] = changes - - # Decay confidence based on changes and time - if changes > 5: - info["confidence"] *= 0.5 - elif changes > 0: - info["confidence"] *= 0.9 - - # Flag stale entries - if info["confidence"] < 0.4: - info["flag"] = "STALE" - - return map_data -``` - ---- - -### 11.4 QA Learning Feedback Loop - -> **Note:** This enhancement has been **moved to Phase 4 of core implementation** (Section 5). It is now a P0 deliverable, not a future enhancement. The details below remain for reference. - -**Current State:** QA outcomes not recorded. Same issues repeat. - -**Enhancement:** QA feeds learnings back into the system. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ QA FEEDBACK LOOP │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ QA REJECTION │ -│ ├── Issue: "Missing auth middleware on /api/payments" │ -│ ├── File: routes/payments.py │ -│ └── Pattern: "All /api/* routes need @require_auth" │ -│ │ -│ │ │ -│ ▼ │ -│ │ -│ AUTOMATIC UPDATES │ -│ ├── gotchas.md += "API routes need @require_auth decorator" │ -│ ├── patterns.md += "Pattern: @require_auth on all /api/* endpoints" │ -│ └── Graphiti += Episode("QA caught missing auth", confidence=HIGH) │ -│ │ -│ │ │ -│ ▼ │ -│ │ -│ NEXT SPEC CREATION │ -│ └── historical_context.json includes: │ -│ "gotchas_to_avoid": ["API routes need @require_auth decorator"] │ -│ │ -│ NEXT CODER SESSION │ -│ └── Pre-implementation checklist includes: │ -│ "⚠️ QA previously caught: Missing auth on API routes" │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -**Implementation in qa_loop.py:** -```python -async def record_qa_learnings( - spec_dir: Path, - qa_result: QAResult, - graphiti_memory: GraphitiMemory | None, -): - """Extract and store learnings from QA results.""" - - learnings = [] - - for issue in qa_result.issues: - if issue.severity in ["critical", "major"]: - learning = { - "type": "qa_rejection", - "issue": issue.description, - "file": issue.file_path, - "pattern": issue.suggested_pattern, - "timestamp": datetime.now(timezone.utc).isoformat(), - } - learnings.append(learning) - - # Update gotchas.md - append_to_gotchas(spec_dir, issue.description) - - # Update patterns.md if pattern suggested - if issue.suggested_pattern: - append_to_patterns(spec_dir, issue.suggested_pattern) - - # Store in Graphiti for cross-spec learning - if graphiti_memory and learnings: - await graphiti_memory.save_qa_learnings(learnings) -``` - ---- - -### 11.5 Semantic File Similarity for Pattern Discovery - -**Current State:** `patterns_from` in chunks is manually specified by Planner. - -**Enhancement:** Automatic discovery of similar files via embeddings. - -```python -async def find_similar_files( - target_file: str, - project_dir: Path, - graphiti_memory: GraphitiMemory, - top_k: int = 3, -) -> list[str]: - """Find files semantically similar to target for pattern reference.""" - - # Get file content (or summary if too large) - content = read_file_summary(project_dir / target_file) - - # Search Graphiti for similar file descriptions - results = await graphiti_memory.search( - query=f"file similar to: {content[:500]}", - group_ids=[graphiti_memory.project_group_id], - num_results=top_k, - ) - - return [r["file_path"] for r in results if r["file_path"] != target_file] -``` - -**Use Cases:** -- Planner auto-discovers `patterns_from` files -- Coder finds similar implementations when stuck -- QA finds similar tests to reference - ---- - -### 11.6 Session Handoff Optimization - -**Current State:** Each session reads ALL memory files from scratch. - -**Enhancement:** Summarized handoff document optimized for next session. - -```markdown -# Session Handoff: Session 5 → Session 6 - -## Progress Summary -- Completed: 7/12 chunks (58%) -- Current phase: phase-2-worker (2/3 chunks done) -- Next chunk: chunk-2-3 (Create Celery beat schedule) - -## What You Need to Know -1. **Redis connection**: Use `get_redis()` from `src/utils/redis.py` (NOT direct connection) -2. **Celery tasks**: Follow pattern in `worker/tasks/existing_task.py` -3. **Gotcha**: Celery beat requires `CELERY_BEAT_SCHEDULE` in settings (I missed this initially) - -## Files I Modified This Session -- `worker/tasks/aggregation.py` - Added aggregation task (verified working) -- `worker/celeryconfig.py` - Added task routing - -## Recommended First Action -Read `worker/tasks/existing_task.py` for the beat schedule pattern before starting chunk-2-3. - -## Open Questions -- Should beat schedule be in separate file or celeryconfig.py? (I put in celeryconfig) -``` - -**Generated automatically at session end:** -```python -def generate_handoff( - spec_dir: Path, - session_insights: dict, - next_chunk: dict | None, -) -> str: - """Generate optimized handoff document for next session.""" - - handoff = f"""# Session Handoff: Session {session_insights['session_number']} → Session {session_insights['session_number'] + 1} - -## Progress Summary -{generate_progress_summary(spec_dir)} - -## What You Need to Know -{format_key_learnings(session_insights)} - -## Files I Modified This Session -{format_modified_files(session_insights)} - -## Recommended First Action -{generate_recommended_action(next_chunk, session_insights)} -""" - return handoff -``` - ---- - -### 11.7 Verification Result Caching - -**Current State:** Verification runs every time, even for unchanged code. - -**Enhancement:** Cache verification results, invalidate on file change. - -```json -{ - "chunk-1-2": { - "last_verified": "2025-12-11T10:00:00Z", - "verification_passed": true, - "files_hash": "abc123def456", - "verification_output": "All tests passed (5/5)", - "dependencies_hash": "xyz789" - } -} -``` - -**Logic:** -```python -def should_reverify(chunk_id: str, cache: dict, project_dir: Path) -> bool: - """Check if chunk needs re-verification.""" - - if chunk_id not in cache: - return True - - cached = cache[chunk_id] - - # Check if files changed - current_hash = hash_files(cached["files"], project_dir) - if current_hash != cached["files_hash"]: - return True - - # Check if dependencies changed - deps_hash = hash_dependencies(cached["dependencies"], project_dir) - if deps_hash != cached["dependencies_hash"]: - return True - - return False -``` - -**Benefits:** -- Faster session startup (skip verified chunks) -- Reduced API/browser test load -- Clear signal when verification actually needed - ---- - -### 11.8 Multi-Project Learning (Future) - -**Current State:** Each project is isolated. No cross-project learning. - -**Future Enhancement:** Opt-in cross-project pattern sharing. - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ MULTI-PROJECT LEARNING │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Project A (FastAPI + React) │ -│ └── Learned: "FastAPI CORS needs explicit origins list" │ -│ │ -│ Project B (FastAPI + Vue) ◄── NEW PROJECT │ -│ └── Query: "What do I need to know about FastAPI?" │ -│ └── Result: "From similar project: CORS needs explicit origins list" │ -│ │ -│ Filtering: │ -│ • Same tech stack: Include │ -│ • Different stack: Exclude │ -│ • Generic patterns: Include with lower confidence │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -**Considerations:** -- Opt-in only (privacy) -- Filter by tech stack similarity -- Confidence decay for cross-project learnings -- User can review/approve cross-project suggestions - ---- - -### 11.9 Implementation Priority Matrix - -| Enhancement | Impact | Effort | Dependencies | Priority | Status | -|-------------|--------|--------|--------------|----------|--------| -| **QA Learning Feedback Loop** | High | Medium | Smart Context | P0 | **Moved to Phase 4** | -| **Predictive Bug Prevention** | High | Medium | QA Feedback + 20 specs | P0 | Future (needs data) | -| **Session Handoff Optimization** | Medium | Small | None | P1 | Future | -| **Verification Result Caching** | Medium | Small | None | P1 | Future | -| **Adaptive Chunk Complexity** | Medium | Medium | Smart Context | P2 | Future | -| **Codebase Map Evolution** | Medium | Small | None | P2 | Future | -| **Semantic File Similarity** | Medium | Medium | Graphiti required | P2 | Future | -| **Multi-Project Learning** | High | Large | Full Smart Context | P3 | Future | - -> **Note:** QA Learning Feedback Loop has been promoted to core implementation (Phase 4). It is essential for the Knowledge Flywheel to function. - ---- - -### 11.10 Success Metrics for Enhancements - -| Metric | Current | After Smart Context | After All Enhancements | -|--------|---------|---------------------|------------------------| -| QA first-pass rate | ~60% | ~75% | ~90% | -| Repeated gotchas | Common | Rare | Near-zero | -| Session context load time | 5-10s | 5-10s | 2-5s (with caching) | -| Chunks requiring recovery | ~15% | ~10% | ~5% | -| Cross-spec pattern reuse | 0% | 50%+ | 80%+ | -| Prediction accuracy | Generic | Codebase-specific | File-specific | -| Ideation acceptance rate | ~40% | ~60% | ~80% (learns from dismissals) | -| Complexity estimation accuracy | ~50% | ~70% | ~85% (calibrated over time) | -| Spec discard rate | Unknown | Tracked | ~5% (preventable discards avoided) | -| Context file accuracy | ~50% | ~65% | ~85% (learns what's relevant) | - ---- - -## 12. Long-Term Vision: The Self-Improving System - -This section envisions how Auto Claude evolves from a blank slate to a deeply knowledgeable coding partner that understands your specific project better than any general-purpose AI could. - -### The Flywheel Effect - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ THE KNOWLEDGE FLYWHEEL │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────┐ │ -│ │ BUILD │ │ -│ │ SPEC N │ │ -│ └──────┬──────┘ │ -│ │ │ -│ ┌───────────────────┼───────────────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ -│ │ Patterns │ │ Gotchas │ │ Outcomes │ │ -│ │ Learned │ │ Found │ │ Recorded │ │ -│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ -│ │ │ │ │ -│ └──────────────────┴──────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ KNOWLEDGE BASE │ │ -│ │ (Memory Layer) │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ BUILD │ │ -│ │ SPEC N+1 │◄── Faster, fewer errors, │ -│ │ (Smarter) │ better patterns │ -│ └─────────────────┘ │ -│ │ -│ Each spec makes the next one better. The system compounds knowledge. │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -### 12.1 Spec 1: Cold Start (The Blank Slate) - -**Knowledge State:** Empty. No patterns, no gotchas, no history. - -``` -Memory Contents: -├── codebase_map.json → {} -├── patterns.md → (doesn't exist) -├── gotchas.md → (doesn't exist) -├── session_insights/ → (empty) -└── Graphiti → (empty graph) -``` - -**Behavior:** -- Spec creation uses only static project analysis -- Context discovery relies purely on keyword matching -- Coder uses generic predictions ("check for SQL injection") -- QA has no baseline for "normal" in this codebase -- High chance of hitting unknown gotchas - -**Metrics (Baseline):** -| Metric | Value | -|--------|-------| -| QA first-pass rate | ~50-60% | -| Sessions per chunk | 1.5 avg | -| Gotchas hit | Many (unknown codebase) | -| Pattern adherence | Low (discovering patterns) | - -**What Gets Learned:** -``` -Session 1 discovers: - - "Database uses SQLAlchemy with async sessions" - - "All API routes are in routes/ directory" - - "Tests use pytest with fixtures in conftest.py" - -Session 2 discovers: - - "Auth uses JWT tokens stored in httpOnly cookies" - - "Gotcha: Must close async sessions explicitly" - -QA discovers: - - "Missing CORS configuration on new endpoints" - - "Pattern: All routes need @require_auth decorator" -``` - ---- - -### 12.2 Spec 20: Pattern Recognition (The Learning Phase) - -**Knowledge State:** Rich local patterns, emerging cross-spec insights. - -``` -Memory Contents: -├── codebase_map.json → 150+ files mapped with descriptions -├── patterns.md → 40+ established patterns -├── gotchas.md → 25+ known pitfalls -├── session_insights/ → 60+ session records -├── qa_outcomes/ → 20 spec outcomes (15 passed, 5 needed fixes) -└── Graphiti → 500+ nodes, 200+ relationships -``` - -**Behavior:** -- Historical context surfaces relevant past work automatically -- "You're adding a payment endpoint. Spec-007 added Stripe integration. Here's what worked..." -- Predictions are codebase-specific: "This file (routes/api.py) has caused CORS issues 3 times" -- QA knows what "normal" looks like and catches regressions faster -- Ideation avoids suggesting features that already exist -- **Ideation learns from dismissals**: "User has dismissed 3 'add GraphQL' suggestions → avoid GraphQL-related ideas" - -**Example Historical Context for Spec 20:** -```json -{ - "task": "Add email notification system", - "similar_past_tasks": [ - { - "spec": "012-slack-notifications", - "relevance": 0.85, - "learnings": [ - "Used background worker for async sending", - "Created NotificationService base class", - "Added retry logic with exponential backoff" - ] - }, - { - "spec": "008-password-reset", - "relevance": 0.72, - "learnings": [ - "Email templates in templates/email/", - "Used Jinja2 for templating", - "Gotcha: SMTP timeout needs explicit handling" - ] - } - ], - "patterns_that_worked": [ - "All async operations go through worker service", - "Notification services inherit from BaseNotificationService", - "Templates use the shared email layout base.html" - ], - "gotchas_to_avoid": [ - "SMTP connections must be pooled (discovered spec-008)", - "Email sending must not block API response (discovered spec-012)", - "Rate limiting needed for notification endpoints (QA catch spec-015)" - ], - "recommended_approach": "Follow spec-012 pattern: Create EmailNotificationService extending BaseNotificationService, use worker for async delivery" -} -``` - -**Metrics (Improved):** -| Metric | Spec 1 | Spec 20 | Improvement | -|--------|--------|---------|-------------| -| QA first-pass rate | 50-60% | 75-80% | +20-25% | -| Sessions per chunk | 1.5 | 1.1 | -27% | -| Gotchas hit | Many | Few (predicted) | -70% | -| Pattern adherence | Low | High (guided) | +60% | -| Ideation acceptance rate | ~40% | ~70% | +30% (learns from dismissals) | - -**Emergent Capabilities:** -- System starts predicting which chunks will be difficult -- Planner references past specs when creating implementation plans -- Coder asks "how did we do this before?" and gets real answers -- QA report includes "regression from spec-X" when patterns violated - ---- - -### 12.3 Spec 60: Deep Understanding (The Expertise Phase) - -**Knowledge State:** Comprehensive project knowledge graph, statistical patterns. - -``` -Memory Contents: -├── codebase_map.json → 400+ files, confidence-scored -├── patterns.md → 100+ patterns (categorized by domain) -├── gotchas.md → 60+ pitfalls (with severity scores) -├── session_insights/ → 200+ sessions analyzed -├── qa_outcomes/ → 60 specs (52 first-pass, 8 needed fixes) -├── chunk_statistics/ → Success rates by chunk type -├── file_risk_scores/ → Per-file failure probability -└── Graphiti → 2000+ nodes, rich relationship graph -``` - -**Behavior:** -- System has statistical model of what succeeds and fails -- Knows which files are "hot" (frequently cause issues) -- Predicts chunk complexity with high accuracy -- Suggests optimal implementation order based on historical success -- Catches architectural drift: "This pattern contradicts how we do auth elsewhere" - -**Example Chunk Complexity Prediction:** -```json -{ - "chunk": "chunk-3-2: Add WebSocket real-time updates", - "predicted_complexity": 0.85, - "confidence": 0.92, - "factors": [ - { - "factor": "Technology complexity", - "score": 0.9, - "evidence": "WebSocket chunks failed 40% more often historically" - }, - { - "factor": "File risk", - "score": 0.8, - "evidence": "services/realtime.py has 0.7 failure rate" - }, - { - "factor": "Integration points", - "score": 0.85, - "evidence": "Touches 4 services (above average of 2.1)" - } - ], - "recommended_mitigations": [ - "Review spec-034 WebSocket implementation first", - "Start with connection test before message handlers", - "Allocate 2 sessions (vs. 1 average)" - ], - "similar_successful_chunks": [ - "spec-034/chunk-2-1 (WebSocket notifications) - 1 session", - "spec-041/chunk-4-2 (Real-time dashboard) - 2 sessions" - ] -} -``` - -**Metrics (Mature):** -| Metric | Spec 1 | Spec 20 | Spec 60 | Improvement | -|--------|--------|---------|---------|-------------| -| QA first-pass rate | 50% | 78% | 88% | +38% | -| Sessions per chunk | 1.5 | 1.1 | 1.05 | -30% | -| Gotchas hit | Many | Few | Rare | -90% | -| Pattern adherence | Low | High | Near-perfect | +80% | -| Accurate complexity prediction | N/A | 65% | 85% | +20% | - -**Emergent Capabilities:** -- System warns about architectural decisions: "Adding this here will create circular dependency" -- Suggests refactoring opportunities: "These 3 services share duplicated code (found across 5 specs)" -- Identifies technical debt accumulation: "Error handling in payments/ degraded over last 10 specs" -- Predicts which changes will need extensive QA - ---- - -### 12.4 Spec 200: Institutional Knowledge (The Mastery Phase) - -**Knowledge State:** Complete project history, causal understanding, predictive models. - -``` -Memory Contents: -├── codebase_map.json → 800+ files, versioned history -├── patterns.md → 200+ patterns (evolution tracked) -├── gotchas.md → 100+ pitfalls (some deprecated as fixed) -├── session_insights/ → 700+ sessions, ML-ready dataset -├── qa_outcomes/ → 200 specs (185 first-pass) -├── architectural_model/ → Learned service dependencies -├── developer_preferences/ → Coding style learned over time -├── seasonal_patterns/ → Time-based failure patterns -└── Graphiti → 10,000+ nodes, causal graph -``` - -**Behavior:** -- System has "seen everything" in this codebase -- Can predict cascading effects of changes -- Knows the history of every file and why it evolved -- Understands implicit conventions that were never documented -- Can explain WHY things are done a certain way - -**Example: Historical Reasoning** -``` -User: "Why does PaymentService have this weird retry logic?" - -System: "Based on project history: - -1. Original implementation (Spec-012): Simple retry with fixed delay -2. Spec-034: Added exponential backoff after Stripe rate limiting incident -3. Spec-067: Added circuit breaker after production outage (Spec-067 QA report) -4. Spec-089: Current form with jitter added (discovered thundering herd in Spec-088) - -The retry logic evolved through 4 production incidents. Each layer addresses -a specific failure mode discovered in production. - -Related gotchas: -- 'Stripe rate limits are per-second, not per-minute' (Spec-034) -- 'Payment retries must be idempotent' (Spec-067) -- 'Add jitter to prevent thundering herd' (Spec-089) - -If you're modifying this, be aware that removing any layer will likely -cause the original issue to resurface." -``` - -**Example: Predictive Architecture** -```json -{ - "proposed_change": "Add caching layer to UserService", - "impact_analysis": { - "direct_impact": ["UserService", "AuthService"], - "cascading_impact": ["PaymentService", "NotificationService"], - "predicted_issues": [ - { - "issue": "Cache invalidation for auth tokens", - "confidence": 0.89, - "evidence": "Similar change in Spec-145 caused auth bugs", - "mitigation": "Use event-based invalidation (pattern from Spec-156)" - }, - { - "issue": "Memory pressure in worker service", - "confidence": 0.72, - "evidence": "Worker runs on limited memory (discovered Spec-078)", - "mitigation": "Use Redis instead of in-memory cache" - } - ], - "recommended_approach": { - "pattern": "Distributed cache with event invalidation", - "reference_specs": ["Spec-156", "Spec-167"], - "estimated_chunks": 4, - "estimated_sessions": 5 - } - } -} -``` - -**Metrics (Mastery):** -| Metric | Spec 1 | Spec 20 | Spec 60 | Spec 200 | -|--------|--------|---------|---------|----------| -| QA first-pass rate | 50% | 78% | 88% | 95% | -| Sessions per chunk | 1.5 | 1.1 | 1.05 | 1.02 | -| Accurate estimates | N/A | 50% | 75% | 92% | -| Architectural violations | Common | Occasional | Rare | Near-zero | -| Novel gotchas | Frequent | Occasional | Rare | Very rare | - -**Emergent Capabilities:** -- **Causal understanding**: "If you change X, Y and Z will break because..." -- **Historical context**: "This was tried in Spec-45, failed because..." -- **Proactive suggestions**: "Based on your recent specs, you might want to refactor..." -- **Onboarding acceleration**: New team members can query the system for project history -- **Technical debt tracking**: "These areas have accumulated debt over the last 50 specs" - ---- - -### 12.5 The Compound Effect Visualized - -``` - KNOWLEDGE ACCUMULATION OVER TIME - -Knowledge │ ┌───────── -Depth │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ - │─┘ - └────────────────────────────────────────────────────────► - 1 20 60 120 200 Specs - - │←─────────────→│←──────────────→│←────────────────────→│ - Learning Pattern Mastery - Phase Recognition Phase - (Discovery) (Optimization) (Prediction) - - - SUCCESS RATE IMPROVEMENT - -Success │ ┌───────── -Rate │ ┌────┘ 95% - │ ┌────┘ - │ ┌────┘ 88% - │ ┌────┘ - │ ┌────┘ 78% - │ ┌────┘ - │ ┌────┘ - │ ┌────┘ 60% - │ ┌────┘ - │─┘ 50% - └────────────────────────────────────────────────────────► - 1 20 60 120 200 Specs - - - NOVEL PROBLEMS ENCOUNTERED - -Novel │ -Problems │─┐ Many - │ └─┐ - │ └─┐ - │ └──┐ - │ └───┐ - │ └────┐ Few - │ └─────┐ - │ └───────┐ - │ └─────────────────── Rare - └────────────────────────────────────────────────────────► - 1 20 60 120 200 Specs -``` - ---- - -### 12.6 What Makes This Self-Improving - -The system improves itself through multiple feedback loops: - -#### Loop 1: Direct Learning (Every Session) -``` -Coder Session → Discovers pattern/gotcha → Writes to memory → Next session uses it -``` - -#### Loop 2: QA Feedback (Every Spec) -``` -QA Rejection → Extracts issue → Creates gotcha → Coder gets warning → Issue prevented -``` - -#### Loop 2.5: Ideation Preference Learning (Every Review) -``` -User dismisses idea → Records dismissal + reason → Next ideation run retrieves dismissed → - → Agent avoids similar suggestions → More relevant ideas generated -``` -This loop creates **personalized ideation** over time. The system learns: -- What types of features fit this project -- What complexity level is acceptable -- What constraints matter to this user -- What categories get consistently rejected - -#### Loop 3: Cross-Spec Learning (Accumulative) -``` -Multiple similar specs → Pattern emerges → Graphiti identifies → Historical context surfaces -``` - -#### Loop 4: Statistical Learning (Long-term) -``` -200 specs of data → Chunk complexity model → Accurate predictions → Better planning -``` - -#### Loop 5: Architectural Learning (Emergent) -``` -Many file changes tracked → Dependency graph learned → Impact prediction → Safer changes -``` - ---- - -### 12.6.1 Additional Learning Opportunities (Not Yet Implemented) - -The following user interaction points and system outputs could enable additional self-improvement loops. These represent **missing feedback signals** that are currently lost: - -#### Loop 6: Spec Discard Learning (Missing) -``` -User runs --discard → Records: spec_id, stage_reached, reason (if provided) → - → Next spec creation: "Similar specs were discarded before, consider: [reasons]" -``` -**Signal:** When a user discards an implementation, something went fundamentally wrong. This is a strong negative signal that should inform future specs. - -**Data to capture:** -- Which spec was discarded -- How far did implementation get (chunks completed) -- User-provided reason (optional prompt: "Why are you discarding?") -- Files that were modified before discard - -**Use case:** "2 of 3 specs involving WebSocket changes were discarded. Consider simpler approach or breaking into smaller specs." - -#### Loop 7: Complexity Estimation Calibration (Missing) -``` -Complexity Assessment predicts: "STANDARD" → Actual outcome: 8 sessions, 3 QA cycles → - → Recalibrate: This type of task is actually COMPLEX in this codebase -``` -**Signal:** The complexity assessor currently makes predictions but never learns if they were accurate. - -**Data to capture:** -- Predicted complexity (simple/standard/complex) -- Actual sessions needed -- Actual QA cycles -- Task type/category - -**Use case:** "Tasks involving 'authentication' in this codebase are consistently underestimated. Auto-upgrading complexity." - -#### Loop 8: Spec Edit Learning (Missing) -``` -spec_writer generates spec.md → User manually edits before implementation → - → Diff captured → Patterns learned: "User always adds [X] section" -``` -**Signal:** Manual spec edits show gaps in spec_writer's understanding of user preferences. - -**Data to capture:** -- Original spec.md vs. user-edited version (diff) -- Sections added/removed/modified -- Recurring edit patterns - -**Use case:** "User has added 'Database Migration Notes' section to 5 specs. Auto-include in template." - -#### Loop 9: Context Discovery Accuracy (Missing) -``` -Context Discovery finds 15 files → Coder only references 8 → - → Track accuracy: Some file patterns are over-included -``` -**Signal:** context.json often includes files that aren't actually relevant. - -**Data to capture:** -- Files included in context.json -- Files actually modified/read by coder -- Files that were missing (coder had to discover) - -**Use case:** "test_*.py files are included 80% of time but used 10% of time. Reduce weight." - -#### Loop 10: Research Value Tracking (Missing) -``` -Research phase queries external docs → Some research used, some ignored → - → Track: Which research queries produce actionable insights -``` -**Signal:** The research phase can waste tokens on irrelevant queries. - -**Data to capture:** -- Research queries made -- Research results referenced in spec/code -- Research that was ignored - -**Use case:** "Research about 'Stripe webhooks' was used in 3 specs. Research about 'payment history' never used." - -#### Loop 11: Recovery Strategy Learning (Missing) -``` -Chunk fails → coder_recovery.md kicks in → Specific approach succeeds → - → Record: "For stuck [chunk_type], try [approach] first" -``` -**Signal:** When chunks fail and recovery succeeds, we should learn what worked. - -**Data to capture:** -- What caused the chunk to get stuck -- What recovery approach was tried -- Which approach succeeded -- Time to recovery - -**Use case:** "Import errors in this codebase: 80% resolved by checking __init__.py exports first." - -#### Loop 12: Time Estimation Learning (Missing) -``` -Planner estimates: "3 chunks, ~45 min" → Actual: 5 chunks, 2 hours → - → Calibrate: This codebase takes 2x estimated time -``` -**Signal:** Time estimates are currently generic, not calibrated to this codebase. - -**Data to capture:** -- Estimated chunks/time per spec -- Actual chunks/time -- Factors that caused variance - -**Use case:** "Specs in this project average 1.8x estimated time. Adjusting future estimates." - -#### Loop 13: Roadmap Priority Learning (Missing) -``` -Roadmap generates 20 features → User consistently picks "security" over "UI" → - → Learn: Prioritize security-related features higher -``` -**Signal:** User feature selection patterns reveal unstated priorities. - -**Data to capture:** -- Features presented by roadmap -- Features user selected to implement -- Features user explicitly rejected -- Order of implementation - -**Use case:** "User has implemented 5 security features before any UI features. Adjust priority weighting." - -#### Loop 14: Requirements Clarification Learning (Missing) -``` -Spec Gatherer asks: "Do you want auth?" → User clarifies: "Yes, JWT" → - → Learn: This project prefers JWT for auth (don't ask again) -``` -**Signal:** Clarifying questions and answers reveal project conventions. - -**Data to capture:** -- Questions asked during requirements gathering -- User answers -- Whether the same question was asked in previous specs - -**Use case:** "User has specified 'TypeScript strict mode' in 3 specs. Default to strict mode." - -#### Loop 15: Merge Follow-up Learning (Future - requires user feedback) -``` -Spec merged with --merge → 2 weeks later, user reports: "Caused regression" → - → Associate: This pattern/approach caused issues post-merge -``` -**Signal:** Some issues only surface after merge, in production use. - -**Data to capture:** -- Post-merge issue reports (requires explicit user feedback) -- Association with spec patterns -- Time-to-issue-discovery - -**Use case:** "Specs that modified auth/ directory have 30% higher post-merge issue rate." - ---- - -### 12.6.2 Implementation Priority for Missing Loops - -| Loop | Signal Strength | Implementation Effort | Data Already Available | Priority | -|------|-----------------|----------------------|------------------------|----------| -| **Loop 6: Discard Learning** | High (strong negative signal) | Small | Partial (need reason prompt) | P0 | -| **Loop 7: Complexity Calibration** | High (affects all planning) | Small | Yes (can compute from outcomes) | P0 | -| **Loop 8: Spec Edit Learning** | Medium (user preference signal) | Medium | Yes (can diff before/after) | P1 | -| **Loop 9: Context Accuracy** | Medium (reduces token waste) | Small | Yes (can compare context vs actual) | P1 | -| **Loop 11: Recovery Learning** | High (reduces stuck time) | Medium | Partial (need structured recovery logs) | P1 | -| **Loop 12: Time Estimation** | Medium (better UX) | Small | Yes (timestamps available) | P2 | -| **Loop 10: Research Value** | Low (only affects research phase) | Medium | Partial (need reference tracking) | P2 | -| **Loop 13: Roadmap Priority** | Medium (better prioritization) | Medium | Partial (need selection tracking) | P2 | -| **Loop 14: Requirements Learning** | Low (cumulative benefit) | Medium | No (need Q&A logging) | P3 | -| **Loop 15: Merge Follow-up** | High (but requires user action) | Large | No (requires feedback UI) | P3 | - -### 12.6.3 Quick Wins: Loops That Can Be Added with Minimal Effort - -These loops require only **adding recording calls to existing code paths**: - -**1. Discard Learning (Loop 6)** -```python -# In run.py, when --discard is invoked: -async def handle_discard(spec_dir: Path, reason: str | None = None): - # Existing discard logic... - - # NEW: Record discard for learning - await record_spec_outcome( - spec_id=spec_dir.name, - outcome="discarded", - stage_reached=get_current_stage(spec_dir), - reason=reason, - chunks_completed=count_completed_chunks(spec_dir), - ) -``` - -**2. Complexity Calibration (Loop 7)** -```python -# In qa_loop.py, when spec completes: -async def record_complexity_outcome(spec_dir: Path): - assessment = load_json(spec_dir / "complexity_assessment.json") - plan = load_json(spec_dir / "implementation_plan.json") - - await save_complexity_calibration( - predicted=assessment["complexity"], - actual_chunks=count_chunks(plan), - actual_sessions=count_sessions(spec_dir), - actual_qa_cycles=count_qa_cycles(spec_dir), - task_keywords=assessment.get("keywords", []), - ) -``` - -**3. Context Accuracy (Loop 9)** -```python -# In agent.py, at end of implementation: -async def record_context_usage(spec_dir: Path, files_actually_used: set[str]): - context = load_json(spec_dir / "context.json") - files_in_context = set(context.get("relevant_files", [])) - - await save_context_accuracy( - files_predicted=files_in_context, - files_used=files_actually_used, - files_missed=files_actually_used - files_in_context, - files_unused=files_in_context - files_actually_used, - ) -``` - ---- - -### 12.7 The Moat: Project-Specific Intelligence - -After 200 specs, the system has something no general AI can replicate: - -| General AI | Auto Claude @ Spec 200 | -|------------|------------------------| -| Knows Python patterns | Knows YOUR Python patterns | -| Suggests generic solutions | Suggests solutions that worked HERE | -| Can't predict your bugs | Predicts YOUR specific bugs | -| Doesn't know your history | Knows why every file exists | -| Treats each task fresh | Builds on 200 specs of context | - -**This is the moat**: The longer you use Auto Claude on a project, the more valuable it becomes. The knowledge is specific to YOUR codebase, YOUR patterns, YOUR gotchas. - -A new AI tool starts at zero. Auto Claude at Spec 200 has: -- 10,000+ knowledge nodes -- 700+ session insights -- 200+ spec outcomes -- Complete architectural understanding -- Predictive models trained on YOUR code - ---- - -### 12.8 Enabling This Vision - -To achieve this vision, the system needs: - -| Capability | Required For | Status | -|------------|--------------|--------| -| **Historical Context Phase** | Cross-spec learning | This PRD | -| **Project-level Memory** | Accumulated knowledge | This PRD | -| **QA Outcome Recording** | Feedback loop | This PRD | -| **Graphiti Integration** | Semantic search | Existing (underutilized) | -| **Chunk Statistics** | Complexity prediction | Future (Section 11) | -| **Architectural Modeling** | Impact prediction | Future | -| **Causal Graph** | "Why" explanations | Future | - -#### What Each Implementation Phase Enables - -| Phase | Immediate (Spec 1-5) | Medium-term (Spec 20) | Long-term (Spec 60+) | -|-------|---------------------|----------------------|---------------------| -| **Phase 1: Foundation** | Project-level search works | Cross-spec pattern matching | Statistical queries | -| **Phase 2: Historical Context** | "No history" placeholder | "Similar past tasks" surfaced | Ranked recommendations | -| **Phase 3: Prompt Updates** | Agents read history | Agents use patterns | Agents predict issues | -| **Phase 4: QA Feedback** | Outcomes recorded | Feedback loop active | Success rate tracking | -| **Phase 5: Ideation** | Avoid duplicates | Pattern extension ideas | Informed suggestions | - -#### What This PRD Does NOT Enable (Future Work) - -These capabilities require additional infrastructure beyond this PRD: - -| Future Capability | Why It's Not Here | Prerequisite | -|-------------------|-------------------|--------------| -| **Causal graph reasoning** | Requires graph schema extension | 50+ specs of data | -| **Complexity prediction models** | Requires statistical learning | 60+ specs of data | -| **Architectural impact analysis** | Requires dependency graph | Codebase map evolution | -| **Multi-project learning** | Requires federated setup | Privacy/consent model | -| **File-level bug prediction** | Requires ML pipeline | Significant training data | - -These build ON TOP of the foundation this PRD creates. They cannot exist without bidirectional memory flow. - -The Smart Context System (this PRD) is the **foundation** that enables all future improvements. Without bidirectional memory flow, none of the advanced capabilities are possible. - ---- - -### 12.9 The Ultimate Goal - -By Spec 200, Auto Claude should be able to: - -1. **Understand intent**: "Add user notifications" → knows exactly what that means in THIS codebase -2. **Predict problems**: "This will conflict with the rate limiter we added in Spec-67" -3. **Suggest approaches**: "We solved something similar in Spec-89, here's what worked" -4. **Explain history**: "This code looks weird because of the incident in Spec-45" -5. **Prevent regressions**: "This change would break the pattern we've used in 15 other places" -6. **Estimate accurately**: "This will take 3 chunks, ~4 sessions, based on similar past work" - -**The system becomes a senior developer who has worked on your project for years** - because in a very real sense, it has. - ---- - -*End of PRD* diff --git a/PRD_INTELLIGENT_VALIDATION_SYSTEM.md b/PRD_INTELLIGENT_VALIDATION_SYSTEM.md deleted file mode 100644 index 9176a85c..00000000 --- a/PRD_INTELLIGENT_VALIDATION_SYSTEM.md +++ /dev/null @@ -1,2746 +0,0 @@ -# Product Requirements Document: Intelligent Validation System - -**Project:** Auto Claude - Intelligent Validation System -**Version:** 1.0 -**Date:** 2025-12-13 -**Author:** Atlas (Principal Software Architect) -**Status:** Draft for Implementation - ---- - -## Executive Summary - -### Project Overview -Auto Claude is an autonomous coding framework that builds software through coordinated AI agent sessions. Currently, it has basic per-chunk verification, but lacks a robust, production-grade validation system. This PRD defines a comprehensive **Intelligent Validation System** that adapts verification strategies based on task risk, project type, and available testing infrastructure. - -### Success Metrics -- **Test Coverage**: 90%+ of code changes have automated test coverage -- **QA Loop Efficiency**: <3 iterations average to QA approval -- **Risk Detection Accuracy**: 95%+ accuracy in high-risk change classification -- **Zero Critical Escapes**: No critical security issues or data loss events in production -- **Autonomous Test Creation**: 80%+ of test infrastructure created autonomously by QA agent - -### Technology Stack -- **Core Language**: Python 3.10+ -- **Testing Frameworks**: pytest (Python), Vitest/Jest (JS/TS), Playwright (E2E), Go test, Cargo test -- **Security Scanning**: semgrep, npm audit, bandit, safety -- **Project Analysis**: Existing `project_analyzer.py` and `security.py` -- **Agent Framework**: Claude SDK (existing) -- **Storage**: File-based (JSON/Markdown) in spec directories - -### Timeline Estimate -- **Phase 1 (Complexity Assessor Enhancement)**: 1-2 weeks -- **Phase 2 (Verification Strategy)**: 1-2 weeks -- **Phase 3 (Smart Test Discovery + CI Integration)**: 1-2 weeks -- **Phase 4 (QA Test Creation)**: 2-3 weeks -- **Phase 5 (Security Scanning)**: 1-2 weeks -- **Phase 6 (Multi-Service Orchestration)**: 1-2 weeks -- **Phase 7 (QA Loop + No-Test Projects)**: 1-2 weeks -- **Total**: 8-13 weeks - -### Resource Requirements -- **1 Senior Backend Engineer**: Core validation engine, risk assessment -- **1 QA Automation Engineer**: Test framework integration, E2E testing -- **1 DevOps Engineer** (Part-time): Security scanning, deployment pipelines -- **AI Agents**: Planner, Coder, QA Reviewer, QA Fixer (existing agents with enhancements) - ---- - -## Table of Contents - -1. [System Architecture](#system-architecture) -2. [Feature Requirements](#feature-requirements) - - [1. Task Risk Classification](#1-task-risk-classification) - - [2. Verification Strategy (Planner Phase)](#2-verification-strategy-planner-phase) - - [3. Smart Test Discovery](#3-smart-test-discovery) - - [4. QA Test Creation](#4-qa-test-creation) - - [5. Project Type Adaptation](#5-project-type-adaptation) - - [6. High-Risk Validation](#6-high-risk-validation) - - [7. QA Loop](#7-qa-loop) - - [8. No-Test Projects](#8-no-test-projects) -3. [Technical Specifications](#technical-specifications) -4. [Implementation Plan](#implementation-plan) -5. [Testing & Validation](#testing--validation) -6. [Success Criteria](#success-criteria) - ---- - -## System Architecture - -### High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ PLANNER AGENT │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Risk Classifier → Verification Strategy Builder │ │ -│ │ - Analyzes task scope │ │ -│ │ - Determines risk level (low/medium/high/critical) │ │ -│ │ - Defines verification requirements per chunk │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - implementation_plan.json - ┌───────────────────────────────────────┐ - │ + verification_strategy section │ - │ + risk_level per chunk │ - │ + required_test_types │ - └───────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ CODER AGENT │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Implements chunks WITHOUT creating tests │ │ -│ │ - Focuses on functionality │ │ -│ │ - Tests could break as implementation evolves │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - All chunks completed - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ QA AGENT │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Smart Test Discovery Module │ │ -│ │ - Detects: package.json, pyproject.toml, Cargo.toml │ │ -│ │ - Identifies available test frameworks │ │ -│ │ - Maps frameworks → test commands │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Test Creation Module │ │ -│ │ - Reads verification_strategy from plan │ │ -│ │ - Creates coherent test suite for ENTIRE feature │ │ -│ │ - Unit + Integration + E2E as needed │ │ -│ │ - High-risk: adds security tests │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Test Execution & Validation Module │ │ -│ │ - Runs all tests │ │ -│ │ - For high-risk: security scan + staging deploy │ │ -│ │ - Generates qa_report.md │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ ↓ │ -│ APPROVED or REJECTED │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - REJECTED? - ↓ -┌─────────────────────────────────────────────────────────────────┐ -│ QA FIXER AGENT │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Reads QA_FIX_REQUEST.md → Applies fixes → Back to QA │ │ -│ └──────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ↓ - Loop until APPROVED - (Max: MAX_QA_ITERATIONS) -``` - -### Data Flow - -1. **Planning Phase**: Planner analyzes task → Generates risk classification + verification strategy → Stores in `implementation_plan.json` -2. **Implementation Phase**: Coder implements chunks → NO test creation (tests would become stale) -3. **QA Phase**: QA Agent discovers test infrastructure → Creates comprehensive tests → Executes → Reports -4. **Fix Phase** (if needed): QA Fixer reads issues → Fixes code → QA re-validates - -### Key Files & Directories - -``` -auto-claude/specs/XXX-name/ -├── implementation_plan.json # NOW INCLUDES: verification_strategy -├── risk_assessment.json # NEW: Risk analysis results -├── test_discovery.json # NEW: Detected test frameworks/commands -├── tests/ # NEW: QA-generated tests -│ ├── unit/ -│ ├── integration/ -│ └── e2e/ -├── security_scan_results.json # NEW: Security scan outputs (high-risk only) -├── qa_report.md # ENHANCED: Test results + validation -└── QA_FIX_REQUEST.md # Existing: Fix requests -``` - -### Integration Points - -- **`project_analyzer.py`**: Extend to detect test frameworks (Jest, pytest, Playwright, etc.) -- **`security.py`**: Add validators for security scanning tools (semgrep, npm audit, etc.) -- **`qa_loop.py`**: Enhance to include test creation before validation -- **`prompts/planner.md`**: Add risk assessment + verification strategy instructions -- **`prompts/qa_reviewer.md`**: Add test creation + smart discovery instructions - ---- - -## Feature Requirements - -### 1. Task Risk Classification - -#### 1.1 User Story -**As a** development team, -**I want** the system to automatically classify task risk level, -**So that** high-risk changes receive appropriate validation depth. - -#### 1.2 Functional Requirements - -**FR-1.1: Integration with Existing Complexity Assessor** - -The risk classification system **extends the existing AI-driven complexity assessor** (`prompts/complexity_assessor.md`) rather than replacing it. The complexity assessor already analyzes: -- Scope (files, services, cross-cutting concerns) -- Integrations (external services, dependencies) -- Infrastructure (Docker, database, config) -- Knowledge (patterns exist, research needed) -- Risk (concerns, security considerations) - -**Key Principle**: Use AI judgment for flexibility, not rigid rules. - -**FR-1.2: Enhanced Complexity Assessment Output** - -Extend `complexity_assessment.json` to include validation recommendations: - -```json -{ - "complexity": "standard", - "workflow_type": "feature", - "confidence": 0.85, - "reasoning": "New API endpoint with auth changes...", - - "analysis": { - "scope": { ... }, - "integrations": { ... }, - "infrastructure": { ... }, - "knowledge": { ... }, - "risk": { - "level": "high", - "concerns": ["authentication changes", "user data access"], - "security_sensitive_areas": ["auth", "user_data"], - "notes": "Modifies login flow" - } - }, - - "validation_recommendations": { - "risk_level": "high", - "skip_validation": false, - "minimal_mode": false, - "test_types_required": ["unit", "integration", "e2e"], - "security_scan_required": true, - "staging_deployment_required": true, - "reasoning": "Auth changes require comprehensive testing and security scan" - }, - - "recommended_phases": [...], - "flags": { ... } -} -``` - -**FR-1.3: Risk Level Mapping (AI-Guided)** - -The AI complexity assessor uses these guidelines (not rigid rules): - -| Indicators | Typical Risk Level | -|------------|-------------------| -| Docs, typos, comments, README | LOW (may skip validation) | -| UI tweaks, config, single-file | LOW | -| New features, 3-10 files | MEDIUM | -| Database, API changes, auth | HIGH | -| Payment, data deletion, security-critical | CRITICAL | - -**FR-1.4: Validation Mode Recommendations** - -The AI assessor recommends validation depth: - -| Risk Level | Validation Mode | Tests | Security | Staging | -|------------|-----------------|-------|----------|---------| -| **TRIVIAL** | Skip | None | No | No | -| **LOW** | Minimal | Unit only (if exist) | No | No | -| **MEDIUM** | Standard | Unit + Integration | No | No | -| **HIGH** | Full | Unit + Integration + E2E | Yes | Optional | -| **CRITICAL** | Full + Manual | All + Security tests | Yes | Yes | - -#### 1.3 Acceptance Criteria -- [ ] Complexity assessor extended with `validation_recommendations` field -- [ ] AI correctly identifies trivial tasks (docs, typos) for skip-validation -- [ ] Risk level influences test creation depth -- [ ] Security-sensitive keywords trigger HIGH+ risk classification -- [ ] Planner agent reads and uses validation recommendations - -#### 1.4 Technical Specifications - -**Enhancement**: `auto-claude/prompts/complexity_assessor.md` - -Add new section after "PHASE 3: OUTPUT ASSESSMENT": - -```markdown -## PHASE 3.5: VALIDATION RECOMMENDATIONS - -Based on your complexity and risk analysis, recommend validation depth: - -### Skip Validation Criteria (TRIVIAL) -Set `skip_validation: true` ONLY when ALL of these are true: -- Changes are documentation-only (*.md, *.rst, comments) -- OR changes are purely cosmetic (whitespace, formatting) -- OR changes are version bumps with no code changes -- No functional code is modified -- Confidence is >= 0.9 - -### Minimal Mode Criteria (LOW) -Set `minimal_mode: true` when: -- Single service affected -- < 5 files modified -- No database changes -- No API signature changes -- No security-sensitive areas touched - -### Security Scan Required -Set `security_scan_required: true` when ANY of these: -- Authentication/authorization code touched -- User data handling modified -- Payment/financial code involved -- API keys, secrets, or credentials involved -- New dependencies with network access - -### Staging Deployment Required -Set `staging_deployment_required: true` when: -- Database migrations involved -- Breaking API changes -- Risk level is CRITICAL - -### Output Format -Add to complexity_assessment.json: - -\`\`\`json -"validation_recommendations": { - "risk_level": "[trivial|low|medium|high|critical]", - "skip_validation": [true|false], - "minimal_mode": [true|false], - "test_types_required": ["unit", "integration", "e2e"], - "security_scan_required": [true|false], - "staging_deployment_required": [true|false], - "reasoning": "[Why this validation depth was chosen]" -} -\`\`\` -``` - -**Module**: `auto-claude/risk_classifier.py` - -This module reads the AI-generated `complexity_assessment.json` and provides programmatic access: - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict, Any, Optional -import json - -@dataclass -class ValidationRecommendations: - risk_level: str # trivial/low/medium/high/critical - skip_validation: bool - minimal_mode: bool - test_types_required: List[str] - security_scan_required: bool - staging_deployment_required: bool - reasoning: str - -@dataclass -class RiskAssessment: - complexity: str # simple/standard/complex - workflow_type: str - confidence: float - risk_level: str - validation: ValidationRecommendations - -class RiskClassifier: - """Reads AI-generated complexity assessment and provides risk classification.""" - - def load_assessment(self, spec_dir: Path) -> Optional[RiskAssessment]: - """Load complexity_assessment.json from spec directory.""" - assessment_file = spec_dir / "complexity_assessment.json" - if not assessment_file.exists(): - return None - - with open(assessment_file) as f: - data = json.load(f) - - validation = data.get("validation_recommendations", {}) - return RiskAssessment( - complexity=data.get("complexity", "standard"), - workflow_type=data.get("workflow_type", "feature"), - confidence=data.get("confidence", 0.5), - risk_level=validation.get("risk_level", "medium"), - validation=ValidationRecommendations( - risk_level=validation.get("risk_level", "medium"), - skip_validation=validation.get("skip_validation", False), - minimal_mode=validation.get("minimal_mode", False), - test_types_required=validation.get("test_types_required", ["unit"]), - security_scan_required=validation.get("security_scan_required", False), - staging_deployment_required=validation.get("staging_deployment_required", False), - reasoning=validation.get("reasoning", "") - ) - ) - - def should_skip_validation(self, spec_dir: Path) -> bool: - """Quick check if validation can be skipped entirely.""" - assessment = self.load_assessment(spec_dir) - return assessment and assessment.validation.skip_validation - - def get_required_test_types(self, spec_dir: Path) -> List[str]: - """Get list of required test types based on risk.""" - assessment = self.load_assessment(spec_dir) - if not assessment: - return ["unit"] # Default to unit tests - return assessment.validation.test_types_required -``` - ---- - -### 2. Verification Strategy (Planner Phase) - -#### 2.1 User Story -**As a** Planner Agent, -**I want** to define verification strategy during planning, -**So that** the QA Agent knows what types of tests are expected. - -#### 2.2 Functional Requirements - -**FR-2.1: Verification Strategy Definition** -- Planner reads `risk_assessment.json` -- For EACH chunk, defines: - - **Expected test types**: unit, integration, e2e, security - - **Acceptance criteria**: Specific behaviors to verify - - **Test coverage targets**: Percentage or critical paths - - **Security requirements**: Scans needed (if high-risk) - -**FR-2.2: Implementation Plan Enhancement** -Add `verification_strategy` section to `implementation_plan.json`: -```json -{ - "workflow_type": "feature", - "risk_assessment": { - "overall_risk_level": "high", - "risk_score": 68 - }, - "verification_strategy": { - "test_creation_phase": "post_implementation", - "test_types_required": ["unit", "integration", "e2e"], - "security_scanning_required": true, - "staging_deployment_required": true, - "acceptance_criteria": [ - "All API endpoints return correct status codes", - "Database migrations are reversible", - "Authentication flow works end-to-end" - ] - }, - "phases": [ - { - "id": "phase-1-backend", - "chunks": [ - { - "id": "chunk-1-1", - "description": "Create user model with auth fields", - "verification": { - "expected_test_types": ["unit"], - "acceptance_criteria": [ - "User model validates email format", - "Password hashing works correctly" - ], - "security_concerns": ["password_storage"] - } - } - ] - } - ] -} -``` - -**FR-2.3: Guidance, Not Requirements** -- Verification strategy is GUIDANCE for QA Agent -- QA Agent can ADD tests if coder did more than planned -- QA Agent can SKIP tests if chunk was not implemented -- Strategy is a starting point, not a constraint - -#### 2.3 Acceptance Criteria -- [ ] Planner generates verification strategy for all risk levels -- [ ] Strategy adapts based on risk level (more tests for high-risk) -- [ ] QA Agent reads and uses strategy as guidance -- [ ] QA Agent can override strategy with justification - -#### 2.4 Technical Specifications - -**Enhancement**: `auto-claude/prompts/planner.md` - -Add new section after "PHASE 3: CREATE implementation_plan.json": - -```markdown -## PHASE 3.5: DEFINE VERIFICATION STRATEGY - -After creating the implementation plan, define how QA will verify the implementation. - -### Read Risk Assessment - -cat risk_assessment.json - -### Define Verification Strategy - -Based on risk level, define: - -1. **Test Types Required** - - LOW risk: Unit tests only - - MEDIUM risk: Unit + Integration tests - - HIGH risk: Unit + Integration + E2E - - CRITICAL risk: All above + Security tests + Staging deployment - -2. **Security Requirements** (if risk_level >= HIGH) - - security_scanning_required: true - - staging_deployment_required: true - - tools: ["semgrep", "bandit", "npm audit"] - -3. **Acceptance Criteria** - - List specific behaviors to verify - - Map to chunks (which chunk verifies which criteria) - -4. **Test Coverage Targets** (optional) - - unit_coverage: 80% - - integration_coverage: 60% - -### Add to implementation_plan.json - -Update the plan with verification_strategy section. -``` - ---- - -### 3. Smart Test Discovery - -#### 3.1 User Story -**As a** QA Agent, -**I want** to automatically discover available test frameworks and commands, -**So that** I can run tests without manual configuration. - -#### 3.2 Functional Requirements - -**FR-3.1: Test Framework Detection** - -Inspect project configuration files to detect test frameworks: - -| File | Frameworks Detected | -|------|---------------------| -| `package.json` → `scripts.test` | Jest, Vitest, Mocha, Jasmine, Playwright, Cypress | -| `package.json` → `devDependencies` | `@playwright/test`, `cypress`, `jest`, `vitest` | -| `pyproject.toml` → `[tool.pytest]` | pytest | -| `pytest.ini`, `setup.cfg`, `tox.ini` | pytest | -| `Cargo.toml` → `[dev-dependencies]` | Cargo test | -| `go.mod` | Go test | -| `Gemfile` → `gem 'rspec'` | RSpec | -| `composer.json` → `phpunit` | PHPUnit | - -**FR-3.2: Test Command Mapping** - -Map detected frameworks to executable commands: - -```python -FRAMEWORK_COMMANDS = { - # JavaScript/TypeScript - "jest": "npm test", # or yarn test, pnpm test - "vitest": "npm run test", - "playwright": "npx playwright test", - "cypress": "npx cypress run", - "mocha": "npm test", - - # Python - "pytest": "pytest tests/", - - # Rust - "cargo_test": "cargo test", - - # Go - "go_test": "go test ./...", - - # Ruby - "rspec": "bundle exec rspec", - - # PHP - "phpunit": "vendor/bin/phpunit", -} -``` - -**FR-3.3: Custom Script Detection** - -If `package.json` has custom test scripts: -```json -{ - "scripts": { - "test": "vitest", - "test:unit": "vitest unit/", - "test:integration": "vitest integration/", - "test:e2e": "playwright test" - } -} -``` - -Detect and use: -- `npm run test` → Run all tests -- `npm run test:unit` → Run unit tests -- `npm run test:integration` → Run integration tests -- `npm run test:e2e` → Run E2E tests - -**FR-3.4: Output Format** - -Store discovered commands in `test_discovery.json`: -```json -{ - "project_type": "nodejs_typescript", - "frameworks_detected": [ - { - "name": "vitest", - "type": "unit", - "command": "npm run test:unit", - "config_file": "vitest.config.ts" - }, - { - "name": "playwright", - "type": "e2e", - "command": "npx playwright test", - "config_file": "playwright.config.ts" - } - ], - "test_directories": [ - "tests/unit", - "tests/integration", - "e2e" - ], - "coverage_command": "npm run test -- --coverage" -} -``` - -#### 3.3 Acceptance Criteria -- [ ] Detects Jest, Vitest, pytest, Playwright, Cypress, Go test, Cargo test -- [ ] Correctly maps frameworks to commands (npm/yarn/pnpm/bun detection) -- [ ] Handles projects with multiple test frameworks -- [ ] Stores discovery results in `test_discovery.json` - -#### 3.4 Technical Specifications - -**Module**: `auto-claude/test_discovery.py` - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict, Optional -import json - -@dataclass -class TestFramework: - name: str - type: str # unit, integration, e2e - command: str - config_file: Optional[str] - -class TestDiscovery: - """Discovers test frameworks and commands in a project.""" - - def discover(self, project_dir: Path) -> Dict: - """Main entry point: discover all test frameworks.""" - pass - - def _detect_package_manager(self, project_dir: Path) -> str: - """Detect npm/yarn/pnpm/bun.""" - pass - - def _discover_js_frameworks(self, project_dir: Path) -> List[TestFramework]: - """Discover JS/TS test frameworks from package.json.""" - pass - - def _discover_python_frameworks(self, project_dir: Path) -> List[TestFramework]: - """Discover Python test frameworks.""" - pass - - def _discover_rust_frameworks(self, project_dir: Path) -> List[TestFramework]: - """Discover Rust test frameworks.""" - pass - - def _discover_go_frameworks(self, project_dir: Path) -> List[TestFramework]: - """Discover Go test frameworks.""" - pass - - def _find_test_directories(self, project_dir: Path) -> List[str]: - """Find test directories (tests/, __tests__, etc.).""" - pass -``` - -**Integration**: Extend `project_analyzer.py` to include test framework detection. - ---- - -### 4. QA Test Creation - -#### 4.1 User Story -**As a** QA Agent, -**I want** to create comprehensive tests AFTER all chunks are complete, -**So that** I have a coherent test suite that covers the entire feature. - -#### 4.2 Functional Requirements - -**FR-4.1: Test Creation Timing** -- QA Agent creates tests AFTER all chunks marked as "completed" -- NEVER during chunk implementation (tests would break as code evolves) -- Creates coherent test suite covering entire feature - -**FR-4.2: Test Types Created** - -Based on `verification_strategy`: - -| Test Type | When to Create | Example | -|-----------|----------------|---------| -| **Unit** | Always (all risk levels) | Test individual functions, methods, components | -| **Integration** | MEDIUM+ risk | Test service interactions, API endpoints | -| **E2E** | HIGH+ risk | Test full user flows, browser automation | -| **Security** | HIGH+ risk with security concerns | Test auth, authorization, input validation | - -**FR-4.3: Test Creation Process** - -1. **Read Verification Strategy** - - Load `implementation_plan.json` → `verification_strategy` - - Load `test_discovery.json` → Available frameworks - -2. **Analyze Implemented Code** - - Read all files changed in chunks - - Identify: Functions to test, API endpoints, UI components - -3. **Generate Tests** - - Use detected framework syntax - - Follow project patterns (read existing tests for style) - - Create tests in appropriate directories - -4. **Test Organization** - ``` - specs/XXX/tests/ - ├── unit/ - │ ├── test_user_model.py - │ └── test_auth_service.py - ├── integration/ - │ └── test_user_api.py - └── e2e/ - └── test_user_registration_flow.spec.ts - ``` - -**FR-4.4: Test Template Examples** - -**Python/pytest Unit Test**: -```python -# specs/XXX/tests/unit/test_user_model.py -import pytest -from app.models.user import User - -def test_user_email_validation(): - """User model should validate email format.""" - user = User(email="invalid-email", password="test123") - with pytest.raises(ValueError): - user.validate() - -def test_password_hashing(): - """Password should be hashed before storage.""" - user = User(email="test@example.com", password="plaintext") - assert user.password != "plaintext" - assert user.password.startswith("$2b$") # bcrypt hash -``` - -**JavaScript/Vitest Integration Test**: -```typescript -// specs/XXX/tests/integration/test_user_api.ts -import { describe, it, expect } from 'vitest' -import request from 'supertest' -import { app } from '../../src/app' - -describe('POST /api/users', () => { - it('should create a new user', async () => { - const response = await request(app) - .post('/api/users') - .send({ - email: 'test@example.com', - password: 'secure123' - }) - - expect(response.status).toBe(201) - expect(response.body).toHaveProperty('id') - expect(response.body.email).toBe('test@example.com') - }) - - it('should reject invalid email', async () => { - const response = await request(app) - .post('/api/users') - .send({ - email: 'invalid', - password: 'secure123' - }) - - expect(response.status).toBe(400) - expect(response.body.error).toContain('email') - }) -}) -``` - -**Playwright E2E Test**: -```typescript -// specs/XXX/tests/e2e/test_user_registration.spec.ts -import { test, expect } from '@playwright/test' - -test('user can register and login', async ({ page }) => { - await page.goto('http://localhost:3000/register') - - // Fill registration form - await page.fill('input[name="email"]', 'newuser@example.com') - await page.fill('input[name="password"]', 'secure123') - await page.fill('input[name="confirmPassword"]', 'secure123') - await page.click('button[type="submit"]') - - // Should redirect to dashboard - await expect(page).toHaveURL('http://localhost:3000/dashboard') - await expect(page.locator('h1')).toContainText('Welcome') -}) -``` - -**FR-4.5: QA Agent Can Add Tests Beyond Strategy** - -If coder implemented additional functionality not in the plan: -- QA Agent SHOULD create tests for it -- QA Agent updates `implementation_plan.json` to reflect actual scope -- QA report includes: "Additional tests created for (not in original plan)" - -#### 4.3 Acceptance Criteria -- [ ] Tests are created AFTER all chunks complete -- [ ] Test suite is coherent (not fragmented per-chunk) -- [ ] Tests follow project conventions (analyzed from existing tests) -- [ ] Tests are organized by type (unit, integration, e2e) -- [ ] QA Agent can add tests for unplanned functionality - -#### 4.4 Technical Specifications - -**Enhancement**: `auto-claude/prompts/qa_reviewer.md` - -Add new section before "PHASE 3: RUN AUTOMATED TESTS": - -```markdown -## PHASE 2.5: CREATE TESTS (If No Tests Exist) - -**CRITICAL**: If the implementation has NO tests, you MUST create them now. - -### Step 1: Read Verification Strategy - -cat implementation_plan.json | jq '.verification_strategy' - -### Step 2: Read Test Discovery Results - -cat test_discovery.json - -### Step 3: Analyze Implemented Code - -# Find all files changed -git diff main --name-only - -# Read implemented files to understand what to test -cat [files from diff] - -### Step 4: Create Tests - -Based on verification_strategy.test_types_required: - -1. **Unit Tests**: Test individual functions/methods - - Framework: [from test_discovery.json] - - Location: tests/unit/ or __tests__/unit/ - - Pattern: test_*.py, *.test.ts, *_test.go - -2. **Integration Tests**: Test service interactions - - Framework: [from test_discovery.json] - - Location: tests/integration/ - - Pattern: test_*_api.py, *.integration.test.ts - -3. **E2E Tests**: Test full user flows (if required) - - Framework: Playwright or Cypress - - Location: e2e/ or tests/e2e/ - - Pattern: *.spec.ts - -### Step 5: Follow Existing Patterns - -# Find existing tests to copy style -find . -name "test_*.py" -o -name "*.test.ts" | head -5 - -# Read 2-3 existing test files to understand: -# - Naming conventions -# - Import patterns -# - Assertion style -# - Mocking/fixture patterns - -### Step 6: Verify Tests Run - -After creating tests, verify they execute: - -# Run the test command from test_discovery.json -[test_command] - -# If tests fail, FIX them before proceeding -# Tests must be GREEN before QA approval -``` - ---- - -### 5. Project Type Adaptation - -#### 5.1 User Story -**As a** QA Agent, -**I want** validation strategies to adapt to project type, -**So that** I use appropriate verification methods for each stack. - -#### 5.2 Functional Requirements - -**FR-5.1: Project Type Detection** - -Detect project type from: -- **`project_index.json`** (created by Planner) -- File patterns (HTML, package.json, pyproject.toml, etc.) -- Dependencies - -**FR-5.2: Validation Strategies by Project Type** - -| Project Type | Validation Strategy | -|--------------|---------------------| -| **Simple HTML/CSS** | - Visual verification (screenshots)
- Lighthouse (performance, accessibility)
- Link checking | -| **React/Vue SPA** | - Component tests (Vitest/Jest)
- E2E (Playwright)
- Browser console errors
- Lighthouse | -| **Fullstack (Next.js, Rails)** | - API tests (REST/GraphQL)
- Frontend tests
- Integration tests
- Database migration tests | -| **Python API (FastAPI, Flask)** | - pytest (unit + integration)
- httpx for API testing
- Database rollback tests | -| **CLI Tool** | - Command output verification
- Exit code checks
- Edge case testing (invalid args) | -| **Mobile (React Native, Flutter)** | - Unit tests
- Integration tests
- Emulator testing (if available) | - -**FR-5.3: Fallback Strategies** - -If NO test infrastructure exists: -- **Browser Verification**: Start dev server, load pages, check console -- **API Verification**: Manual curl/httpx requests to endpoints -- **CLI Verification**: Run commands, verify output -- **Documentation**: Create `MANUAL_TEST_PLAN.md` with steps - -**FR-5.4: Example Adaptation: Simple HTML/CSS** - -```python -# QA Agent detects: No package.json, just HTML/CSS files -# Validation strategy: -1. Start simple HTTP server (python -m http.server) -2. Load each page in headless browser (Playwright) -3. Take screenshots (before/after) -4. Check console for errors -5. Run Lighthouse audit: - - Performance score > 90 - - Accessibility score > 90 - - No broken links -``` - -**FR-5.5: Example Adaptation: Python API** - -```python -# QA Agent detects: FastAPI project with pytest -# Validation strategy: -1. Run pytest tests/ (unit + integration) -2. Check coverage (pytest --cov) -3. Test API endpoints with httpx: - - POST /api/users (201 expected) - - GET /api/users/123 (200 expected) - - DELETE /api/users/999 (404 expected) -4. Check database migrations: - - alembic current (should be latest) - - Test rollback (alembic downgrade -1 && alembic upgrade head) -``` - -#### 5.3 Acceptance Criteria -- [ ] QA Agent adapts validation to project type -- [ ] Simple HTML/CSS projects use visual + Lighthouse validation -- [ ] SPA projects use component + E2E tests -- [ ] API projects use pytest/httpx + database checks -- [ ] CLI projects use command output verification -- [ ] Fallback to manual test plan if no test infra - -#### 5.4 Technical Specifications - -**Module**: `auto-claude/validation_strategy.py` - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict - -@dataclass -class ValidationStep: - name: str - command: str - expected_outcome: str - type: str # test, visual, api, security - -class ValidationStrategyBuilder: - """Builds validation strategy based on project type.""" - - def build_strategy( - self, - project_dir: Path, - spec_dir: Path, - risk_level: str - ) -> List[ValidationStep]: - """Main entry point: build validation strategy.""" - pass - - def _detect_project_type(self, project_dir: Path) -> str: - """Detect project type (html, spa, fullstack, api, cli).""" - pass - - def _strategy_for_html_css(self, risk_level: str) -> List[ValidationStep]: - """Visual + Lighthouse validation.""" - pass - - def _strategy_for_spa(self, risk_level: str) -> List[ValidationStep]: - """Component + E2E tests.""" - pass - - def _strategy_for_fullstack(self, risk_level: str) -> List[ValidationStep]: - """API + Frontend + Integration tests.""" - pass - - def _strategy_for_python_api(self, risk_level: str) -> List[ValidationStep]: - """pytest + API tests + DB checks.""" - pass - - def _strategy_for_cli(self, risk_level: str) -> List[ValidationStep]: - """Command output verification.""" - pass -``` - ---- - -### 6. High-Risk Validation - -#### 6.1 User Story -**As a** development team, -**I want** high-risk changes to undergo additional validation, -**So that** we prevent security issues and data loss. - -#### 6.2 Functional Requirements - -**FR-6.1: High-Risk Triggers** - -Automatic high-risk validation when: -- `complexity_assessment.json` → `validation_recommendations.risk_level` is "high" or "critical" -- `validation_recommendations.security_scan_required` is `true` -- `validation_recommendations.staging_deployment_required` is `true` - -**FR-6.2: Security Scanning (Integrates Existing scan_secrets.py)** - -The security scanner **integrates the existing `scan_secrets.py`** module plus additional SAST tools: - -| Scan Type | Tool | Source | -|-----------|------|--------| -| **Secrets Detection** | `scan_secrets.py` | **EXISTING** - 50+ patterns for API keys, tokens, credentials | -| **Python SAST** | `bandit` | External tool | -| **Python Dependencies** | `safety` | External tool | -| **JS/TS Dependencies** | `npm audit` | Built-in npm | -| **Multi-language SAST** | `semgrep` | External tool | -| **Rust Dependencies** | `cargo audit` | External tool | -| **Go SAST** | `gosec` | External tool | - -**FR-6.3: Secrets Scanning (Using Existing Infrastructure)** - -The existing `scan_secrets.py` provides: -- 50+ service-specific patterns (OpenAI, AWS, GitHub, Stripe, Slack, etc.) -- Private key detection (RSA, OpenSSH, DSA, EC, PGP) -- Database connection strings with embedded credentials -- False positive filtering -- JSON output mode for programmatic use -- Custom ignore patterns via `.secretsignore` - -**Integration:** -```python -from scan_secrets import scan_files, get_all_tracked_files - -# Scan all tracked files -files = get_all_tracked_files() -matches = scan_files(files, project_dir) - -# Or scan specific changed files -changed_files = get_changed_files_from_spec(spec_dir) -matches = scan_files(changed_files, project_dir) -``` - -**FR-6.4: Security Scan Execution** - -```bash -# 1. Secrets scan (ALWAYS run for HIGH+ risk) -python auto-claude/scan_secrets.py --path . --json > secrets_scan.json - -# 2. Python SAST (if Python project) -bandit -r src/ -f json -o bandit_results.json -safety check --json > safety_results.json - -# 3. JavaScript/TypeScript (if JS/TS project) -npm audit --json > npm_audit_results.json - -# 4. Multi-language SAST (optional, if semgrep available) -semgrep --config=auto src/ --json > semgrep_results.json -``` - -**FR-6.5: Security Scan Results Processing** - -Parse scan results and: -- **Block if CRITICAL vulnerabilities found** (including ANY secrets detected) -- **Block if secrets detected** (secrets are always CRITICAL) -- **Warn if HIGH vulnerabilities found** (allow with manual review) -- **Log LOW/MEDIUM** for future reference - -**Secrets are always blocking:** -```python -if secrets_scan_results["secrets_found"]: - return BlockingResult( - severity="CRITICAL", - reason="Secrets detected in code", - matches=secrets_scan_results["matches"] - ) -``` - -**FR-6.6: Staging Deployment** - -For CRITICAL risk tasks: -1. **Deploy to staging environment** (if available) -2. **Run smoke tests** on staging -3. **Check: No errors in logs** -4. **Check: Services are healthy** -5. **Rollback if issues detected** - -**FR-6.7: Manual Review Checkpoint** - -For CRITICAL risk: -- QA Agent adds manual review flag to `qa_report.md` -- Human review required before merge -- Document: What to review, why it's critical - -#### 6.3 Acceptance Criteria -- [ ] Secrets scan runs automatically for high-risk tasks (using existing scan_secrets.py) -- [ ] ANY detected secrets block QA approval -- [ ] SAST scans run based on detected project type -- [ ] CRITICAL vulnerabilities block QA approval -- [ ] Staging deployment works for projects with staging config -- [ ] Manual review checkpoint added for CRITICAL risk -- [ ] All scan results consolidated in `security_scan_results.json` - -#### 6.4 Technical Specifications - -**Module**: `auto-claude/security_scanner.py` - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict, Optional -import subprocess -import json - -# Import existing secrets scanner -from scan_secrets import scan_files, scan_content, SecretMatch - -@dataclass -class SecurityVulnerability: - severity: str # critical, high, medium, low - source: str # secrets, bandit, npm_audit, semgrep, etc. - title: str - description: str - file: Optional[str] - line: Optional[int] - cwe: Optional[str] - -class SecurityScanner: - """Runs security scans including existing secrets scanner.""" - - def scan(self, project_dir: Path, spec_dir: Path, changed_files: List[str] = None) -> Dict: - """Main entry point: run all applicable security scans.""" - results = { - "secrets": self._run_secrets_scan(project_dir, changed_files), - "sast": [], - "dependencies": [], - "blocking": False, - "blocking_reasons": [] - } - - # Run SAST based on project type - if self._is_python_project(project_dir): - results["sast"].extend(self._run_bandit(project_dir)) - results["dependencies"].extend(self._run_safety(project_dir)) - - if self._is_js_project(project_dir): - results["dependencies"].extend(self._run_npm_audit(project_dir)) - - # Determine if blocking - results["blocking"] = self._should_block_qa(results) - results["blocking_reasons"] = self._get_blocking_reasons(results) - - # Save results - self._save_results(spec_dir, results) - return results - - def _run_secrets_scan(self, project_dir: Path, changed_files: List[str] = None) -> Dict: - """Run existing scan_secrets.py on changed files.""" - if changed_files: - files_to_scan = changed_files - else: - # Scan all tracked files - from scan_secrets import get_all_tracked_files - files_to_scan = get_all_tracked_files() - - matches = scan_files(files_to_scan, project_dir) - - return { - "secrets_found": len(matches) > 0, - "count": len(matches), - "matches": [ - { - "file": m.file_path, - "line": m.line_number, - "type": m.pattern_name, - "severity": "critical" # Secrets are always critical - } - for m in matches - ] - } - - def _should_block_qa(self, results: Dict) -> bool: - """Determine if vulnerabilities should block QA approval.""" - # Secrets ALWAYS block - if results["secrets"]["secrets_found"]: - return True - - # Critical SAST findings block - for vuln in results["sast"]: - if vuln.severity == "critical": - return True - - # Critical dependency vulnerabilities block - for vuln in results["dependencies"]: - if vuln.severity == "critical": - return True - - return False - - def _run_bandit(self, project_dir: Path) -> List[SecurityVulnerability]: - """Run bandit (Python SAST).""" - try: - result = subprocess.run( - ["bandit", "-r", str(project_dir), "-f", "json"], - capture_output=True, text=True - ) - return self._parse_bandit_results(result.stdout) - except FileNotFoundError: - return [] # bandit not installed - - def _run_npm_audit(self, project_dir: Path) -> List[SecurityVulnerability]: - """Run npm audit (Node.js dependencies).""" - try: - result = subprocess.run( - ["npm", "audit", "--json"], - cwd=project_dir, - capture_output=True, text=True - ) - return self._parse_npm_audit_results(result.stdout) - except FileNotFoundError: - return [] - - # ... additional methods -``` - -**Integration**: Call from `qa_loop.py` before running tests if `security_scan_required == True`. - ---- - -### 7. QA Loop - -#### 7.1 User Story -**As a** QA system, -**I want** to iteratively fix issues until approval, -**So that** quality is guaranteed before merge. - -#### 7.2 Functional Requirements - -**FR-7.1: QA Loop Flow** - -``` -┌─────────────────────────────────────┐ -│ QA REVIEWER │ -│ - Runs tests │ -│ - Validates acceptance criteria │ -│ - Checks security (if high-risk) │ -└─────────────────────────────────────┘ - ↓ - ┌──────────────┐ - │ APPROVED? │ - └──────────────┘ - / \ - YES NO - ↓ ↓ - ┌─────────┐ ┌──────────────────────┐ - │ DONE │ │ QA FIXER │ - │ MERGE │ │ - Reads issues │ - └─────────┘ │ - Applies fixes │ - │ - Commits │ - └──────────────────────┘ - ↓ - Back to QA REVIEWER - ↓ - (Loop until approved - or MAX_ITERATIONS) -``` - -**FR-7.2: Handoff Protocol** - -**QA Reviewer → QA Fixer**: -- Creates `QA_FIX_REQUEST.md` with specific issues -- Updates `implementation_plan.json` → `qa_signoff.status` = "rejected" -- Sets `qa_signoff.ready_for_qa_revalidation` = false - -**QA Fixer → QA Reviewer**: -- Fixes all issues in `QA_FIX_REQUEST.md` -- Commits fixes with message: `fix: Address QA issues (qa-requested)` -- Updates `implementation_plan.json` → `qa_signoff.status` = "fixes_applied" -- Sets `qa_signoff.ready_for_qa_revalidation` = true - -**FR-7.3: Maximum Iterations** - -```python -MAX_QA_ITERATIONS = 5 # Default, configurable - -# If exceeded: -# 1. Mark build as "needs_human_review" -# 2. Create escalation report -# 3. Notify user (if Linear integration enabled) -``` - -**FR-7.4: Iteration Tracking** - -Store in `implementation_plan.json`: -```json -{ - "qa_signoff": { - "status": "rejected", // approved, rejected, fixes_applied - "qa_session": 3, - "fix_sessions": 2, - "issues_found": [ - { - "iteration": 1, - "issue": "Test failure: test_user_email_validation", - "fixed": true - }, - { - "iteration": 2, - "issue": "Console error: TypeError in UserForm", - "fixed": true - }, - { - "iteration": 3, - "issue": "Missing migration for new column", - "fixed": false // Current iteration - } - ], - "ready_for_qa_revalidation": false - } -} -``` - -**FR-7.5: Preventing Infinite Loops** - -- Track issue recurrence (same issue fails multiple times) -- If issue recurs 3+ times: - - Escalate to human - - Mark as "unfixable_by_agent" - - Create detailed report with root cause analysis - -#### 7.3 Acceptance Criteria -- [ ] QA loop iterates until approval or max iterations -- [ ] Clear handoff protocol between QA Reviewer and QA Fixer -- [ ] Iteration count tracked in `implementation_plan.json` -- [ ] Infinite loops prevented (max iterations + recurrence detection) -- [ ] Human escalation works when loop fails - -#### 7.4 Technical Specifications - -**Enhancement**: `auto-claude/qa_loop.py` - -Add iteration tracking: - -```python -async def run_qa_validation_loop( - project_dir: Path, - spec_dir: Path, - model: str, - verbose: bool = False, - max_iterations: int = MAX_QA_ITERATIONS, -) -> bool: - """ - Run the full QA validation loop with iteration tracking. - """ - iteration = 0 - issue_history = [] # Track issues across iterations - - while iteration < max_iterations: - iteration += 1 - - # Run QA Reviewer - status, issues = await run_qa_reviewer_session(...) - - if status == "approved": - return True - - # Check for recurring issues - if _has_recurring_issues(issues, issue_history): - await _escalate_to_human(spec_dir, issues) - return False - - issue_history.extend(issues) - - # Run QA Fixer - fix_status = await run_qa_fixer_session(...) - - if fix_status != "fixed": - return False - - # Max iterations exceeded - await _escalate_to_human(spec_dir, issue_history) - return False - -def _has_recurring_issues( - current_issues: List[str], - history: List[str] -) -> bool: - """Check if same issues appear 3+ times.""" - for issue in current_issues: - count = sum(1 for h in history if _similar(h, issue)) - if count >= 3: - return True - return False -``` - ---- - -### 8. No-Test Projects - -#### 8.1 User Story -**As a** QA Agent, -**I want** to handle projects with no test infrastructure, -**So that** validation still occurs even without automated tests. - -#### 8.2 Functional Requirements - -**FR-8.1: Detection** - -Detect "no-test project" when: -- `test_discovery.json` → `frameworks_detected` is empty -- No test directories found -- No test scripts in `package.json`/`pyproject.toml` - -**FR-8.2: Minimal Test Infrastructure Creation** - -QA Agent creates minimal test setup: - -**For Python**: -```bash -# Create test directory -mkdir -p tests - -# Create pytest.ini -cat > pytest.ini << EOF -[pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -EOF - -# Install pytest (if not present) -pip install pytest - -# Create sample test -cat > tests/test_example.py << EOF -def test_placeholder(): - """Placeholder test - QA Agent will create real tests.""" - assert True -EOF -``` - -**For JavaScript/TypeScript**: -```bash -# Install Vitest -npm install -D vitest - -# Update package.json scripts -npm pkg set scripts.test="vitest" - -# Create vitest.config.ts -cat > vitest.config.ts << EOF -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - globals: true, - }, -}) -EOF - -# Create sample test -mkdir -p tests -cat > tests/example.test.ts << EOF -import { describe, it, expect } from 'vitest' - -describe('placeholder', () => { - it('should pass', () => { - expect(true).toBe(true) - }) -}) -EOF -``` - -**FR-8.3: Fallback Verification** - -If test infrastructure creation fails or is inappropriate: - -1. **Browser Verification** (for web projects): - ```python - # Start dev server - subprocess.Popen(["npm", "run", "dev"]) - - # Use Playwright to verify - from playwright.sync_api import sync_playwright - - with sync_playwright() as p: - browser = p.chromium.launch() - page = browser.new_page() - page.goto("http://localhost:3000") - - # Check: No console errors - errors = page.evaluate("() => window.console.errors || []") - assert len(errors) == 0 - - # Check: Page renders - assert page.title() != "" - - browser.close() - ``` - -2. **API Verification** (for API projects): - ```python - import httpx - - # Start API server - subprocess.Popen(["python", "main.py"]) - - # Verify endpoints - response = httpx.post("http://localhost:8000/api/users", json={ - "email": "test@example.com", - "password": "test123" - }) - assert response.status_code == 201 - ``` - -3. **Manual Test Plan** (fallback): - ```markdown - # MANUAL_TEST_PLAN.md - - ## Feature: User Registration - - ### Test Case 1: Valid Registration - **Steps:** - 1. Navigate to http://localhost:3000/register - 2. Enter email: test@example.com - 3. Enter password: secure123 - 4. Click "Register" - - **Expected:** - - User is created - - Redirect to /dashboard - - No console errors - - ### Test Case 2: Invalid Email - **Steps:** - 1. Navigate to http://localhost:3000/register - 2. Enter email: invalid-email - 3. Enter password: secure123 - 4. Click "Register" - - **Expected:** - - Error message: "Invalid email format" - - No redirect - ``` - -**FR-8.4: Documentation** - -Create `QA_TESTING_APPROACH.md`: -```markdown -# QA Testing Approach - -## Project Type -Simple HTML/CSS website - -## Test Infrastructure -**Status**: No automated tests - -## Verification Strategy -- Manual browser testing (Chrome, Firefox, Safari) -- Lighthouse audit (Performance, Accessibility, SEO) -- Visual regression (screenshots) - -## Manual Test Plan -See MANUAL_TEST_PLAN.md - -## Recommendations for Future -1. Add Playwright for E2E testing -2. Add Lighthouse CI to CI/CD pipeline -3. Add Percy or Chromatic for visual regression -``` - -#### 8.3 Acceptance Criteria -- [ ] QA Agent detects projects with no test infrastructure -- [ ] Creates minimal test setup (pytest, Vitest) when appropriate -- [ ] Falls back to browser/API verification when tests are inappropriate -- [ ] Creates manual test plan as last resort -- [ ] Documents testing approach in `QA_TESTING_APPROACH.md` - -#### 8.4 Technical Specifications - -**Enhancement**: `auto-claude/prompts/qa_reviewer.md` - -Add section after test discovery: - -```markdown -## PHASE 2.75: HANDLE NO-TEST PROJECTS - -If test_discovery.json shows NO test frameworks: - -### Option 1: Create Minimal Test Infrastructure - -**For Python projects:** -1. Create tests/ directory -2. Create pytest.ini -3. Install pytest -4. Create sample test file - -**For JavaScript/TypeScript:** -1. Install vitest -2. Update package.json scripts -3. Create vitest.config.ts -4. Create sample test file - -### Option 2: Fallback Verification - -**For web projects:** -- Start dev server -- Use Playwright for browser verification -- Check: No console errors -- Check: Page renders -- Take screenshots - -**For API projects:** -- Start API server -- Use httpx for endpoint verification -- Check: Expected status codes -- Check: Response format - -### Option 3: Manual Test Plan - -If automated testing is not appropriate: -- Create MANUAL_TEST_PLAN.md -- Document test cases with steps and expected results -- Create QA_TESTING_APPROACH.md explaining why manual testing -``` - ---- - -### 9. Multi-Service Testing - -#### 9.1 User Story -**As a** QA Agent working on monorepo or multi-service projects, -**I want** to automatically orchestrate service dependencies before testing, -**So that** integration tests can run against real service instances. - -#### 9.2 Functional Requirements - -**FR-9.1: Multi-Service Detection** - -Detect multi-service projects when: -- `docker-compose.yml` or `docker-compose.yaml` exists -- Multiple `package.json` / `pyproject.toml` in subdirectories -- Monorepo tool configs exist: `nx.json`, `turbo.json`, `lerna.json`, `pnpm-workspace.yaml` -- `project_index.json` shows `project_type: "monorepo"` - -**FR-9.2: Service Orchestration** - -Before running integration/e2e tests: - -```bash -# 1. Detect docker-compose file -if [ -f docker-compose.test.yml ]; then - COMPOSE_FILE="docker-compose.test.yml" -elif [ -f docker-compose.yml ]; then - COMPOSE_FILE="docker-compose.yml" -fi - -# 2. Start services -docker-compose -f $COMPOSE_FILE up -d - -# 3. Wait for health checks -docker-compose -f $COMPOSE_FILE ps --filter "health=healthy" - -# 4. Run tests -npm run test:integration - -# 5. Tear down -docker-compose -f $COMPOSE_FILE down -``` - -**FR-9.3: Service Health Checks** - -Wait for services to be healthy before running tests: - -```python -import time -import subprocess - -def wait_for_services(compose_file: str, timeout: int = 120) -> bool: - """Wait for all services to be healthy.""" - start = time.time() - while time.time() - start < timeout: - result = subprocess.run( - ["docker-compose", "-f", compose_file, "ps", "--format", "json"], - capture_output=True, text=True - ) - services = json.loads(result.stdout) - - all_healthy = all( - s.get("Health") == "healthy" or s.get("State") == "running" - for s in services - ) - - if all_healthy: - return True - - time.sleep(5) - - return False -``` - -**FR-9.4: External Service Mocking** - -For services that can't be dockerized (third-party APIs): - -| Service Type | Mocking Strategy | -|--------------|------------------| -| AWS S3 | `moto` (Python) or `localstack` (Docker) | -| External HTTP APIs | `httpretty`, `responses` (Python) or `msw` (JS) | -| Stripe | `stripe-mock` Docker image | -| Database | Use test containers or Docker Compose | - -**FR-9.5: API Contract Validation** - -For frontend-backend coordination: - -```python -# Detect API schemas -schemas_found = [] -if (project_dir / "openapi.yaml").exists(): - schemas_found.append("openapi") -if (project_dir / "schema.graphql").exists(): - schemas_found.append("graphql") - -# Run contract validation -if "openapi" in schemas_found: - # Compare current schema against main branch - subprocess.run([ - "openapi-diff", - "main:openapi.yaml", - "HEAD:openapi.yaml", - "--fail-on-breaking" - ]) -``` - -**FR-9.6: Output Format** - -Store in `service_orchestration.json`: -```json -{ - "project_type": "monorepo", - "services_detected": [ - {"name": "api", "path": "services/api", "port": 8000}, - {"name": "frontend", "path": "apps/web", "port": 3000}, - {"name": "postgres", "type": "docker", "port": 5432} - ], - "compose_file": "docker-compose.test.yml", - "orchestration_used": true, - "services_started": ["api", "frontend", "postgres"], - "health_check_passed": true, - "mock_services": ["stripe"], - "api_contracts": { - "type": "openapi", - "breaking_changes": false - } -} -``` - -#### 9.3 Acceptance Criteria -- [ ] Detect multi-service projects (docker-compose, monorepo tools) -- [ ] Start services via docker-compose before integration tests -- [ ] Wait for health checks before running tests -- [ ] Tear down services after tests complete -- [ ] Provide mocking guidance for external services -- [ ] API contract validation for breaking changes - -#### 9.4 Technical Specifications - -**Module**: `auto-claude/service_orchestrator.py` - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict, Optional -import subprocess -import json -import time - -@dataclass -class ServiceConfig: - name: str - path: Optional[str] - port: Optional[int] - type: str # docker, local, mock - health_endpoint: Optional[str] - -class ServiceOrchestrator: - """Orchestrates multi-service environments for testing.""" - - def __init__(self, project_dir: Path): - self.project_dir = project_dir - self.compose_file = self._find_compose_file() - - def _find_compose_file(self) -> Optional[str]: - """Find docker-compose file.""" - for name in ["docker-compose.test.yml", "docker-compose.test.yaml", - "docker-compose.yml", "docker-compose.yaml"]: - if (self.project_dir / name).exists(): - return name - return None - - def is_multi_service(self) -> bool: - """Detect if project is multi-service.""" - if self.compose_file: - return True - # Check for monorepo tools - monorepo_markers = ["nx.json", "turbo.json", "lerna.json", "pnpm-workspace.yaml"] - return any((self.project_dir / m).exists() for m in monorepo_markers) - - def start_services(self, timeout: int = 120) -> bool: - """Start all services and wait for health.""" - if not self.compose_file: - return True # No services to start - - # Start services - subprocess.run( - ["docker-compose", "-f", self.compose_file, "up", "-d"], - cwd=self.project_dir, check=True - ) - - # Wait for health - return self._wait_for_health(timeout) - - def stop_services(self) -> None: - """Stop all services.""" - if self.compose_file: - subprocess.run( - ["docker-compose", "-f", self.compose_file, "down"], - cwd=self.project_dir - ) - - def _wait_for_health(self, timeout: int) -> bool: - """Wait for all services to be healthy.""" - # Implementation as shown above - pass -``` - -**Integration**: Call from `qa_loop.py` before running integration/e2e tests. - ---- - -### 10. CI/CD Integration - -#### 10.1 User Story -**As a** developer with existing CI/CD pipelines, -**I want** Auto Claude to integrate with my existing CI configuration, -**So that** validation reuses existing test infrastructure. - -#### 10.2 Functional Requirements - -**FR-10.1: CI Configuration Detection** - -Detect existing CI configuration: - -| File Pattern | CI System | -|--------------|-----------| -| `.github/workflows/*.yml` | GitHub Actions | -| `.gitlab-ci.yml` | GitLab CI | -| `Jenkinsfile` | Jenkins | -| `azure-pipelines.yml` | Azure DevOps | -| `bitbucket-pipelines.yml` | Bitbucket | -| `.circleci/config.yml` | CircleCI | -| `.travis.yml` | Travis CI | - -**FR-10.2: Test Command Extraction** - -Extract test commands from CI config: - -```yaml -# Example: .github/workflows/test.yml -name: Test -on: [push, pull_request] -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install dependencies - run: npm ci - - name: Run tests - run: npm run test:ci # <-- Extract this command - - name: Run e2e - run: npm run test:e2e # <-- And this -``` - -Extracted to `ci_discovery.json`: -```json -{ - "ci_system": "github_actions", - "config_file": ".github/workflows/test.yml", - "test_commands": { - "unit": "npm run test:ci", - "e2e": "npm run test:e2e" - }, - "has_coverage": true, - "coverage_command": "npm run test:ci -- --coverage", - "environment_variables": ["DATABASE_URL", "API_KEY"] -} -``` - -**FR-10.3: CI vs Local Execution Choice** - -The QA Agent can choose: - -| Option | When to Use | -|--------|-------------| -| **Run locally** | Fast feedback, no CI secrets needed | -| **Trigger CI** | Need CI secrets, matrix testing, production-like environment | -| **Hybrid** | Run unit tests locally, trigger CI for integration/e2e | - -**FR-10.4: Incremental Test Detection** - -Only run tests affected by changed files: - -```python -def get_affected_tests(changed_files: List[str], project_dir: Path) -> List[str]: - """Determine which tests to run based on changed files.""" - - # Use built-in tools if available - if (project_dir / "jest.config.js").exists(): - # Jest has built-in changed detection - return ["npx jest --changedSince=main"] - - if (project_dir / "vitest.config.ts").exists(): - # Vitest with changed files - return ["npx vitest --changed"] - - # Manual mapping for Python - test_files = [] - for changed in changed_files: - if changed.endswith(".py"): - # Map src/module.py -> tests/test_module.py - test_path = changed.replace("src/", "tests/test_") - if Path(test_path).exists(): - test_files.append(test_path) - - if test_files: - return [f"pytest {' '.join(test_files)}"] - - return ["pytest tests/"] # Fallback to all tests -``` - -**FR-10.5: Test Caching** - -Respect and utilize existing caches: - -```yaml -# Detect cache configuration from CI -cache_detected: - - type: npm - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - - type: pip - path: ~/.cache/pip - key: pip-${{ hashFiles('requirements.txt') }} -``` - -Before running tests locally: -```bash -# Restore cache if available -if [ -d ~/.npm ]; then - echo "Using cached npm modules" -fi - -# Or pull from CI cache service -gh cache restore npm-cache -``` - -**FR-10.6: Coverage Threshold Enforcement** - -Extract coverage thresholds from project config: - -```python -def get_coverage_threshold(project_dir: Path) -> Optional[int]: - """Extract coverage threshold from project config.""" - - # Check jest.config.js - jest_config = project_dir / "jest.config.js" - if jest_config.exists(): - # Parse coverageThreshold - pass - - # Check pyproject.toml - pyproject = project_dir / "pyproject.toml" - if pyproject.exists(): - import tomli - config = tomli.loads(pyproject.read_text()) - return config.get("tool", {}).get("coverage", {}).get("fail_under") - - # Check codecov.yml - codecov = project_dir / "codecov.yml" - if codecov.exists(): - # Parse target coverage - pass - - return None -``` - -#### 10.3 Acceptance Criteria -- [ ] Detect existing CI configuration files -- [ ] Extract test commands from CI config -- [ ] Provide option to run locally vs trigger CI -- [ ] Support incremental test execution (changed files only) -- [ ] Respect coverage thresholds from project config -- [ ] Cache awareness for faster test execution - -#### 10.4 Technical Specifications - -**Module**: `auto-claude/ci_discovery.py` - -```python -from dataclasses import dataclass -from pathlib import Path -from typing import List, Dict, Optional -import yaml -import re - -@dataclass -class CIConfig: - ci_system: str # github_actions, gitlab, jenkins, etc. - config_file: str - test_commands: Dict[str, str] # unit, integration, e2e -> command - coverage_command: Optional[str] - environment_variables: List[str] - cache_config: Dict[str, any] - -class CIDiscovery: - """Discovers and parses existing CI configuration.""" - - def discover(self, project_dir: Path) -> Optional[CIConfig]: - """Main entry point: discover CI configuration.""" - - # Check GitHub Actions - gh_workflows = project_dir / ".github" / "workflows" - if gh_workflows.exists(): - return self._parse_github_actions(gh_workflows) - - # Check GitLab CI - gitlab_ci = project_dir / ".gitlab-ci.yml" - if gitlab_ci.exists(): - return self._parse_gitlab_ci(gitlab_ci) - - # Check other CI systems... - return None - - def _parse_github_actions(self, workflows_dir: Path) -> CIConfig: - """Parse GitHub Actions workflow files.""" - test_commands = {} - env_vars = set() - - for workflow_file in workflows_dir.glob("*.yml"): - with open(workflow_file) as f: - workflow = yaml.safe_load(f) - - for job_name, job in workflow.get("jobs", {}).items(): - for step in job.get("steps", []): - run_cmd = step.get("run", "") - - # Detect test commands - if "test" in run_cmd.lower(): - if "e2e" in run_cmd or "playwright" in run_cmd: - test_commands["e2e"] = run_cmd - elif "integration" in run_cmd: - test_commands["integration"] = run_cmd - else: - test_commands["unit"] = run_cmd - - # Extract environment variables - for match in re.findall(r'\$\{\{\s*secrets\.(\w+)\s*\}\}', run_cmd): - env_vars.add(match) - - return CIConfig( - ci_system="github_actions", - config_file=str(workflows_dir), - test_commands=test_commands, - coverage_command=test_commands.get("unit", "") + " --coverage", - environment_variables=list(env_vars), - cache_config={} - ) - - def get_test_command(self, project_dir: Path, test_type: str = "unit") -> str: - """Get the appropriate test command for a test type.""" - config = self.discover(project_dir) - if config and test_type in config.test_commands: - return config.test_commands[test_type] - - # Fallback to defaults - defaults = { - "unit": "npm test", - "integration": "npm run test:integration", - "e2e": "npm run test:e2e" - } - return defaults.get(test_type, "npm test") -``` - -**Integration**: Call from `test_discovery.py` to augment test command detection. - ---- - -## Technical Specifications - -### Database Schema (File-Based) - -All data stored in JSON/Markdown files within spec directories: - -**`risk_assessment.json`**: -```json -{ - "schema_version": "1.0", - "overall_risk_level": "high", - "risk_score": 68, - "risk_factors": { ... }, - "verification_requirements": { ... } -} -``` - -**`test_discovery.json`**: -```json -{ - "schema_version": "1.0", - "project_type": "nodejs_typescript", - "frameworks_detected": [ ... ], - "test_directories": [ ... ], - "commands": { ... } -} -``` - -**`implementation_plan.json` (enhanced)**: -```json -{ - "workflow_type": "feature", - "risk_assessment": { ... }, - "verification_strategy": { - "test_creation_phase": "post_implementation", - "test_types_required": ["unit", "integration"], - "security_scanning_required": true, - "staging_deployment_required": false, - "acceptance_criteria": [ ... ] - }, - "phases": [ ... ], - "qa_signoff": { - "status": "approved", - "qa_session": 2, - "fix_sessions": 1, - "timestamp": "2025-12-13T10:30:00Z", - "tests_passed": { - "unit": "45/45", - "integration": "12/12", - "e2e": "5/5" - } - } -} -``` - -### API Specifications (Internal Python APIs) - -**RiskClassifier API**: -```python -class RiskClassifier: - def analyze_spec(self, spec_dir: Path) -> RiskAssessment: - """ - Analyze spec and return risk assessment. - - Args: - spec_dir: Path to spec directory - - Returns: - RiskAssessment object with risk level and factors - """ - pass -``` - -**TestDiscovery API**: -```python -class TestDiscovery: - def discover(self, project_dir: Path) -> Dict: - """ - Discover test frameworks and commands. - - Args: - project_dir: Path to project root - - Returns: - Dict with frameworks_detected, commands, directories - """ - pass -``` - -**SecurityScanner API**: -```python -class SecurityScanner: - def scan(self, project_dir: Path, spec_dir: Path) -> Dict: - """ - Run security scans and return results. - - Args: - project_dir: Project root - spec_dir: Spec directory for storing results - - Returns: - Dict with vulnerabilities and scan status - """ - pass -``` - -### New Modules & Files - -**New Python Modules**: -1. `auto-claude/risk_classifier.py` - Reads AI-generated complexity_assessment.json, provides programmatic access -2. `auto-claude/test_discovery.py` - Detects test frameworks and commands -3. `auto-claude/security_scanner.py` - **Integrates existing `scan_secrets.py`** + SAST tools -4. `auto-claude/validation_strategy.py` - Builds validation strategy based on project type -5. `auto-claude/test_creator.py` - Creates tests after implementation -6. `auto-claude/service_orchestrator.py` - **NEW** Multi-service/monorepo orchestration -7. `auto-claude/ci_discovery.py` - **NEW** Detects and parses CI configuration - -**Leveraged Existing Modules** (no changes needed, just integration): -1. `auto-claude/scan_secrets.py` - **EXISTING** - Secrets detection (50+ patterns) -2. `auto-claude/prompts/complexity_assessor.md` - **EXISTING** - AI-driven complexity assessment - -**Enhanced Existing Modules**: -1. `auto-claude/project_analyzer.py` - Add test framework detection -2. `auto-claude/security.py` - Add security tool validators -3. `auto-claude/qa_loop.py` - Add test creation, iteration tracking, service orchestration - -**Enhanced Prompts**: -1. `auto-claude/prompts/complexity_assessor.md` - **Add `validation_recommendations` output** -2. `auto-claude/prompts/planner.md` - Read validation recommendations, define verification strategy -3. `auto-claude/prompts/qa_reviewer.md` - Add test creation, smart discovery, service orchestration -4. `auto-claude/prompts/qa_fixer.md` - Add iteration awareness - -**New Configuration Files**: -1. `/Users/andremikalsen/Documents/Coding/autonomous-coding/auto-claude/config/validation_config.yaml`: - ```yaml - risk_classification: - weights: - files_changed: 0.20 - layers_affected: 0.25 - security_sensitivity: 0.30 - breaking_change: 0.15 - dependency: 0.10 - thresholds: - low: 25 - medium: 50 - high: 75 - - qa_loop: - max_iterations: 5 - recurrence_threshold: 3 - - security_scanning: - enabled_tools: - python: ["bandit", "safety", "semgrep"] - javascript: ["npm audit", "semgrep"] - typescript: ["npm audit", "semgrep"] - - test_creation: - create_if_missing: true - min_coverage: 80 # percentage - ``` - ---- - -## Implementation Plan - -### Timeline Overview - -| Phase | Duration | Focus | -|-------|----------|-------| -| Phase 1 | 1-2 weeks | Complexity Assessor Enhancement (validation_recommendations) | -| Phase 2 | 1-2 weeks | Verification Strategy Builder | -| Phase 3 | 1-2 weeks | Smart Test Discovery + CI Integration | -| Phase 4 | 2-3 weeks | QA Test Creation | -| Phase 5 | 1-2 weeks | Security Scanning (integrate scan_secrets.py) | -| Phase 6 | 1-2 weeks | Multi-Service Orchestration | -| Phase 7 | 1-2 weeks | QA Loop Enhancements + No-Test Projects | -| **Total** | **8-13 weeks** | | - ---- - -### Phase 1: Complexity Assessor Enhancement (Weeks 1-2) - -**Objectives:** -- Extend existing AI complexity assessor with validation recommendations -- Create risk_classifier.py to read AI output programmatically -- Enable skip-validation for trivial tasks - -**Tasks:** -1. Enhance `prompts/complexity_assessor.md` with PHASE 3.5: VALIDATION RECOMMENDATIONS -2. Create `risk_classifier.py` module (reads complexity_assessment.json) -3. Implement `ValidationRecommendations` dataclass -4. Implement `RiskClassifier.load_assessment()` -5. Implement `should_skip_validation()` for trivial task detection -6. Unit tests for risk classifier -7. Integration test: Complexity Assessor → risk_classifier.py - -**Deliverables:** -- Enhanced `prompts/complexity_assessor.md` -- `auto-claude/risk_classifier.py` -- `tests/test_risk_classifier.py` - -**Acceptance Criteria:** -- [ ] AI complexity assessor outputs `validation_recommendations` field -- [ ] AI correctly identifies trivial tasks (docs, typos) for skip-validation -- [ ] risk_classifier.py correctly parses complexity_assessment.json -- [ ] Planner can read validation recommendations - ---- - -### Phase 2: Verification Strategy (Weeks 3-4) - -**Objectives:** -- Enhance planner to define verification strategies -- Map risk levels to verification requirements -- Store verification strategy in implementation plan - -**Tasks:** -1. Enhance `prompts/planner.md` with verification strategy section -2. Create `validation_strategy.py` module -3. Implement `ValidationStrategyBuilder.build_strategy()` -4. Implement project type adapters: - - `_strategy_for_html_css()` - - `_strategy_for_spa()` - - `_strategy_for_fullstack()` - - `_strategy_for_python_api()` - - `_strategy_for_cli()` -5. Enhance `implementation_plan.json` schema with `verification_strategy` section -6. Unit tests for validation strategy builder -7. Integration test: Planner → Risk → Verification Strategy - -**Deliverables:** -- Enhanced `prompts/planner.md` -- `auto-claude/validation_strategy.py` -- `tests/test_validation_strategy.py` -- Updated `implementation_plan.json` schema - -**Acceptance Criteria:** -- [ ] Planner generates verification strategy for all risk levels -- [ ] Strategy adapts to project type -- [ ] Verification strategy stored in implementation plan - ---- - -### Phase 3: Smart Test Discovery + CI Integration (Weeks 5-6) - -**Objectives:** -- Detect test frameworks in projects -- Map frameworks to executable commands -- Detect and reuse existing CI configuration -- Support incremental test execution - -**Tasks:** -1. Extend `project_analyzer.py` to detect test frameworks -2. Create `test_discovery.py` module -3. Implement framework detectors: - - `_discover_js_frameworks()` - - `_discover_python_frameworks()` - - `_discover_rust_frameworks()` - - `_discover_go_frameworks()` -4. Implement package manager detection (npm/yarn/pnpm/bun) -5. **Create `ci_discovery.py` module** -6. **Implement CI config parsers:** - - `_parse_github_actions()` - - `_parse_gitlab_ci()` - - `_parse_jenkinsfile()` -7. **Implement incremental test detection (changed files → affected tests)** -8. Implement test command mapper (prioritize CI commands if found) -9. Create `test_discovery.json` and `ci_discovery.json` schemas -10. Unit tests for test discovery and CI discovery -11. Integration test: QA Agent → Test Discovery → CI Integration - -**Deliverables:** -- Enhanced `auto-claude/project_analyzer.py` -- `auto-claude/test_discovery.py` -- `auto-claude/ci_discovery.py` **NEW** -- `tests/test_discovery.py` -- `tests/test_ci_discovery.py` **NEW** - -**Acceptance Criteria:** -- [ ] Detects Jest, Vitest, pytest, Playwright, Cypress, Go test, Cargo test -- [ ] Detects GitHub Actions, GitLab CI, Jenkins, CircleCI configs -- [ ] Extracts test commands from CI configuration -- [ ] Supports incremental test execution (--changedSince, --changed) -- [ ] Correctly maps frameworks to commands -- [ ] Handles multiple test frameworks - ---- - -### Phase 4: QA Test Creation (Weeks 6-8) - -**Objectives:** -- QA Agent creates tests after implementation complete -- Tests follow project patterns -- Tests are organized by type - -**Tasks:** -1. Create `test_creator.py` module -2. Implement `TestCreator.create_tests()` -3. Implement test generators: - - `_generate_unit_tests()` - - `_generate_integration_tests()` - - `_generate_e2e_tests()` - - `_generate_security_tests()` -4. Implement test pattern analyzer (reads existing tests) -5. Enhance `prompts/qa_reviewer.md` with test creation section -6. Implement test file organizer (unit/, integration/, e2e/) -7. Unit tests for test creator -8. Integration test: QA Agent → Test Creation → Test Execution - -**Deliverables:** -- `auto-claude/test_creator.py` -- Enhanced `prompts/qa_reviewer.md` -- `tests/test_creator.py` -- Example test templates documented - -**Acceptance Criteria:** -- [ ] QA Agent creates tests after all chunks complete -- [ ] Tests follow project conventions -- [ ] Tests are organized by type -- [ ] Tests execute successfully - ---- - -### Phase 5: Security Scanning (Weeks 9-10) - -**Objectives:** -- Create security_scanner.py that **integrates existing scan_secrets.py** -- Add SAST tools (bandit, npm audit, semgrep) -- Implement staging deployment (optional) -- Add manual review checkpoints - -**Tasks:** -1. Create `security_scanner.py` module -2. **Integrate existing `scan_secrets.py`:** - - Import `scan_files`, `get_all_tracked_files`, `SecretMatch` - - Call as primary secrets detection -3. Implement additional security tool runners: - - `_run_bandit()` (Python SAST) - - `_run_npm_audit()` (JavaScript dependencies) - - `_run_semgrep()` (Multi-language SAST, optional) - - `_run_safety()` (Python dependencies) -4. Implement vulnerability parser (consolidate all results) -5. Implement blocking logic: - - **Secrets detected → ALWAYS block** - - CRITICAL vulnerabilities → block - - HIGH vulnerabilities → warn -6. Enhance `security.py` to allow security tool commands -7. Create `security_scan_results.json` schema -8. Implement staging deployment (optional, project-specific) -9. Unit tests for security scanner -10. Integration test: High-Risk Task → Security Scan → QA - -**Deliverables:** -- `auto-claude/security_scanner.py` (integrates scan_secrets.py) -- Enhanced `auto-claude/security.py` -- `tests/test_security_scanner.py` -- `security_scan_results.json` schema documented - -**Acceptance Criteria:** -- [ ] Secrets scan runs using existing scan_secrets.py -- [ ] ANY detected secrets block QA approval -- [ ] SAST scans run based on detected project type -- [ ] Security results consolidated in single JSON -- [ ] Manual review checkpoint added for critical tasks - ---- - -### Phase 6: Multi-Service Orchestration (Weeks 11-12) - -**Objectives:** -- Detect and orchestrate multi-service/monorepo projects -- Start services via docker-compose before tests -- Add API contract validation - -**Tasks:** -1. Create `service_orchestrator.py` module -2. Implement multi-service detection: - - docker-compose.yml detection - - Monorepo tool detection (nx, turbo, lerna, pnpm-workspace) -3. Implement service orchestration: - - `start_services()` - docker-compose up -d - - `_wait_for_health()` - Health check polling - - `stop_services()` - docker-compose down -4. Implement API contract validation: - - OpenAPI schema diff detection - - GraphQL schema comparison -5. Integrate with `qa_loop.py` (start services before integration/e2e tests) -6. Create `service_orchestration.json` schema -7. Unit tests for service orchestrator -8. Integration test: Multi-service project → Orchestration → Tests - -**Deliverables:** -- `auto-claude/service_orchestrator.py` -- `tests/test_service_orchestrator.py` -- `service_orchestration.json` schema documented - -**Acceptance Criteria:** -- [ ] Detect docker-compose and monorepo configurations -- [ ] Start services before integration/e2e tests -- [ ] Wait for health checks before proceeding -- [ ] Tear down services after tests complete -- [ ] API contract validation for breaking changes - ---- - -### Phase 7: QA Loop Enhancements & No-Test Projects (Weeks 13-14) - -**Objectives:** -- Implement iteration tracking -- Prevent infinite loops -- Handle no-test projects - -**Tasks:** -1. Enhance `qa_loop.py` with iteration tracking -2. Implement recurring issue detection -3. Implement human escalation -4. Implement no-test project detection -5. Implement minimal test infrastructure creation -6. Implement fallback verification (browser, API) -7. Implement manual test plan generation -8. Enhanced `prompts/qa_reviewer.md` with no-test handling -9. Unit tests for QA loop enhancements -10. Integration test: Full QA Loop with iterations - -**Deliverables:** -- Enhanced `auto-claude/qa_loop.py` -- Enhanced `prompts/qa_reviewer.md` -- `tests/test_qa_loop.py` -- Documentation for no-test projects - -**Acceptance Criteria:** -- [ ] QA loop tracks iterations -- [ ] Infinite loops prevented -- [ ] No-test projects handled gracefully -- [ ] Manual test plans created when needed - ---- - -## Testing & Validation - -### Unit Tests - -**Test Coverage Targets:** -- `risk_classifier.py`: 90%+ -- `test_discovery.py`: 85%+ -- `ci_discovery.py`: 85%+ -- `security_scanner.py`: 80%+ -- `validation_strategy.py`: 85%+ -- `test_creator.py`: 80%+ -- `service_orchestrator.py`: 80%+ - -**Test Files:** -``` -tests/ -├── test_risk_classifier.py -├── test_ci_discovery.py -├── test_service_orchestrator.py -├── test_discovery.py -├── test_security_scanner.py -├── test_validation_strategy.py -├── test_creator.py -└── test_qa_loop_enhancements.py -``` - -### Integration Tests - -**Test Scenarios:** -1. **End-to-End: Low-Risk Feature** - - Planner → Risk (LOW) → Verification Strategy (unit tests only) → QA → Approval -2. **End-to-End: High-Risk Feature** - - Planner → Risk (HIGH) → Verification Strategy (unit+integration+e2e+security) → QA → Security Scan → Approval -3. **QA Loop: Multiple Iterations** - - QA finds issues → Fixer fixes → QA re-validates → Approval -4. **No-Test Project** - - QA detects no tests → Creates minimal infrastructure → Runs tests → Approval - -**Test Projects:** -Create sample projects for testing: -- `tests/fixtures/simple_html/` - HTML/CSS project -- `tests/fixtures/react_spa/` - React SPA -- `tests/fixtures/python_api/` - FastAPI project -- `tests/fixtures/no_tests/` - Project with no test infrastructure - -### Manual Testing Checklist - -**Before Release:** -- [ ] Test risk classification on 10+ real-world specs -- [ ] Verify test discovery on 5+ different project types -- [ ] Run security scanner on projects with known vulnerabilities -- [ ] Test QA loop with intentionally broken code -- [ ] Test no-test project handling on legacy code -- [ ] Test staging deployment (if available) -- [ ] Verify manual review checkpoints work - ---- - -## Success Criteria - -### Quantitative Metrics - -1. **Test Coverage**: 90%+ of code changes have automated test coverage -2. **QA Loop Efficiency**: Average <3 iterations to QA approval -3. **Risk Detection Accuracy**: 95%+ accuracy in high-risk classification -4. **Zero Critical Escapes**: No critical security issues in production -5. **Autonomous Test Creation**: 80%+ of test infrastructure created autonomously - -### Qualitative Metrics - -1. **Developer Satisfaction**: Developers trust the validation system -2. **Reduced Manual QA**: 70%+ reduction in manual QA time -3. **Security Posture**: High-risk changes always scanned -4. **Documentation Quality**: Clear test plans for all projects - -### Acceptance Criteria (Overall) - -- [ ] All phases completed and tested -- [ ] Integration tests pass on all project types -- [ ] Security scanning works on high-risk tasks -- [ ] QA loop prevents infinite iterations -- [ ] No-test projects handled gracefully -- [ ] Documentation complete (README, config, schemas) -- [ ] Performance: Risk classification <5s, Test creation <30s - ---- - -## Appendix - -### A. Glossary - -- **Risk Level**: Classification of task danger (low/medium/high/critical) -- **Verification Strategy**: Plan for how to validate implementation -- **Test Discovery**: Automatic detection of test frameworks -- **QA Loop**: Iterative fix-validate cycle -- **High-Risk Validation**: Additional security/staging checks - -### B. Configuration Reference - -**`auto-claude/config/validation_config.yaml`**: -See "New Configuration Files" section above. - -### C. File Naming Conventions - -- **Complexity Assessment**: `complexity_assessment.json` (includes validation_recommendations) -- **Test Discovery**: `test_discovery.json` -- **CI Discovery**: `ci_discovery.json` -- **Service Orchestration**: `service_orchestration.json` -- **Security Results**: `security_scan_results.json` -- **QA Report**: `qa_report.md` -- **Fix Request**: `QA_FIX_REQUEST.md` -- **Test Plan**: `MANUAL_TEST_PLAN.md` -- **Testing Approach**: `QA_TESTING_APPROACH.md` - -### D. Example Workflows - -**Example 1: Simple HTML/CSS Change (Low Risk)** -``` -User: "Change button color to blue" -↓ -Planner: Risk=LOW, Strategy=visual+lighthouse -↓ -Coder: Changes CSS -↓ -QA: Lighthouse audit, screenshot comparison -↓ -Approved -``` - -**Example 2: Auth System Change (High Risk)** -``` -User: "Add OAuth2 authentication" -↓ -Planner: Risk=HIGH, Strategy=unit+integration+e2e+security -↓ -Coder: Implements OAuth2 -↓ -QA: - - Discovers pytest - - Creates auth tests - - Runs security scan (bandit, semgrep) - - Finds 1 issue (hardcoded secret) -↓ -Fixer: Moves secret to .env -↓ -QA: Re-validates, all pass -↓ -Approved -``` - ---- - -## Document Revision History - -| Version | Date | Author | Changes | -|---------|------|--------|---------| -| 1.0 | 2025-12-13 | Atlas | Initial PRD creation | -| 1.1 | 2025-12-14 | Claude | **Major revision based on gap analysis:** | -| | | | - Section 1: Refactored to integrate with existing AI complexity_assessor.md (not rigid rules) | -| | | | - Section 6: Updated to integrate existing scan_secrets.py for secrets detection | -| | | | - Section 9: Added Multi-Service Testing (docker-compose orchestration, monorepo support) | -| | | | - Section 10: Added CI/CD Integration (GitHub Actions, GitLab CI, incremental testing) | -| | | | - Implementation Plan: Added Phase 6 (Multi-Service) and Phase 7, revised timeline to 8-13 weeks | -| | | | - New modules: service_orchestrator.py, ci_discovery.py | -| | | | - Leverages existing: scan_secrets.py, complexity_assessor.md | - ---- - -## Sign-Off - -**Architect**: Atlas (Principal Software Architect) -**Date**: 2025-12-13 - -**Reviewer**: Claude (Gap Analysis & Revision) -**Date**: 2025-12-14 - -**Status**: Ready for Implementation - -This PRD defines a comprehensive, production-ready Intelligent Validation System for Auto Claude. All requirements are implementable, testable, and align with the existing codebase architecture. Key design principles: - -1. **AI-driven flexibility** - Uses existing complexity_assessor.md for intelligent risk classification instead of rigid rules -2. **Leverages existing code** - Integrates scan_secrets.py for secrets detection instead of adding new tools -3. **Developer experience** - Supports skip-validation for trivial tasks, incremental testing for fast feedback -4. **Enterprise-ready** - Multi-service orchestration, CI/CD integration, security scanning diff --git a/RECOVERY_AND_RESUMPTION.md b/RECOVERY_AND_RESUMPTION.md deleted file mode 100644 index 473691c2..00000000 --- a/RECOVERY_AND_RESUMPTION.md +++ /dev/null @@ -1,268 +0,0 @@ -# Auto-Claude Recovery and Resumption System - -## Overview - -This document explains how Auto-Claude detects completed planning phases, identifies existing worktrees, and resumes execution after crashes or user interruptions. - -## Key State Detection Mechanisms - -### 1. Planning Phase Completion Detection - -**Primary Indicator: `implementation_plan.json`** - -The system determines if planning is complete by checking for the existence and validity of `implementation_plan.json`: - -- **Location**: `.auto-claude/specs/{spec-name}/implementation_plan.json` -- **Detection Logic**: - - `prompts.py::is_first_run()` checks if `implementation_plan.json` exists - - `coordinator.py::load_implementation_plan()` raises `FileNotFoundError` if missing - - `agent.py::load_implementation_plan()` returns `None` if file doesn't exist - -**Code References:** -```python -# auto-claude/prompts.py:181-192 -def is_first_run(spec_dir: Path) -> bool: - plan_file = spec_dir / "implementation_plan.json" - return not plan_file.exists() - -# auto-claude/coordinator.py:175-186 -def load_implementation_plan(self) -> ImplementationPlan: - plan_file = self.spec_dir / "implementation_plan.json" - if not plan_file.exists(): - raise FileNotFoundError("Implementation plan not found") - # ... -``` - -**Planning Phase Flow:** -1. `spec_runner.py` runs phases: discovery → requirements → complexity → context → spec → planning -2. `phase_planning()` creates `implementation_plan.json` via `planner.py` or planner agent -3. Once `implementation_plan.json` exists and is valid, planning is considered complete - -### 2. Worktree Detection - -**Staging Worktree Detection** - -The system checks for existing worktrees using: - -- **Location**: `.worktrees/auto-claude/` (staging worktree) -- **Detection Functions**: - - `workspace.py::get_existing_build_worktree()` - checks if staging worktree exists - - `worktree.py::get_or_create_staging()` - gets existing or creates new staging worktree - - `worktree.py::staging_exists()` - boolean check for staging worktree - -**Code References:** -```python -# auto-claude/workspace.py:88-93 -def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Optional[Path]: - worktree_path = project_dir / ".worktrees" / STAGING_WORKTREE_NAME - if worktree_path.exists(): - return worktree_path - return None - -# auto-claude/worktree.py:173-206 -def get_or_create_staging(self, spec_name: str) -> WorktreeInfo: - staging_path = self.worktrees_dir / STAGING_WORKTREE_NAME - branch_name = f"auto-claude/{spec_name}" - - if staging_path.exists(): - # Load existing worktree info - # ... - return info - # Create new staging worktree - return self.create(STAGING_WORKTREE_NAME, branch_name) -``` - -**Worktree Persistence:** -- Worktrees persist across crashes and app restarts -- Git worktrees are stored in `.worktrees/` directory -- Each worktree has its own branch: `auto-claude/{spec-name}` -- Staging worktree is preserved until explicitly merged or discarded - -### 3. Resumption Logic - -**Entry Point: `run.py::main()`** - -When `run.py` starts, it follows this resumption flow: - -1. **Find Spec**: Locates spec directory via `find_spec()` -2. **Check Existing Build**: Calls `get_existing_build_worktree()` to detect staging worktree -3. **User Choice** (if not `--auto-continue`): - - Continue where it left off - - Review what was built - - Merge existing build - - Start fresh (discard) -4. **Workspace Setup**: - - If existing worktree found → uses it - - If not → creates new staging worktree -5. **Execution**: - - **Sequential Mode**: `run_autonomous_agent()` checks for `implementation_plan.json` - - **Parallel Mode**: `SwarmCoordinator` checks for plan, runs planner if missing - -**Code Flow:** -```python -# auto-claude/run.py:618-631 -if get_existing_build_worktree(project_dir, spec_dir.name): - if args.auto_continue: - print("Auto-continue: Resuming existing build...") - else: - continue_existing = check_existing_build(project_dir, spec_dir.name) - if continue_existing: - # Continue with existing worktree - pass - else: - # User chose to start fresh or merged existing - pass -``` - -### 4. Planner vs Coder Detection - -**Sequential Mode (`agent.py`):** - -```python -# auto-claude/agent.py:547-691 -async def run_autonomous_agent(...): - # ... - first_run = is_first_run(spec_dir) # Checks for implementation_plan.json - - if first_run: - # Run planner session - prompt = generate_planner_prompt(spec_dir) - else: - # Run coder session - next_chunk = get_next_chunk(spec_dir) - prompt = generate_coder_prompt(spec_dir, next_chunk) -``` - -**Parallel Mode (`coordinator.py`):** - -```python -# auto-claude/coordinator.py:304-350 -async def run_planner_session(self) -> bool: - """Run planner if implementation_plan.json doesn't exist""" - plan_file = self.spec_dir / "implementation_plan.json" - if not plan_file.exists(): - # Run planner agent session - # ... -``` - -### 5. Chunk Progress Tracking - -**State Storage: `implementation_plan.json`** - -Chunk statuses are stored in the plan file: -- `pending` - Not started -- `in_progress` - Currently being worked on -- `completed` - Finished successfully -- `blocked` - Stuck, needs attention -- `failed` - Failed after attempts - -**Resumption Logic:** -```python -# auto-claude/progress.py:399-452 -def get_next_chunk(spec_dir: Path) -> dict | None: - plan_file = spec_dir / "implementation_plan.json" - # ... - # Find first pending chunk in available phase - for chunk in phase.get("chunks", []): - if chunk.get("status") == "pending": - return chunk -``` - -**Recovery Handling:** -- `in_progress` chunks are reset to `pending` on recovery (see `ipc-handlers.ts:926`) -- Completed chunks remain `completed` to preserve progress -- Failed chunks can be reset to `pending` for retry - -## Recovery Scenarios - -### Scenario 1: Planning Complete, No Worktree - -**State:** -- ✅ `implementation_plan.json` exists -- ❌ No staging worktree exists - -**Behavior:** -- System detects plan exists → skips planner -- Creates new staging worktree -- Starts coding phase with first pending chunk - -### Scenario 2: Planning Complete, Worktree Exists - -**State:** -- ✅ `implementation_plan.json` exists -- ✅ Staging worktree exists at `.worktrees/auto-claude/` - -**Behavior:** -- System detects both exist -- Prompts user (or auto-continues) to resume -- Uses existing worktree -- Resumes from last incomplete chunk - -### Scenario 3: Planning Incomplete - -**State:** -- ❌ `implementation_plan.json` missing or invalid -- ❌ No staging worktree (or exists but no plan) - -**Behavior:** -- System detects missing plan -- Runs planner session first -- Creates `implementation_plan.json` -- Then proceeds to coding phase - -### Scenario 4: Crash During Coding - -**State:** -- ✅ `implementation_plan.json` exists -- ✅ Staging worktree exists -- ⚠️ Some chunks `in_progress` or `failed` - -**Behavior:** -- On restart, `in_progress` chunks are reset to `pending` -- System resumes from first `pending` chunk -- Worktree preserves all committed work -- Uncommitted changes may be lost (depends on git state) - -## Frontend Integration - -**UI State Detection (`auto-claude-ui`):** - -The frontend tracks state via: -1. **File Watcher**: Monitors `implementation_plan.json` for changes -2. **Task Store**: Reads plan file to determine task status -3. **Agent Manager**: Parses logs to detect phase transitions - -**Recovery Actions:** -- `ipc-handlers.ts::recoverTask()` - Resets stuck tasks -- Resets `in_progress` → `pending` for interrupted chunks -- Preserves `completed` chunks - -## Key Files for State - -**State Files:** -- `.auto-claude/specs/{spec}/implementation_plan.json` - Main state file -- `.auto-claude/specs/{spec}/memory/attempt_history.json` - Recovery history -- `.auto-claude/specs/{spec}/memory/build_commits.json` - Git commit tracking -- `.worktrees/auto-claude/` - Staging worktree (git-managed) - -**Detection Functions:** -- `is_first_run()` - Checks if planning needed -- `get_existing_build_worktree()` - Checks for worktree -- `get_next_chunk()` - Finds next work item -- `load_implementation_plan()` - Loads plan state - -## Summary - -The system uses a **file-based state detection** approach: - -1. **Planning Complete?** → Check for `implementation_plan.json` -2. **Worktree Exists?** → Check for `.worktrees/auto-claude/` -3. **What's Next?** → Read chunk statuses from `implementation_plan.json` -4. **Resume Point** → First `pending` chunk in available phase - -This design ensures: -- ✅ No duplicate planning if plan already exists -- ✅ Work preservation via git worktrees -- ✅ Progress tracking via chunk statuses -- ✅ Safe resumption after crashes -- ✅ User control over continuation vs fresh start diff --git a/auto-claude-ui/.gitignore b/auto-claude-ui/.gitignore index f7f0625a..52160aaa 100644 --- a/auto-claude-ui/.gitignore +++ b/auto-claude-ui/.gitignore @@ -6,6 +6,10 @@ out/ dist/ build/ +# Compiled TypeScript (source files are .ts) +src/**/*.js +src/**/*.js.map + # electron-vite .vite/ diff --git a/auto-claude-ui/src/__tests__/integration/subprocess-spawn.test.ts b/auto-claude-ui/src/__tests__/integration/subprocess-spawn.test.ts index 924e9a8c..06872e38 100644 --- a/auto-claude-ui/src/__tests__/integration/subprocess-spawn.test.ts +++ b/auto-claude-ui/src/__tests__/integration/subprocess-spawn.test.ts @@ -72,7 +72,7 @@ describe('Subprocess Spawn Integration', () => { describe('AgentManager', () => { it('should spawn Python process for spec creation', async () => { const { spawn } = await import('child_process'); - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description'); @@ -95,7 +95,7 @@ describe('Subprocess Spawn Integration', () => { it('should spawn Python process for task execution', async () => { const { spawn } = await import('child_process'); - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001'); @@ -111,7 +111,7 @@ describe('Subprocess Spawn Integration', () => { it('should spawn Python process for QA process', async () => { const { spawn } = await import('child_process'); - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001'); @@ -132,7 +132,7 @@ describe('Subprocess Spawn Integration', () => { it('should include parallel options when specified', async () => { const { spawn } = await import('child_process'); - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', { @@ -148,7 +148,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should emit log events from stdout', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); const logHandler = vi.fn(); @@ -163,7 +163,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should emit log events from stderr', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); const logHandler = vi.fn(); @@ -178,7 +178,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should emit exit event when process exits', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); const exitHandler = vi.fn(); @@ -193,7 +193,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should emit error event when process errors', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); const errorHandler = vi.fn(); @@ -208,7 +208,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should kill task and remove from tracking', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test'); @@ -223,7 +223,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should return false when killing non-existent task', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); const result = manager.killTask('nonexistent'); @@ -232,7 +232,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should track running tasks', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); expect(manager.getRunningTasks()).toHaveLength(0); @@ -246,7 +246,7 @@ describe('Subprocess Spawn Integration', () => { it('should use configured Python path', async () => { const { spawn } = await import('child_process'); - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.configure('/custom/python3'); @@ -261,7 +261,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should kill all running tasks', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1'); @@ -273,7 +273,7 @@ describe('Subprocess Spawn Integration', () => { }); it('should kill existing process when starting new one for same task', async () => { - const { AgentManager } = await import('../../main/agent-manager'); + const { AgentManager } = await import('../../main/agent'); const manager = new AgentManager(); manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1'); diff --git a/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts b/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts index fd3ba925..faa84868 100644 --- a/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts +++ b/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts @@ -91,6 +91,11 @@ describe('IPC Handlers', () => { invokeClaude: ReturnType; killAll: ReturnType; }; + let mockPythonEnvManager: { + on: ReturnType; + initialize: ReturnType; + getStatus: ReturnType; + }; beforeEach(async () => { cleanupTestDirs(); @@ -125,6 +130,12 @@ describe('IPC Handlers', () => { killAll: vi.fn(() => Promise.resolve()) }; + mockPythonEnvManager = { + on: vi.fn(), + initialize: vi.fn(() => Promise.resolve({ ready: true, pythonPath: '/usr/bin/python3', venvExists: true, depsInstalled: true })), + getStatus: vi.fn(() => Promise.resolve({ ready: true, pythonPath: '/usr/bin/python3', venvExists: true, depsInstalled: true })) + }; + // Need to reset modules to re-register handlers vi.resetModules(); }); @@ -137,7 +148,7 @@ describe('IPC Handlers', () => { describe('project:add handler', () => { it('should return error for non-existent path', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('project:add', {}, '/nonexistent/path'); @@ -149,7 +160,7 @@ describe('IPC Handlers', () => { it('should successfully add an existing project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -162,7 +173,7 @@ describe('IPC Handlers', () => { it('should return existing project if already added', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add project twice const result1 = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -177,7 +188,7 @@ describe('IPC Handlers', () => { describe('project:list handler', () => { it('should return empty array when no projects', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('project:list', {}); @@ -189,7 +200,7 @@ describe('IPC Handlers', () => { it('should return all added projects', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -205,7 +216,7 @@ describe('IPC Handlers', () => { describe('project:remove handler', () => { it('should return false for non-existent project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('project:remove', {}, 'nonexistent-id'); @@ -214,7 +225,7 @@ describe('IPC Handlers', () => { it('should successfully remove an existing project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project first const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -235,7 +246,7 @@ describe('IPC Handlers', () => { describe('project:updateSettings handler', () => { it('should return error for non-existent project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler( 'project:updateSettings', @@ -252,7 +263,7 @@ describe('IPC Handlers', () => { it('should successfully update project settings', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project first const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -273,7 +284,7 @@ describe('IPC Handlers', () => { describe('task:list handler', () => { it('should return empty array for project with no specs', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project first const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -289,7 +300,7 @@ describe('IPC Handlers', () => { it('should return tasks when specs exist', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project first const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -325,7 +336,7 @@ describe('IPC Handlers', () => { describe('task:create handler', () => { it('should return error for non-existent project', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler( 'task:create', @@ -343,7 +354,7 @@ describe('IPC Handlers', () => { it('should create task and start spec creation', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); // Add a project first const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); @@ -365,7 +376,7 @@ describe('IPC Handlers', () => { describe('settings:get handler', () => { it('should return default settings when no settings file exists', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('settings:get', {}); @@ -378,7 +389,7 @@ describe('IPC Handlers', () => { describe('settings:save handler', () => { it('should save settings successfully', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler( 'settings:save', @@ -397,7 +408,7 @@ describe('IPC Handlers', () => { it('should configure agent manager when paths change', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); await ipcMain.invokeHandler( 'settings:save', @@ -412,7 +423,7 @@ describe('IPC Handlers', () => { describe('app:version handler', () => { it('should return app version', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); const result = await ipcMain.invokeHandler('app:version', {}); @@ -423,7 +434,7 @@ describe('IPC Handlers', () => { describe('Agent Manager event forwarding', () => { it('should forward log events to renderer', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); mockAgentManager.emit('log', 'task-1', 'Test log message'); @@ -436,7 +447,7 @@ describe('IPC Handlers', () => { it('should forward error events to renderer', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); mockAgentManager.emit('error', 'task-1', 'Test error message'); @@ -449,7 +460,7 @@ describe('IPC Handlers', () => { it('should forward exit events with status change', async () => { const { setupIpcHandlers } = await import('../ipc-handlers'); - setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never, mockPythonEnvManager as never); mockAgentManager.emit('exit', 'task-1', 0); diff --git a/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts.bak b/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts.bak new file mode 100644 index 00000000..fd3ba925 --- /dev/null +++ b/auto-claude-ui/src/main/__tests__/ipc-handlers.test.ts.bak @@ -0,0 +1,463 @@ +/** + * Unit tests for IPC handlers + * Tests all IPC communication patterns between main and renderer processes + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; +import path from 'path'; + +// Test data directory +const TEST_DIR = '/tmp/ipc-handlers-test'; +const TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project'); + +// Mock modules before importing +vi.mock('electron', () => { + const mockIpcMain = new (class extends EventEmitter { + private handlers: Map = new Map(); + + handle(channel: string, handler: Function): void { + this.handlers.set(channel, handler); + } + + removeHandler(channel: string): void { + this.handlers.delete(channel); + } + + async invokeHandler(channel: string, event: unknown, ...args: unknown[]): Promise { + const handler = this.handlers.get(channel); + if (handler) { + return handler(event, ...args); + } + throw new Error(`No handler for channel: ${channel}`); + } + + getHandler(channel: string): Function | undefined { + return this.handlers.get(channel); + } + })(); + + return { + app: { + getPath: vi.fn((name: string) => { + if (name === 'userData') return path.join(TEST_DIR, 'userData'); + return TEST_DIR; + }), + getVersion: vi.fn(() => '0.1.0'), + isPackaged: false + }, + ipcMain: mockIpcMain, + dialog: { + showOpenDialog: vi.fn(() => Promise.resolve({ canceled: false, filePaths: [TEST_PROJECT_PATH] })) + }, + BrowserWindow: class { + webContents = { send: vi.fn() }; + } + }; +}); + +// Setup test project structure +function setupTestProject(): void { + mkdirSync(TEST_PROJECT_PATH, { recursive: true }); + mkdirSync(path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs'), { recursive: true }); +} + +// Cleanup test directories +function cleanupTestDirs(): void { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } +} + +describe('IPC Handlers', () => { + let ipcMain: EventEmitter & { + handlers: Map; + invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise; + getHandler: (channel: string) => Function | undefined; + }; + let mockMainWindow: { webContents: { send: ReturnType } }; + let mockAgentManager: EventEmitter & { + startSpecCreation: ReturnType; + startTaskExecution: ReturnType; + startQAProcess: ReturnType; + killTask: ReturnType; + configure: ReturnType; + }; + let mockTerminalManager: { + create: ReturnType; + destroy: ReturnType; + write: ReturnType; + resize: ReturnType; + invokeClaude: ReturnType; + killAll: ReturnType; + }; + + beforeEach(async () => { + cleanupTestDirs(); + setupTestProject(); + mkdirSync(path.join(TEST_DIR, 'userData', 'store'), { recursive: true }); + + // Get mocked ipcMain + const electron = await import('electron'); + ipcMain = electron.ipcMain as unknown as typeof ipcMain; + + // Create mock window + mockMainWindow = { + webContents: { send: vi.fn() } + }; + + // Create mock agent manager + mockAgentManager = Object.assign(new EventEmitter(), { + startSpecCreation: vi.fn(), + startTaskExecution: vi.fn(), + startQAProcess: vi.fn(), + killTask: vi.fn(), + configure: vi.fn() + }); + + // Create mock terminal manager + mockTerminalManager = { + create: vi.fn(() => Promise.resolve({ success: true })), + destroy: vi.fn(() => Promise.resolve({ success: true })), + write: vi.fn(), + resize: vi.fn(), + invokeClaude: vi.fn(), + killAll: vi.fn(() => Promise.resolve()) + }; + + // Need to reset modules to re-register handlers + vi.resetModules(); + }); + + afterEach(() => { + cleanupTestDirs(); + vi.clearAllMocks(); + }); + + describe('project:add handler', () => { + it('should return error for non-existent path', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('project:add', {}, '/nonexistent/path'); + + expect(result).toEqual({ + success: false, + error: 'Directory does not exist' + }); + }); + + it('should successfully add an existing project', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('data'); + const data = (result as { data: { path: string; name: string } }).data; + expect(data.path).toBe(TEST_PROJECT_PATH); + expect(data.name).toBe('test-project'); + }); + + it('should return existing project if already added', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add project twice + const result1 = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const result2 = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + + const data1 = (result1 as { data: { id: string } }).data; + const data2 = (result2 as { data: { id: string } }).data; + expect(data1.id).toBe(data2.id); + }); + }); + + describe('project:list handler', () => { + it('should return empty array when no projects', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('project:list', {}); + + expect(result).toEqual({ + success: true, + data: [] + }); + }); + + it('should return all added projects', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project + await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + + const result = await ipcMain.invokeHandler('project:list', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: unknown[] }).data; + expect(data).toHaveLength(1); + }); + }); + + describe('project:remove handler', () => { + it('should return false for non-existent project', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('project:remove', {}, 'nonexistent-id'); + + expect(result).toEqual({ success: false }); + }); + + it('should successfully remove an existing project', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project first + const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const projectId = (addResult as { data: { id: string } }).data.id; + + // Remove it + const removeResult = await ipcMain.invokeHandler('project:remove', {}, projectId); + + expect(removeResult).toEqual({ success: true }); + + // Verify it's gone + const listResult = await ipcMain.invokeHandler('project:list', {}); + const data = (listResult as { data: unknown[] }).data; + expect(data).toHaveLength(0); + }); + }); + + describe('project:updateSettings handler', () => { + it('should return error for non-existent project', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler( + 'project:updateSettings', + {}, + 'nonexistent-id', + { parallelEnabled: true } + ); + + expect(result).toEqual({ + success: false, + error: 'Project not found' + }); + }); + + it('should successfully update project settings', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project first + const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const projectId = (addResult as { data: { id: string } }).data.id; + + // Update settings + const result = await ipcMain.invokeHandler( + 'project:updateSettings', + {}, + projectId, + { parallelEnabled: true, maxWorkers: 4 } + ); + + expect(result).toEqual({ success: true }); + }); + }); + + describe('task:list handler', () => { + it('should return empty array for project with no specs', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project first + const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const projectId = (addResult as { data: { id: string } }).data.id; + + const result = await ipcMain.invokeHandler('task:list', {}, projectId); + + expect(result).toEqual({ + success: true, + data: [] + }); + }); + + it('should return tasks when specs exist', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project first + const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const projectId = (addResult as { data: { id: string } }).data.id; + + // Create a spec directory with implementation plan + const specDir = path.join(TEST_PROJECT_PATH, 'auto-claude', 'specs', '001-test-feature'); + mkdirSync(specDir, { recursive: true }); + writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify({ + feature: 'Test Feature', + workflow_type: 'feature', + services_involved: [], + phases: [{ + phase: 1, + name: 'Test Phase', + type: 'implementation', + subtasks: [{ id: 'subtask-1', description: 'Test subtask', status: 'pending' }] + }], + final_acceptance: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + spec_file: '' + })); + + const result = await ipcMain.invokeHandler('task:list', {}, projectId); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: unknown[] }).data; + expect(data).toHaveLength(1); + }); + }); + + describe('task:create handler', () => { + it('should return error for non-existent project', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler( + 'task:create', + {}, + 'nonexistent-id', + 'Test Task', + 'Test description' + ); + + expect(result).toEqual({ + success: false, + error: 'Project not found' + }); + }); + + it('should create task and start spec creation', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + // Add a project first + const addResult = await ipcMain.invokeHandler('project:add', {}, TEST_PROJECT_PATH); + const projectId = (addResult as { data: { id: string } }).data.id; + + const result = await ipcMain.invokeHandler( + 'task:create', + {}, + projectId, + 'Test Task', + 'Test description' + ); + + expect(result).toHaveProperty('success', true); + expect(mockAgentManager.startSpecCreation).toHaveBeenCalled(); + }); + }); + + describe('settings:get handler', () => { + it('should return default settings when no settings file exists', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('settings:get', {}); + + expect(result).toHaveProperty('success', true); + const data = (result as { data: { theme: string } }).data; + expect(data).toHaveProperty('theme', 'system'); + }); + }); + + describe('settings:save handler', () => { + it('should save settings successfully', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler( + 'settings:save', + {}, + { theme: 'dark', defaultModel: 'opus' } + ); + + expect(result).toEqual({ success: true }); + + // Verify settings were saved + const getResult = await ipcMain.invokeHandler('settings:get', {}); + const data = (getResult as { data: { theme: string; defaultModel: string } }).data; + expect(data.theme).toBe('dark'); + expect(data.defaultModel).toBe('opus'); + }); + + it('should configure agent manager when paths change', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + await ipcMain.invokeHandler( + 'settings:save', + {}, + { pythonPath: '/usr/bin/python3' } + ); + + expect(mockAgentManager.configure).toHaveBeenCalledWith('/usr/bin/python3', undefined); + }); + }); + + describe('app:version handler', () => { + it('should return app version', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + const result = await ipcMain.invokeHandler('app:version', {}); + + expect(result).toBe('0.1.0'); + }); + }); + + describe('Agent Manager event forwarding', () => { + it('should forward log events to renderer', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + mockAgentManager.emit('log', 'task-1', 'Test log message'); + + expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( + 'task:log', + 'task-1', + 'Test log message' + ); + }); + + it('should forward error events to renderer', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + mockAgentManager.emit('error', 'task-1', 'Test error message'); + + expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( + 'task:error', + 'task-1', + 'Test error message' + ); + }); + + it('should forward exit events with status change', async () => { + const { setupIpcHandlers } = await import('../ipc-handlers'); + setupIpcHandlers(mockAgentManager as never, mockTerminalManager as never, () => mockMainWindow as never); + + mockAgentManager.emit('exit', 'task-1', 0); + + expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( + 'task:statusChange', + 'task-1', + 'ai_review' + ); + }); + }); +}); diff --git a/auto-claude-ui/src/main/agent-manager.ts b/auto-claude-ui/src/main/agent-manager.ts index 0436a024..dd7c9f76 100644 --- a/auto-claude-ui/src/main/agent-manager.ts +++ b/auto-claude-ui/src/main/agent-manager.ts @@ -1,1101 +1,32 @@ -import { spawn, ChildProcess } from 'child_process'; -import { EventEmitter } from 'events'; -import path from 'path'; -import { existsSync, readFileSync } from 'fs'; -import { app } from 'electron'; -import { projectStore } from './project-store'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; - -interface AgentProcess { - taskId: string; - process: ChildProcess; - startedAt: Date; - projectPath?: string; // For ideation processes to load session on completion - spawnId: number; // Unique ID to identify this specific spawn -} - -export interface ExecutionProgressData { - phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed'; - phaseProgress: number; - overallProgress: number; - currentSubtask?: string; - message?: string; -} - -export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process'; - -export interface AgentManagerEvents { - log: (taskId: string, log: string) => void; - error: (taskId: string, error: string) => void; - exit: (taskId: string, code: number | null, processType: ProcessType) => void; - 'execution-progress': (taskId: string, progress: ExecutionProgressData) => void; -} - /** - * Manages Python subprocess spawning for auto-claude agents + * AgentManager - Slim re-export facade + * + * This file maintains backward compatibility for imports using the old path. + * The actual implementation has been refactored into modular components in ./agent/ + * + * For new code, prefer importing directly from './agent': + * import { AgentManager } from './agent' + * + * This facade ensures existing imports continue to work: + * import { AgentManager } from './agent-manager' */ -export class AgentManager extends EventEmitter { - private processes: Map = new Map(); - private killedSpawnIds: Set = new Set(); // Track spawn IDs whose processes were killed - private spawnCounter: number = 0; // Unique ID for each spawn - private pythonPath: string = 'python3'; - private autoBuildSourcePath: string = ''; // Source auto-claude repo location - constructor() { - super(); - } - - /** - * Configure paths for Python and auto-claude source - */ - configure(pythonPath?: string, autoBuildSourcePath?: string): void { - if (pythonPath) { - this.pythonPath = pythonPath; - } - if (autoBuildSourcePath) { - this.autoBuildSourcePath = autoBuildSourcePath; - } - } - - /** - * Get the auto-claude source path (detects automatically if not configured) - */ - private getAutoBuildSourcePath(): string | null { - // If manually configured, use that - if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { - return this.autoBuildSourcePath; - } - - // Auto-detect from app location - const possiblePaths = [ - // Dev mode: from dist/main -> ../../auto-claude (sibling to auto-claude-ui) - path.resolve(__dirname, '..', '..', '..', 'auto-claude'), - // Alternative: from app root - path.resolve(app.getAppPath(), '..', 'auto-claude'), - // If running from repo root - path.resolve(process.cwd(), 'auto-claude') - ]; - - for (const p of possiblePaths) { - if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { - return p; - } - } - return null; - } - - /** - * Get project-specific environment variables based on project settings - */ - private getProjectEnvVars(projectPath: string): Record { - const env: Record = {}; - - // Find project by path - const projects = projectStore.getProjects(); - const project = projects.find((p) => p.path === projectPath); - - if (project?.settings) { - // Graphiti MCP integration - if (project.settings.graphitiMcpEnabled) { - const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/'; - env['GRAPHITI_MCP_URL'] = graphitiUrl; - } - } - - return env; - } - - /** - * Load environment variables from auto-claude .env file - */ - private loadAutoBuildEnv(): Record { - const autoBuildSource = this.getAutoBuildSourcePath(); - if (!autoBuildSource) { - console.log('[loadAutoBuildEnv] No auto-build source path found'); - return {}; - } - - const envPath = path.join(autoBuildSource, '.env'); - console.log('[loadAutoBuildEnv] Looking for .env at:', envPath); - if (!existsSync(envPath)) { - console.log('[loadAutoBuildEnv] .env file does not exist'); - return {}; - } - - try { - const envContent = readFileSync(envPath, 'utf-8'); - const envVars: Record = {}; - - // Handle both Unix (\n) and Windows (\r\n) line endings - for (const line of envContent.split(/\r?\n/)) { - const trimmed = line.trim(); - // Skip comments and empty lines - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.substring(0, eqIndex).trim(); - let value = trimmed.substring(eqIndex + 1).trim(); - - // Remove quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - - envVars[key] = value; - } - } - - return envVars; - } catch { - return {}; - } - } - - /** - * Start spec creation process - */ - startSpecCreation( - taskId: string, - projectPath: string, - taskDescription: string, - specDir?: string, // Optional spec directory (when task already has a directory created by UI) - metadata?: { requireReviewBeforeCoding?: boolean } // Task metadata to check for review requirement - ): void { - // Use source auto-claude path (the repo), not the project's auto-claude - const autoBuildSource = this.getAutoBuildSourcePath(); - - if (!autoBuildSource) { - this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); - return; - } - - const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py'); - - if (!existsSync(specRunnerPath)) { - this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`); - return; - } - - // Load environment variables from auto-claude .env file and project settings - const autoBuildEnv = this.loadAutoBuildEnv(); - const projectEnv = this.getProjectEnvVars(projectPath); - const combinedEnv = { ...autoBuildEnv, ...projectEnv }; - - // spec_runner.py will auto-start run.py after spec creation completes - const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath]; - - // Pass spec directory if provided (for UI-created tasks that already have a directory) - if (specDir) { - args.push('--spec-dir', specDir); - } - - // Check if user requires review before coding - // If requireReviewBeforeCoding is true, skip auto-approve to trigger review checkpoint - if (!metadata?.requireReviewBeforeCoding) { - // Auto-approve: When user starts a task from the UI without requiring review, that IS their approval - // No need for interactive review checkpoint - user explicitly clicked "Start" - args.push('--auto-approve'); - } - // If requireReviewBeforeCoding is true, don't add --auto-approve, allowing the review checkpoint to appear - - // Note: This is spec-creation but it chains to task-execution via run.py - // So we treat the whole thing as task-execution for status purposes - this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); - } - - /** - * Start task execution (run.py) - */ - startTaskExecution( - taskId: string, - projectPath: string, - specId: string, - options: { parallel?: boolean; workers?: number } = {} - ): void { - console.log('[AgentManager] startTaskExecution called for:', taskId, specId); - // Use source auto-claude path (the repo), not the project's auto-claude - const autoBuildSource = this.getAutoBuildSourcePath(); - - if (!autoBuildSource) { - console.log('[AgentManager] ERROR: Auto-build source path not found'); - this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); - return; - } - - const runPath = path.join(autoBuildSource, 'run.py'); - console.log('[AgentManager] runPath:', runPath); - - if (!existsSync(runPath)) { - console.log('[AgentManager] ERROR: Run script not found at:', runPath); - this.emit('error', taskId, `Run script not found at: ${runPath}`); - return; - } - - // Load environment variables from auto-claude .env file and project settings - const autoBuildEnv = this.loadAutoBuildEnv(); - const projectEnv = this.getProjectEnvVars(projectPath); - const combinedEnv = { ...autoBuildEnv, ...projectEnv }; - - const args = [runPath, '--spec', specId, '--project-dir', projectPath]; - - // Always use auto-continue when running from UI (non-interactive) - args.push('--auto-continue'); - - // Force: When user starts a task from the UI, that IS their approval - // The review checkpoint is for CLI users who need to review before building - // UI users have already seen the spec in the interface before clicking "Start" - args.push('--force'); - - if (options.parallel && options.workers) { - args.push('--parallel', options.workers.toString()); - } - - console.log('[AgentManager] Spawning process with args:', args); - this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); - } - - /** - * Start QA process - */ - startQAProcess( - taskId: string, - projectPath: string, - specId: string - ): void { - // Use source auto-claude path (the repo), not the project's auto-claude - const autoBuildSource = this.getAutoBuildSourcePath(); - - if (!autoBuildSource) { - this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); - return; - } - - const runPath = path.join(autoBuildSource, 'run.py'); - - if (!existsSync(runPath)) { - this.emit('error', taskId, `Run script not found at: ${runPath}`); - return; - } - - // Load environment variables from auto-claude .env file and project settings - const autoBuildEnv = this.loadAutoBuildEnv(); - const projectEnv = this.getProjectEnvVars(projectPath); - const combinedEnv = { ...autoBuildEnv, ...projectEnv }; - - const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa']; - - this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process'); - } - - /** - * Start roadmap generation process - */ - startRoadmapGeneration( - projectId: string, - projectPath: string, - refresh: boolean = false - ): void { - // Use source auto-claude path (the repo), not the project's auto-claude - const autoBuildSource = this.getAutoBuildSourcePath(); - - if (!autoBuildSource) { - this.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); - return; - } - - const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py'); - - if (!existsSync(roadmapRunnerPath)) { - this.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`); - return; - } - - const args = [roadmapRunnerPath, '--project', projectPath]; - - if (refresh) { - args.push('--refresh'); - } - - // Use projectId as taskId for roadmap operations - this.spawnRoadmapProcess(projectId, projectPath, args); - } - - /** - * Start ideation generation process - */ - startIdeationGeneration( - projectId: string, - projectPath: string, - config: { - enabledTypes: string[]; - includeRoadmapContext: boolean; - includeKanbanContext: boolean; - maxIdeasPerType: number; - append?: boolean; - }, - refresh: boolean = false - ): void { - // Use source auto-claude path (the repo), not the project's auto-claude - const autoBuildSource = this.getAutoBuildSourcePath(); - - if (!autoBuildSource) { - this.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); - return; - } - - const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py'); - - if (!existsSync(ideationRunnerPath)) { - this.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`); - return; - } - - const args = [ideationRunnerPath, '--project', projectPath]; - - // Add enabled types as comma-separated list - if (config.enabledTypes.length > 0) { - args.push('--types', config.enabledTypes.join(',')); - } - - // Add context flags (script uses --no-roadmap/--no-kanban negative flags) - if (!config.includeRoadmapContext) { - args.push('--no-roadmap'); - } - if (!config.includeKanbanContext) { - args.push('--no-kanban'); - } - - // Add max ideas per type - if (config.maxIdeasPerType) { - args.push('--max-ideas', config.maxIdeasPerType.toString()); - } - - if (refresh) { - args.push('--refresh'); - } - - // Add append flag to preserve existing ideas - if (config.append) { - args.push('--append'); - } - - // Use projectId as taskId for ideation operations - this.spawnIdeationProcess(projectId, projectPath, args); - } - - /** - * Spawn a Python process for ideation generation - */ - private spawnIdeationProcess( - projectId: string, - projectPath: string, - args: string[] - ): void { - // Kill existing process for this project if any - this.killTask(projectId); - - // Generate unique spawn ID for this process instance - const spawnId = ++this.spawnCounter; - - // Run from auto-claude source directory so imports work correctly - const autoBuildSource = this.getAutoBuildSourcePath(); - const cwd = autoBuildSource || process.cwd(); - - // Load environment variables from auto-claude .env file and project settings - const autoBuildEnv = this.loadAutoBuildEnv(); - const projectEnv = this.getProjectEnvVars(projectPath); - const combinedEnv = { ...autoBuildEnv, ...projectEnv }; - - // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) - const profileEnv = getProfileEnv(); - - const childProcess = spawn(this.pythonPath, args, { - cwd, - env: { - ...process.env, - ...combinedEnv, // Include auto-claude .env variables and project-specific env vars - ...profileEnv, // Include active Claude profile config - PYTHONUNBUFFERED: '1' - } - }); - - this.processes.set(projectId, { - taskId: projectId, - process: childProcess, - startedAt: new Date(), - projectPath, // Store project path for loading session on completion - spawnId - }); - - // Track progress through output - let progressPhase = 'analyzing'; - let progressPercent = 10; - // Collect output for rate limit detection - let allOutput = ''; - - // Helper to emit logs - split multi-line output into individual log lines - const emitLogs = (log: string) => { - const lines = log.split('\n').filter(line => line.trim().length > 0); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.length > 0) { - console.log('[Ideation]', trimmed); - this.emit('ideation-log', projectId, trimmed); - } - } - }; - - console.log('[Ideation] Starting ideation process with args:', args); - console.log('[Ideation] CWD:', cwd); - console.log('[Ideation] Python path:', this.pythonPath); - console.log('[Ideation] Env vars loaded:', Object.keys(autoBuildEnv)); - console.log('[Ideation] Has CLAUDE_CODE_OAUTH_TOKEN:', !!autoBuildEnv['CLAUDE_CODE_OAUTH_TOKEN']); - - // Track completed types for progress calculation - const completedTypes = new Set(); - const totalTypes = args.filter(a => a !== '--types').length > 0 ? 7 : 7; // Default all types - - // Handle stdout - childProcess.stdout?.on('data', (data: Buffer) => { - const log = data.toString(); - // Collect output for rate limit detection (keep last 10KB) - allOutput = (allOutput + log).slice(-10000); - - // Emit all log lines for the activity log - emitLogs(log); - - // Check for streaming type completion signals - const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/); - if (typeCompleteMatch) { - const [, ideationType, ideasCount] = typeCompleteMatch; - completedTypes.add(ideationType); - console.log(`[Ideation] Type complete: ${ideationType} with ${ideasCount} ideas`); - - // Emit event for UI to load this type's ideas immediately - this.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10)); - } - - const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/); - if (typeFailedMatch) { - const [, ideationType] = typeFailedMatch; - completedTypes.add(ideationType); - console.log(`[Ideation] Type failed: ${ideationType}`); - this.emit('ideation-type-failed', projectId, ideationType); - } - - // Parse progress from output - track phase transitions - if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) { - progressPhase = 'analyzing'; - progressPercent = 10; - } else if (log.includes('CONTEXT GATHERING')) { - progressPhase = 'discovering'; - progressPercent = 20; - } else if (log.includes('GENERATING IDEAS (PARALLEL)') || log.includes('Starting') && log.includes('ideation agents in parallel')) { - progressPhase = 'generating'; - progressPercent = 30; - } else if (log.includes('MERGE') || log.includes('FINALIZE')) { - progressPhase = 'finalizing'; - progressPercent = 90; - } else if (log.includes('IDEATION COMPLETE')) { - progressPhase = 'complete'; - progressPercent = 100; - } - - // Update progress based on completed types during generation phase - if (progressPhase === 'generating' && completedTypes.size > 0) { - // Progress from 30% to 90% based on completed types - progressPercent = 30 + Math.floor((completedTypes.size / totalTypes) * 60); - } - - // Emit progress update with a clean message for the status bar - const statusMessage = log.trim().split('\n')[0].substring(0, 200); - this.emit('ideation-progress', projectId, { - phase: progressPhase, - progress: progressPercent, - message: statusMessage, - completedTypes: Array.from(completedTypes) - }); - }); - - // Handle stderr - also emit as logs - childProcess.stderr?.on('data', (data: Buffer) => { - const log = data.toString(); - // Collect stderr for rate limit detection too - allOutput = (allOutput + log).slice(-10000); - console.error('[Ideation STDERR]', log); - emitLogs(log); - this.emit('ideation-progress', projectId, { - phase: progressPhase, - progress: progressPercent, - message: log.trim().split('\n')[0].substring(0, 200) - }); - }); - - // Handle process exit - childProcess.on('exit', (code: number | null) => { - console.log('[Ideation] Process exited with code:', code); - - // Get the stored project path before deleting from map - const processInfo = this.processes.get(projectId); - const storedProjectPath = processInfo?.projectPath; - this.processes.delete(projectId); - - // Check for rate limit if process failed - if (code !== 0) { - const rateLimitDetection = detectRateLimit(allOutput); - if (rateLimitDetection.isRateLimited) { - console.log('[Ideation] Rate limit detected:', { - projectId, - resetTime: rateLimitDetection.resetTime, - limitType: rateLimitDetection.limitType, - suggestedProfile: rateLimitDetection.suggestedProfile?.name - }); - - const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, { - projectId - }); - this.emit('sdk-rate-limit', rateLimitInfo); - } - } - - if (code === 0) { - this.emit('ideation-progress', projectId, { - phase: 'complete', - progress: 100, - message: 'Ideation generation complete' - }); - - // Load and emit the complete ideation session - if (storedProjectPath) { - try { - const ideationFilePath = path.join( - storedProjectPath, - '.auto-claude', - 'ideation', - 'ideation.json' - ); - if (existsSync(ideationFilePath)) { - const content = readFileSync(ideationFilePath, 'utf-8'); - const session = JSON.parse(content); - console.log('[Ideation] Emitting ideation-complete with session data'); - this.emit('ideation-complete', projectId, session); - } else { - console.warn('[Ideation] ideation.json not found at:', ideationFilePath); - } - } catch (err) { - console.error('[Ideation] Failed to load ideation session:', err); - } - } - } else { - this.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`); - } - }); - - // Handle process error - childProcess.on('error', (err: Error) => { - console.error('[Ideation] Process error:', err.message); - this.processes.delete(projectId); - this.emit('ideation-error', projectId, err.message); - }); - } - - /** - * Spawn a Python process for roadmap generation - */ - private spawnRoadmapProcess( - projectId: string, - projectPath: string, - args: string[] - ): void { - // Kill existing process for this project if any - this.killTask(projectId); - - // Generate unique spawn ID for this process instance - const spawnId = ++this.spawnCounter; - - // Run from auto-claude source directory so imports work correctly - const autoBuildSource = this.getAutoBuildSourcePath(); - const cwd = autoBuildSource || process.cwd(); - - // Load environment variables from auto-claude .env file and project settings - const autoBuildEnv = this.loadAutoBuildEnv(); - const projectEnv = this.getProjectEnvVars(projectPath); - const combinedEnv = { ...autoBuildEnv, ...projectEnv }; - - // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) - const ideationProfileEnv = getProfileEnv(); - - const childProcess = spawn(this.pythonPath, args, { - cwd, - env: { - ...process.env, - ...combinedEnv, // Include auto-claude .env variables and project-specific env vars - ...ideationProfileEnv, // Include active Claude profile config - PYTHONUNBUFFERED: '1' - } - }); - - this.processes.set(projectId, { - taskId: projectId, - process: childProcess, - startedAt: new Date(), - spawnId - }); - - // Track progress through output - let progressPhase = 'analyzing'; - let progressPercent = 10; - // Collect output for rate limit detection - let allRoadmapOutput = ''; - - // Handle stdout - childProcess.stdout?.on('data', (data: Buffer) => { - const log = data.toString(); - // Collect output for rate limit detection (keep last 10KB) - allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); - - // Parse progress from output - if (log.includes('PROJECT ANALYSIS')) { - progressPhase = 'analyzing'; - progressPercent = 20; - } else if (log.includes('PROJECT DISCOVERY')) { - progressPhase = 'discovering'; - progressPercent = 40; - } else if (log.includes('FEATURE GENERATION')) { - progressPhase = 'generating'; - progressPercent = 70; - } else if (log.includes('ROADMAP GENERATED')) { - progressPhase = 'complete'; - progressPercent = 100; - } - - // Emit progress update - this.emit('roadmap-progress', projectId, { - phase: progressPhase, - progress: progressPercent, - message: log.trim().substring(0, 200) // Truncate long messages - }); - }); - - // Handle stderr - childProcess.stderr?.on('data', (data: Buffer) => { - const log = data.toString(); - // Collect stderr for rate limit detection too - allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); - this.emit('roadmap-progress', projectId, { - phase: progressPhase, - progress: progressPercent, - message: log.trim().substring(0, 200) - }); - }); - - // Handle process exit - childProcess.on('exit', (code: number | null) => { - this.processes.delete(projectId); - - // Check for rate limit if process failed - if (code !== 0) { - const rateLimitDetection = detectRateLimit(allRoadmapOutput); - if (rateLimitDetection.isRateLimited) { - console.log('[Roadmap] Rate limit detected:', { - projectId, - resetTime: rateLimitDetection.resetTime, - limitType: rateLimitDetection.limitType, - suggestedProfile: rateLimitDetection.suggestedProfile?.name - }); - - const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, { - projectId - }); - this.emit('sdk-rate-limit', rateLimitInfo); - } - } - - if (code === 0) { - this.emit('roadmap-progress', projectId, { - phase: 'complete', - progress: 100, - message: 'Roadmap generation complete' - }); - } else { - this.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`); - } - }); - - // Handle process error - childProcess.on('error', (err: Error) => { - this.processes.delete(projectId); - this.emit('roadmap-error', projectId, err.message); - }); - } - - /** - * Parse log output to detect execution phase transitions - */ - private parseExecutionPhase( - log: string, - currentPhase: ExecutionProgressData['phase'], - isSpecRunner: boolean - ): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null { - const lowerLog = log.toLowerCase(); - - // Spec runner phase detection (all part of "planning") - if (isSpecRunner) { - if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) { - return { phase: 'planning', message: 'Discovering project context...' }; - } - if (lowerLog.includes('requirements') || lowerLog.includes('gathering')) { - return { phase: 'planning', message: 'Gathering requirements...' }; - } - if (lowerLog.includes('writing spec') || lowerLog.includes('spec writer')) { - return { phase: 'planning', message: 'Writing specification...' }; - } - if (lowerLog.includes('validating') || lowerLog.includes('validation')) { - return { phase: 'planning', message: 'Validating specification...' }; - } - if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) { - return { phase: 'planning', message: 'Specification complete' }; - } - } - - // Run.py phase detection - // Planner agent running - if (lowerLog.includes('planner agent') || lowerLog.includes('creating implementation plan')) { - return { phase: 'planning', message: 'Creating implementation plan...' }; - } - - // Coder agent running - if (lowerLog.includes('coder agent') || lowerLog.includes('starting coder')) { - return { phase: 'coding', message: 'Implementing code changes...' }; - } - - // Subtask progress detection - const subtaskMatch = log.match(/subtask[:\s]+(\d+(?:\/\d+)?|\w+[-_]\w+)/i); - if (subtaskMatch && currentPhase === 'coding') { - return { phase: 'coding', currentSubtask: subtaskMatch[1], message: `Working on subtask ${subtaskMatch[1]}...` }; - } - - // Subtask completion detection - if (lowerLog.includes('subtask completed') || lowerLog.includes('subtask done')) { - const completedSubtask = log.match(/subtask[:\s]+"?([^"]+)"?\s+completed/i); - return { - phase: 'coding', - currentSubtask: completedSubtask?.[1], - message: `Subtask ${completedSubtask?.[1] || ''} completed` - }; - } - - // QA Review phase - if (lowerLog.includes('qa reviewer') || lowerLog.includes('qa_reviewer') || lowerLog.includes('starting qa')) { - return { phase: 'qa_review', message: 'Running QA review...' }; - } - - // QA Fixer phase - if (lowerLog.includes('qa fixer') || lowerLog.includes('qa_fixer') || lowerLog.includes('fixing issues')) { - return { phase: 'qa_fixing', message: 'Fixing QA issues...' }; - } - - // Completion detection - be conservative, require explicit success markers - // The AI agent prints "=== BUILD COMPLETE ===" when truly done (from coder.md) - // Only trust this pattern, not generic "all subtasks completed" which could be false positive - if (lowerLog.includes('=== build complete ===') || lowerLog.includes('qa passed')) { - return { phase: 'complete', message: 'Build completed successfully' }; - } - - // "All subtasks completed" is informational - don't change phase based on this alone - // The coordinator may print this even when subtasks are blocked, so we stay in coding phase - // and let the actual implementation_plan.json status drive the UI - if (lowerLog.includes('all subtasks completed')) { - return { phase: 'coding', message: 'Subtasks marked complete' }; - } - - // Incomplete build detection - when coordinator exits with pending subtasks - if (lowerLog.includes('build incomplete') || lowerLog.includes('subtasks still pending')) { - return { phase: 'coding', message: 'Build paused - subtasks still pending' }; - } - - // Error/failure detection - if (lowerLog.includes('build failed') || lowerLog.includes('error:') || lowerLog.includes('fatal')) { - return { phase: 'failed', message: log.trim().substring(0, 200) }; - } - - return null; - } - - /** - * Calculate overall progress based on phase and phase progress - */ - private calculateOverallProgress(phase: ExecutionProgressData['phase'], phaseProgress: number): number { - // Phase weight ranges (same as in constants.ts) - const weights: Record = { - idle: { start: 0, end: 0 }, - planning: { start: 0, end: 20 }, - coding: { start: 20, end: 80 }, - qa_review: { start: 80, end: 95 }, - qa_fixing: { start: 80, end: 95 }, - complete: { start: 100, end: 100 }, - failed: { start: 0, end: 0 } - }; - - const phaseWeight = weights[phase] || { start: 0, end: 0 }; - const phaseRange = phaseWeight.end - phaseWeight.start; - return Math.round(phaseWeight.start + (phaseRange * phaseProgress / 100)); - } - - /** - * Spawn a Python process - */ - private spawnProcess( - taskId: string, - cwd: string, - args: string[], - extraEnv: Record = {}, - processType: ProcessType = 'task-execution' - ): void { - const isSpecRunner = processType === 'spec-creation'; - // Kill existing process for this task if any - this.killTask(taskId); - - // Generate unique spawn ID for this process instance - const spawnId = ++this.spawnCounter; - - console.log('[spawnProcess] Spawning with pythonPath:', this.pythonPath); - console.log('[spawnProcess] cwd:', cwd); - console.log('[spawnProcess] processType:', processType); - console.log('[spawnProcess] spawnId:', spawnId); - - // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) - const spawnProfileEnv = getProfileEnv(); - - const childProcess = spawn(this.pythonPath, args, { - cwd, - env: { - ...process.env, - ...extraEnv, - ...spawnProfileEnv, // Include active Claude profile config - PYTHONUNBUFFERED: '1' // Ensure real-time output - } - }); - - console.log('[spawnProcess] Process spawned, pid:', childProcess.pid); - - this.processes.set(taskId, { - taskId, - process: childProcess, - startedAt: new Date(), - spawnId - }); - - // Track execution progress - let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning'; - let phaseProgress = 0; - let currentSubtask: string | undefined; - let lastMessage: string | undefined; - // Collect all output for rate limit detection - let allOutput = ''; - - // Emit initial progress - this.emit('execution-progress', taskId, { - phase: currentPhase, - phaseProgress: 0, - overallProgress: this.calculateOverallProgress(currentPhase, 0), - message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...' - }); - - const processLog = (log: string) => { - // Collect output for rate limit detection (keep last 10KB) - allOutput = (allOutput + log).slice(-10000); - // Parse for phase transitions - const phaseUpdate = this.parseExecutionPhase(log, currentPhase, isSpecRunner); - - if (phaseUpdate) { - const phaseChanged = phaseUpdate.phase !== currentPhase; - currentPhase = phaseUpdate.phase; - - if (phaseUpdate.currentSubtask) { - currentSubtask = phaseUpdate.currentSubtask; - } - if (phaseUpdate.message) { - lastMessage = phaseUpdate.message; - } - - // Reset phase progress on phase change, otherwise increment - if (phaseChanged) { - phaseProgress = 10; // Start new phase at 10% - } else { - phaseProgress = Math.min(90, phaseProgress + 5); // Increment within phase - } - - const overallProgress = this.calculateOverallProgress(currentPhase, phaseProgress); - - this.emit('execution-progress', taskId, { - phase: currentPhase, - phaseProgress, - overallProgress, - currentSubtask, - message: lastMessage - }); - } - }; - - // Handle stdout - childProcess.stdout?.on('data', (data: Buffer) => { - const log = data.toString(); - console.log('[spawnProcess] stdout:', log.substring(0, 200)); - this.emit('log', taskId, log); - processLog(log); - }); - - // Handle stderr - childProcess.stderr?.on('data', (data: Buffer) => { - const log = data.toString(); - console.log('[spawnProcess] stderr:', log.substring(0, 200)); - // Some Python output goes to stderr (like progress bars) - // so we treat it as log, not error - this.emit('log', taskId, log); - processLog(log); - }); - - // Handle process exit - childProcess.on('exit', (code: number | null) => { - console.log('[spawnProcess] Process exited with code:', code, 'spawnId:', spawnId); - this.processes.delete(taskId); - - // Check if this specific spawn was killed (vs exited naturally) - // If killed, don't emit exit event to prevent race condition with new process - if (this.killedSpawnIds.has(spawnId)) { - console.log('[spawnProcess] Process was killed, skipping exit event for spawnId:', spawnId); - this.killedSpawnIds.delete(spawnId); - return; - } - - // Check for rate limit if process failed - if (code !== 0) { - const rateLimitDetection = detectRateLimit(allOutput); - if (rateLimitDetection.isRateLimited) { - console.log('[spawnProcess] Rate limit detected in task output:', { - taskId, - resetTime: rateLimitDetection.resetTime, - limitType: rateLimitDetection.limitType, - suggestedProfile: rateLimitDetection.suggestedProfile?.name - }); - - // Determine source type based on processType - const source = processType === 'spec-creation' ? 'task' : 'task'; - - // Emit rate limit event - const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { - taskId - }); - this.emit('sdk-rate-limit', rateLimitInfo); - } - } - - // Emit final progress - const finalPhase = code === 0 ? 'complete' : 'failed'; - this.emit('execution-progress', taskId, { - phase: finalPhase, - phaseProgress: 100, - overallProgress: code === 0 ? 100 : this.calculateOverallProgress(currentPhase, phaseProgress), - message: code === 0 ? 'Process completed successfully' : `Process exited with code ${code}` - }); - - this.emit('exit', taskId, code, processType); - }); - - // Handle process error - childProcess.on('error', (err: Error) => { - console.log('[spawnProcess] Process error:', err.message); - this.processes.delete(taskId); - - this.emit('execution-progress', taskId, { - phase: 'failed', - phaseProgress: 0, - overallProgress: 0, - message: `Error: ${err.message}` - }); - - this.emit('error', taskId, err.message); - }); - } - - /** - * Kill a specific task's process - */ - killTask(taskId: string): boolean { - const agentProcess = this.processes.get(taskId); - if (agentProcess) { - try { - // Mark this specific spawn as killed so its exit handler knows to ignore - this.killedSpawnIds.add(agentProcess.spawnId); - - // Send SIGTERM first for graceful shutdown - agentProcess.process.kill('SIGTERM'); - - // Force kill after timeout - setTimeout(() => { - if (!agentProcess.process.killed) { - agentProcess.process.kill('SIGKILL'); - } - }, 5000); - - this.processes.delete(taskId); - return true; - } catch { - return false; - } - } - return false; - } - - /** - * Stop ideation generation for a project - */ - stopIdeation(projectId: string): boolean { - const wasRunning = this.isRunning(projectId); - if (wasRunning) { - this.killTask(projectId); - this.emit('ideation-stopped', projectId); - return true; - } - return false; - } - - /** - * Check if ideation is running for a project - */ - isIdeationRunning(projectId: string): boolean { - return this.isRunning(projectId); - } - - /** - * Kill all running processes - */ - async killAll(): Promise { - const killPromises = Array.from(this.processes.keys()).map((taskId) => { - return new Promise((resolve) => { - this.killTask(taskId); - resolve(); - }); - }); - await Promise.all(killPromises); - } - - /** - * Check if a task is running - */ - isRunning(taskId: string): boolean { - return this.processes.has(taskId); - } - - /** - * Get all running task IDs - */ - getRunningTasks(): string[] { - return Array.from(this.processes.keys()); - } -} +export { + AgentManager, + AgentState, + AgentEvents, + AgentProcessManager, + AgentQueueManager +} from './agent'; + +export type { + AgentProcess, + ExecutionProgressData, + ProcessType, + AgentManagerEvents, + IdeationConfig, + TaskExecutionOptions, + SpecCreationMetadata, + IdeationProgressData, + RoadmapProgressData +} from './agent'; diff --git a/auto-claude-ui/src/main/agent-manager.ts.backup b/auto-claude-ui/src/main/agent-manager.ts.backup new file mode 100644 index 00000000..0436a024 --- /dev/null +++ b/auto-claude-ui/src/main/agent-manager.ts.backup @@ -0,0 +1,1101 @@ +import { spawn, ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { app } from 'electron'; +import { projectStore } from './project-store'; +import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from './rate-limit-detector'; + +interface AgentProcess { + taskId: string; + process: ChildProcess; + startedAt: Date; + projectPath?: string; // For ideation processes to load session on completion + spawnId: number; // Unique ID to identify this specific spawn +} + +export interface ExecutionProgressData { + phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed'; + phaseProgress: number; + overallProgress: number; + currentSubtask?: string; + message?: string; +} + +export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process'; + +export interface AgentManagerEvents { + log: (taskId: string, log: string) => void; + error: (taskId: string, error: string) => void; + exit: (taskId: string, code: number | null, processType: ProcessType) => void; + 'execution-progress': (taskId: string, progress: ExecutionProgressData) => void; +} + +/** + * Manages Python subprocess spawning for auto-claude agents + */ +export class AgentManager extends EventEmitter { + private processes: Map = new Map(); + private killedSpawnIds: Set = new Set(); // Track spawn IDs whose processes were killed + private spawnCounter: number = 0; // Unique ID for each spawn + private pythonPath: string = 'python3'; + private autoBuildSourcePath: string = ''; // Source auto-claude repo location + + constructor() { + super(); + } + + /** + * Configure paths for Python and auto-claude source + */ + configure(pythonPath?: string, autoBuildSourcePath?: string): void { + if (pythonPath) { + this.pythonPath = pythonPath; + } + if (autoBuildSourcePath) { + this.autoBuildSourcePath = autoBuildSourcePath; + } + } + + /** + * Get the auto-claude source path (detects automatically if not configured) + */ + private getAutoBuildSourcePath(): string | null { + // If manually configured, use that + if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { + return this.autoBuildSourcePath; + } + + // Auto-detect from app location + const possiblePaths = [ + // Dev mode: from dist/main -> ../../auto-claude (sibling to auto-claude-ui) + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + // Alternative: from app root + path.resolve(app.getAppPath(), '..', 'auto-claude'), + // If running from repo root + path.resolve(process.cwd(), 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; + } + + /** + * Get project-specific environment variables based on project settings + */ + private getProjectEnvVars(projectPath: string): Record { + const env: Record = {}; + + // Find project by path + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === projectPath); + + if (project?.settings) { + // Graphiti MCP integration + if (project.settings.graphitiMcpEnabled) { + const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/'; + env['GRAPHITI_MCP_URL'] = graphitiUrl; + } + } + + return env; + } + + /** + * Load environment variables from auto-claude .env file + */ + private loadAutoBuildEnv(): Record { + const autoBuildSource = this.getAutoBuildSourcePath(); + if (!autoBuildSource) { + console.log('[loadAutoBuildEnv] No auto-build source path found'); + return {}; + } + + const envPath = path.join(autoBuildSource, '.env'); + console.log('[loadAutoBuildEnv] Looking for .env at:', envPath); + if (!existsSync(envPath)) { + console.log('[loadAutoBuildEnv] .env file does not exist'); + return {}; + } + + try { + const envContent = readFileSync(envPath, 'utf-8'); + const envVars: Record = {}; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + envVars[key] = value; + } + } + + return envVars; + } catch { + return {}; + } + } + + /** + * Start spec creation process + */ + startSpecCreation( + taskId: string, + projectPath: string, + taskDescription: string, + specDir?: string, // Optional spec directory (when task already has a directory created by UI) + metadata?: { requireReviewBeforeCoding?: boolean } // Task metadata to check for review requirement + ): void { + // Use source auto-claude path (the repo), not the project's auto-claude + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py'); + + if (!existsSync(specRunnerPath)) { + this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`); + return; + } + + // Load environment variables from auto-claude .env file and project settings + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + const combinedEnv = { ...autoBuildEnv, ...projectEnv }; + + // spec_runner.py will auto-start run.py after spec creation completes + const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath]; + + // Pass spec directory if provided (for UI-created tasks that already have a directory) + if (specDir) { + args.push('--spec-dir', specDir); + } + + // Check if user requires review before coding + // If requireReviewBeforeCoding is true, skip auto-approve to trigger review checkpoint + if (!metadata?.requireReviewBeforeCoding) { + // Auto-approve: When user starts a task from the UI without requiring review, that IS their approval + // No need for interactive review checkpoint - user explicitly clicked "Start" + args.push('--auto-approve'); + } + // If requireReviewBeforeCoding is true, don't add --auto-approve, allowing the review checkpoint to appear + + // Note: This is spec-creation but it chains to task-execution via run.py + // So we treat the whole thing as task-execution for status purposes + this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + } + + /** + * Start task execution (run.py) + */ + startTaskExecution( + taskId: string, + projectPath: string, + specId: string, + options: { parallel?: boolean; workers?: number } = {} + ): void { + console.log('[AgentManager] startTaskExecution called for:', taskId, specId); + // Use source auto-claude path (the repo), not the project's auto-claude + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + console.log('[AgentManager] ERROR: Auto-build source path not found'); + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const runPath = path.join(autoBuildSource, 'run.py'); + console.log('[AgentManager] runPath:', runPath); + + if (!existsSync(runPath)) { + console.log('[AgentManager] ERROR: Run script not found at:', runPath); + this.emit('error', taskId, `Run script not found at: ${runPath}`); + return; + } + + // Load environment variables from auto-claude .env file and project settings + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + const combinedEnv = { ...autoBuildEnv, ...projectEnv }; + + const args = [runPath, '--spec', specId, '--project-dir', projectPath]; + + // Always use auto-continue when running from UI (non-interactive) + args.push('--auto-continue'); + + // Force: When user starts a task from the UI, that IS their approval + // The review checkpoint is for CLI users who need to review before building + // UI users have already seen the spec in the interface before clicking "Start" + args.push('--force'); + + if (options.parallel && options.workers) { + args.push('--parallel', options.workers.toString()); + } + + console.log('[AgentManager] Spawning process with args:', args); + this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + } + + /** + * Start QA process + */ + startQAProcess( + taskId: string, + projectPath: string, + specId: string + ): void { + // Use source auto-claude path (the repo), not the project's auto-claude + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const runPath = path.join(autoBuildSource, 'run.py'); + + if (!existsSync(runPath)) { + this.emit('error', taskId, `Run script not found at: ${runPath}`); + return; + } + + // Load environment variables from auto-claude .env file and project settings + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + const combinedEnv = { ...autoBuildEnv, ...projectEnv }; + + const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa']; + + this.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process'); + } + + /** + * Start roadmap generation process + */ + startRoadmapGeneration( + projectId: string, + projectPath: string, + refresh: boolean = false + ): void { + // Use source auto-claude path (the repo), not the project's auto-claude + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py'); + + if (!existsSync(roadmapRunnerPath)) { + this.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`); + return; + } + + const args = [roadmapRunnerPath, '--project', projectPath]; + + if (refresh) { + args.push('--refresh'); + } + + // Use projectId as taskId for roadmap operations + this.spawnRoadmapProcess(projectId, projectPath, args); + } + + /** + * Start ideation generation process + */ + startIdeationGeneration( + projectId: string, + projectPath: string, + config: { + enabledTypes: string[]; + includeRoadmapContext: boolean; + includeKanbanContext: boolean; + maxIdeasPerType: number; + append?: boolean; + }, + refresh: boolean = false + ): void { + // Use source auto-claude path (the repo), not the project's auto-claude + const autoBuildSource = this.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py'); + + if (!existsSync(ideationRunnerPath)) { + this.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`); + return; + } + + const args = [ideationRunnerPath, '--project', projectPath]; + + // Add enabled types as comma-separated list + if (config.enabledTypes.length > 0) { + args.push('--types', config.enabledTypes.join(',')); + } + + // Add context flags (script uses --no-roadmap/--no-kanban negative flags) + if (!config.includeRoadmapContext) { + args.push('--no-roadmap'); + } + if (!config.includeKanbanContext) { + args.push('--no-kanban'); + } + + // Add max ideas per type + if (config.maxIdeasPerType) { + args.push('--max-ideas', config.maxIdeasPerType.toString()); + } + + if (refresh) { + args.push('--refresh'); + } + + // Add append flag to preserve existing ideas + if (config.append) { + args.push('--append'); + } + + // Use projectId as taskId for ideation operations + this.spawnIdeationProcess(projectId, projectPath, args); + } + + /** + * Spawn a Python process for ideation generation + */ + private spawnIdeationProcess( + projectId: string, + projectPath: string, + args: string[] + ): void { + // Kill existing process for this project if any + this.killTask(projectId); + + // Generate unique spawn ID for this process instance + const spawnId = ++this.spawnCounter; + + // Run from auto-claude source directory so imports work correctly + const autoBuildSource = this.getAutoBuildSourcePath(); + const cwd = autoBuildSource || process.cwd(); + + // Load environment variables from auto-claude .env file and project settings + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + const combinedEnv = { ...autoBuildEnv, ...projectEnv }; + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const profileEnv = getProfileEnv(); + + const childProcess = spawn(this.pythonPath, args, { + cwd, + env: { + ...process.env, + ...combinedEnv, // Include auto-claude .env variables and project-specific env vars + ...profileEnv, // Include active Claude profile config + PYTHONUNBUFFERED: '1' + } + }); + + this.processes.set(projectId, { + taskId: projectId, + process: childProcess, + startedAt: new Date(), + projectPath, // Store project path for loading session on completion + spawnId + }); + + // Track progress through output + let progressPhase = 'analyzing'; + let progressPercent = 10; + // Collect output for rate limit detection + let allOutput = ''; + + // Helper to emit logs - split multi-line output into individual log lines + const emitLogs = (log: string) => { + const lines = log.split('\n').filter(line => line.trim().length > 0); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + console.log('[Ideation]', trimmed); + this.emit('ideation-log', projectId, trimmed); + } + } + }; + + console.log('[Ideation] Starting ideation process with args:', args); + console.log('[Ideation] CWD:', cwd); + console.log('[Ideation] Python path:', this.pythonPath); + console.log('[Ideation] Env vars loaded:', Object.keys(autoBuildEnv)); + console.log('[Ideation] Has CLAUDE_CODE_OAUTH_TOKEN:', !!autoBuildEnv['CLAUDE_CODE_OAUTH_TOKEN']); + + // Track completed types for progress calculation + const completedTypes = new Set(); + const totalTypes = args.filter(a => a !== '--types').length > 0 ? 7 : 7; // Default all types + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect output for rate limit detection (keep last 10KB) + allOutput = (allOutput + log).slice(-10000); + + // Emit all log lines for the activity log + emitLogs(log); + + // Check for streaming type completion signals + const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/); + if (typeCompleteMatch) { + const [, ideationType, ideasCount] = typeCompleteMatch; + completedTypes.add(ideationType); + console.log(`[Ideation] Type complete: ${ideationType} with ${ideasCount} ideas`); + + // Emit event for UI to load this type's ideas immediately + this.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10)); + } + + const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/); + if (typeFailedMatch) { + const [, ideationType] = typeFailedMatch; + completedTypes.add(ideationType); + console.log(`[Ideation] Type failed: ${ideationType}`); + this.emit('ideation-type-failed', projectId, ideationType); + } + + // Parse progress from output - track phase transitions + if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) { + progressPhase = 'analyzing'; + progressPercent = 10; + } else if (log.includes('CONTEXT GATHERING')) { + progressPhase = 'discovering'; + progressPercent = 20; + } else if (log.includes('GENERATING IDEAS (PARALLEL)') || log.includes('Starting') && log.includes('ideation agents in parallel')) { + progressPhase = 'generating'; + progressPercent = 30; + } else if (log.includes('MERGE') || log.includes('FINALIZE')) { + progressPhase = 'finalizing'; + progressPercent = 90; + } else if (log.includes('IDEATION COMPLETE')) { + progressPhase = 'complete'; + progressPercent = 100; + } + + // Update progress based on completed types during generation phase + if (progressPhase === 'generating' && completedTypes.size > 0) { + // Progress from 30% to 90% based on completed types + progressPercent = 30 + Math.floor((completedTypes.size / totalTypes) * 60); + } + + // Emit progress update with a clean message for the status bar + const statusMessage = log.trim().split('\n')[0].substring(0, 200); + this.emit('ideation-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: statusMessage, + completedTypes: Array.from(completedTypes) + }); + }); + + // Handle stderr - also emit as logs + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect stderr for rate limit detection too + allOutput = (allOutput + log).slice(-10000); + console.error('[Ideation STDERR]', log); + emitLogs(log); + this.emit('ideation-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().split('\n')[0].substring(0, 200) + }); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + console.log('[Ideation] Process exited with code:', code); + + // Get the stored project path before deleting from map + const processInfo = this.processes.get(projectId); + const storedProjectPath = processInfo?.projectPath; + this.processes.delete(projectId); + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allOutput); + if (rateLimitDetection.isRateLimited) { + console.log('[Ideation] Rate limit detected:', { + projectId, + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, { + projectId + }); + this.emit('sdk-rate-limit', rateLimitInfo); + } + } + + if (code === 0) { + this.emit('ideation-progress', projectId, { + phase: 'complete', + progress: 100, + message: 'Ideation generation complete' + }); + + // Load and emit the complete ideation session + if (storedProjectPath) { + try { + const ideationFilePath = path.join( + storedProjectPath, + '.auto-claude', + 'ideation', + 'ideation.json' + ); + if (existsSync(ideationFilePath)) { + const content = readFileSync(ideationFilePath, 'utf-8'); + const session = JSON.parse(content); + console.log('[Ideation] Emitting ideation-complete with session data'); + this.emit('ideation-complete', projectId, session); + } else { + console.warn('[Ideation] ideation.json not found at:', ideationFilePath); + } + } catch (err) { + console.error('[Ideation] Failed to load ideation session:', err); + } + } + } else { + this.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`); + } + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + console.error('[Ideation] Process error:', err.message); + this.processes.delete(projectId); + this.emit('ideation-error', projectId, err.message); + }); + } + + /** + * Spawn a Python process for roadmap generation + */ + private spawnRoadmapProcess( + projectId: string, + projectPath: string, + args: string[] + ): void { + // Kill existing process for this project if any + this.killTask(projectId); + + // Generate unique spawn ID for this process instance + const spawnId = ++this.spawnCounter; + + // Run from auto-claude source directory so imports work correctly + const autoBuildSource = this.getAutoBuildSourcePath(); + const cwd = autoBuildSource || process.cwd(); + + // Load environment variables from auto-claude .env file and project settings + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + const combinedEnv = { ...autoBuildEnv, ...projectEnv }; + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const ideationProfileEnv = getProfileEnv(); + + const childProcess = spawn(this.pythonPath, args, { + cwd, + env: { + ...process.env, + ...combinedEnv, // Include auto-claude .env variables and project-specific env vars + ...ideationProfileEnv, // Include active Claude profile config + PYTHONUNBUFFERED: '1' + } + }); + + this.processes.set(projectId, { + taskId: projectId, + process: childProcess, + startedAt: new Date(), + spawnId + }); + + // Track progress through output + let progressPhase = 'analyzing'; + let progressPercent = 10; + // Collect output for rate limit detection + let allRoadmapOutput = ''; + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect output for rate limit detection (keep last 10KB) + allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); + + // Parse progress from output + if (log.includes('PROJECT ANALYSIS')) { + progressPhase = 'analyzing'; + progressPercent = 20; + } else if (log.includes('PROJECT DISCOVERY')) { + progressPhase = 'discovering'; + progressPercent = 40; + } else if (log.includes('FEATURE GENERATION')) { + progressPhase = 'generating'; + progressPercent = 70; + } else if (log.includes('ROADMAP GENERATED')) { + progressPhase = 'complete'; + progressPercent = 100; + } + + // Emit progress update + this.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().substring(0, 200) // Truncate long messages + }); + }); + + // Handle stderr + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect stderr for rate limit detection too + allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); + this.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().substring(0, 200) + }); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + this.processes.delete(projectId); + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allRoadmapOutput); + if (rateLimitDetection.isRateLimited) { + console.log('[Roadmap] Rate limit detected:', { + projectId, + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, { + projectId + }); + this.emit('sdk-rate-limit', rateLimitInfo); + } + } + + if (code === 0) { + this.emit('roadmap-progress', projectId, { + phase: 'complete', + progress: 100, + message: 'Roadmap generation complete' + }); + } else { + this.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`); + } + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + this.processes.delete(projectId); + this.emit('roadmap-error', projectId, err.message); + }); + } + + /** + * Parse log output to detect execution phase transitions + */ + private parseExecutionPhase( + log: string, + currentPhase: ExecutionProgressData['phase'], + isSpecRunner: boolean + ): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null { + const lowerLog = log.toLowerCase(); + + // Spec runner phase detection (all part of "planning") + if (isSpecRunner) { + if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) { + return { phase: 'planning', message: 'Discovering project context...' }; + } + if (lowerLog.includes('requirements') || lowerLog.includes('gathering')) { + return { phase: 'planning', message: 'Gathering requirements...' }; + } + if (lowerLog.includes('writing spec') || lowerLog.includes('spec writer')) { + return { phase: 'planning', message: 'Writing specification...' }; + } + if (lowerLog.includes('validating') || lowerLog.includes('validation')) { + return { phase: 'planning', message: 'Validating specification...' }; + } + if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) { + return { phase: 'planning', message: 'Specification complete' }; + } + } + + // Run.py phase detection + // Planner agent running + if (lowerLog.includes('planner agent') || lowerLog.includes('creating implementation plan')) { + return { phase: 'planning', message: 'Creating implementation plan...' }; + } + + // Coder agent running + if (lowerLog.includes('coder agent') || lowerLog.includes('starting coder')) { + return { phase: 'coding', message: 'Implementing code changes...' }; + } + + // Subtask progress detection + const subtaskMatch = log.match(/subtask[:\s]+(\d+(?:\/\d+)?|\w+[-_]\w+)/i); + if (subtaskMatch && currentPhase === 'coding') { + return { phase: 'coding', currentSubtask: subtaskMatch[1], message: `Working on subtask ${subtaskMatch[1]}...` }; + } + + // Subtask completion detection + if (lowerLog.includes('subtask completed') || lowerLog.includes('subtask done')) { + const completedSubtask = log.match(/subtask[:\s]+"?([^"]+)"?\s+completed/i); + return { + phase: 'coding', + currentSubtask: completedSubtask?.[1], + message: `Subtask ${completedSubtask?.[1] || ''} completed` + }; + } + + // QA Review phase + if (lowerLog.includes('qa reviewer') || lowerLog.includes('qa_reviewer') || lowerLog.includes('starting qa')) { + return { phase: 'qa_review', message: 'Running QA review...' }; + } + + // QA Fixer phase + if (lowerLog.includes('qa fixer') || lowerLog.includes('qa_fixer') || lowerLog.includes('fixing issues')) { + return { phase: 'qa_fixing', message: 'Fixing QA issues...' }; + } + + // Completion detection - be conservative, require explicit success markers + // The AI agent prints "=== BUILD COMPLETE ===" when truly done (from coder.md) + // Only trust this pattern, not generic "all subtasks completed" which could be false positive + if (lowerLog.includes('=== build complete ===') || lowerLog.includes('qa passed')) { + return { phase: 'complete', message: 'Build completed successfully' }; + } + + // "All subtasks completed" is informational - don't change phase based on this alone + // The coordinator may print this even when subtasks are blocked, so we stay in coding phase + // and let the actual implementation_plan.json status drive the UI + if (lowerLog.includes('all subtasks completed')) { + return { phase: 'coding', message: 'Subtasks marked complete' }; + } + + // Incomplete build detection - when coordinator exits with pending subtasks + if (lowerLog.includes('build incomplete') || lowerLog.includes('subtasks still pending')) { + return { phase: 'coding', message: 'Build paused - subtasks still pending' }; + } + + // Error/failure detection + if (lowerLog.includes('build failed') || lowerLog.includes('error:') || lowerLog.includes('fatal')) { + return { phase: 'failed', message: log.trim().substring(0, 200) }; + } + + return null; + } + + /** + * Calculate overall progress based on phase and phase progress + */ + private calculateOverallProgress(phase: ExecutionProgressData['phase'], phaseProgress: number): number { + // Phase weight ranges (same as in constants.ts) + const weights: Record = { + idle: { start: 0, end: 0 }, + planning: { start: 0, end: 20 }, + coding: { start: 20, end: 80 }, + qa_review: { start: 80, end: 95 }, + qa_fixing: { start: 80, end: 95 }, + complete: { start: 100, end: 100 }, + failed: { start: 0, end: 0 } + }; + + const phaseWeight = weights[phase] || { start: 0, end: 0 }; + const phaseRange = phaseWeight.end - phaseWeight.start; + return Math.round(phaseWeight.start + (phaseRange * phaseProgress / 100)); + } + + /** + * Spawn a Python process + */ + private spawnProcess( + taskId: string, + cwd: string, + args: string[], + extraEnv: Record = {}, + processType: ProcessType = 'task-execution' + ): void { + const isSpecRunner = processType === 'spec-creation'; + // Kill existing process for this task if any + this.killTask(taskId); + + // Generate unique spawn ID for this process instance + const spawnId = ++this.spawnCounter; + + console.log('[spawnProcess] Spawning with pythonPath:', this.pythonPath); + console.log('[spawnProcess] cwd:', cwd); + console.log('[spawnProcess] processType:', processType); + console.log('[spawnProcess] spawnId:', spawnId); + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const spawnProfileEnv = getProfileEnv(); + + const childProcess = spawn(this.pythonPath, args, { + cwd, + env: { + ...process.env, + ...extraEnv, + ...spawnProfileEnv, // Include active Claude profile config + PYTHONUNBUFFERED: '1' // Ensure real-time output + } + }); + + console.log('[spawnProcess] Process spawned, pid:', childProcess.pid); + + this.processes.set(taskId, { + taskId, + process: childProcess, + startedAt: new Date(), + spawnId + }); + + // Track execution progress + let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning'; + let phaseProgress = 0; + let currentSubtask: string | undefined; + let lastMessage: string | undefined; + // Collect all output for rate limit detection + let allOutput = ''; + + // Emit initial progress + this.emit('execution-progress', taskId, { + phase: currentPhase, + phaseProgress: 0, + overallProgress: this.calculateOverallProgress(currentPhase, 0), + message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...' + }); + + const processLog = (log: string) => { + // Collect output for rate limit detection (keep last 10KB) + allOutput = (allOutput + log).slice(-10000); + // Parse for phase transitions + const phaseUpdate = this.parseExecutionPhase(log, currentPhase, isSpecRunner); + + if (phaseUpdate) { + const phaseChanged = phaseUpdate.phase !== currentPhase; + currentPhase = phaseUpdate.phase; + + if (phaseUpdate.currentSubtask) { + currentSubtask = phaseUpdate.currentSubtask; + } + if (phaseUpdate.message) { + lastMessage = phaseUpdate.message; + } + + // Reset phase progress on phase change, otherwise increment + if (phaseChanged) { + phaseProgress = 10; // Start new phase at 10% + } else { + phaseProgress = Math.min(90, phaseProgress + 5); // Increment within phase + } + + const overallProgress = this.calculateOverallProgress(currentPhase, phaseProgress); + + this.emit('execution-progress', taskId, { + phase: currentPhase, + phaseProgress, + overallProgress, + currentSubtask, + message: lastMessage + }); + } + }; + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + console.log('[spawnProcess] stdout:', log.substring(0, 200)); + this.emit('log', taskId, log); + processLog(log); + }); + + // Handle stderr + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + console.log('[spawnProcess] stderr:', log.substring(0, 200)); + // Some Python output goes to stderr (like progress bars) + // so we treat it as log, not error + this.emit('log', taskId, log); + processLog(log); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + console.log('[spawnProcess] Process exited with code:', code, 'spawnId:', spawnId); + this.processes.delete(taskId); + + // Check if this specific spawn was killed (vs exited naturally) + // If killed, don't emit exit event to prevent race condition with new process + if (this.killedSpawnIds.has(spawnId)) { + console.log('[spawnProcess] Process was killed, skipping exit event for spawnId:', spawnId); + this.killedSpawnIds.delete(spawnId); + return; + } + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allOutput); + if (rateLimitDetection.isRateLimited) { + console.log('[spawnProcess] Rate limit detected in task output:', { + taskId, + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + // Determine source type based on processType + const source = processType === 'spec-creation' ? 'task' : 'task'; + + // Emit rate limit event + const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { + taskId + }); + this.emit('sdk-rate-limit', rateLimitInfo); + } + } + + // Emit final progress + const finalPhase = code === 0 ? 'complete' : 'failed'; + this.emit('execution-progress', taskId, { + phase: finalPhase, + phaseProgress: 100, + overallProgress: code === 0 ? 100 : this.calculateOverallProgress(currentPhase, phaseProgress), + message: code === 0 ? 'Process completed successfully' : `Process exited with code ${code}` + }); + + this.emit('exit', taskId, code, processType); + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + console.log('[spawnProcess] Process error:', err.message); + this.processes.delete(taskId); + + this.emit('execution-progress', taskId, { + phase: 'failed', + phaseProgress: 0, + overallProgress: 0, + message: `Error: ${err.message}` + }); + + this.emit('error', taskId, err.message); + }); + } + + /** + * Kill a specific task's process + */ + killTask(taskId: string): boolean { + const agentProcess = this.processes.get(taskId); + if (agentProcess) { + try { + // Mark this specific spawn as killed so its exit handler knows to ignore + this.killedSpawnIds.add(agentProcess.spawnId); + + // Send SIGTERM first for graceful shutdown + agentProcess.process.kill('SIGTERM'); + + // Force kill after timeout + setTimeout(() => { + if (!agentProcess.process.killed) { + agentProcess.process.kill('SIGKILL'); + } + }, 5000); + + this.processes.delete(taskId); + return true; + } catch { + return false; + } + } + return false; + } + + /** + * Stop ideation generation for a project + */ + stopIdeation(projectId: string): boolean { + const wasRunning = this.isRunning(projectId); + if (wasRunning) { + this.killTask(projectId); + this.emit('ideation-stopped', projectId); + return true; + } + return false; + } + + /** + * Check if ideation is running for a project + */ + isIdeationRunning(projectId: string): boolean { + return this.isRunning(projectId); + } + + /** + * Kill all running processes + */ + async killAll(): Promise { + const killPromises = Array.from(this.processes.keys()).map((taskId) => { + return new Promise((resolve) => { + this.killTask(taskId); + resolve(); + }); + }); + await Promise.all(killPromises); + } + + /** + * Check if a task is running + */ + isRunning(taskId: string): boolean { + return this.processes.has(taskId); + } + + /** + * Get all running task IDs + */ + getRunningTasks(): string[] { + return Array.from(this.processes.keys()); + } +} diff --git a/auto-claude-ui/src/main/agent/agent-events.ts b/auto-claude-ui/src/main/agent/agent-events.ts new file mode 100644 index 00000000..42b40520 --- /dev/null +++ b/auto-claude-ui/src/main/agent/agent-events.ts @@ -0,0 +1,182 @@ +import { ExecutionProgressData } from './types'; + +/** + * Event handling and progress parsing logic + */ +export class AgentEvents { + /** + * Parse log output to detect execution phase transitions + */ + parseExecutionPhase( + log: string, + currentPhase: ExecutionProgressData['phase'], + isSpecRunner: boolean + ): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null { + const lowerLog = log.toLowerCase(); + + // Spec runner phase detection (all part of "planning") + if (isSpecRunner) { + if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) { + return { phase: 'planning', message: 'Discovering project context...' }; + } + if (lowerLog.includes('requirements') || lowerLog.includes('gathering')) { + return { phase: 'planning', message: 'Gathering requirements...' }; + } + if (lowerLog.includes('writing spec') || lowerLog.includes('spec writer')) { + return { phase: 'planning', message: 'Writing specification...' }; + } + if (lowerLog.includes('validating') || lowerLog.includes('validation')) { + return { phase: 'planning', message: 'Validating specification...' }; + } + if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) { + return { phase: 'planning', message: 'Specification complete' }; + } + } + + // Run.py phase detection + // Planner agent running + if (lowerLog.includes('planner agent') || lowerLog.includes('creating implementation plan')) { + return { phase: 'planning', message: 'Creating implementation plan...' }; + } + + // Coder agent running + if (lowerLog.includes('coder agent') || lowerLog.includes('starting coder')) { + return { phase: 'coding', message: 'Implementing code changes...' }; + } + + // Subtask progress detection + const subtaskMatch = log.match(/subtask[:\s]+(\d+(?:\/\d+)?|\w+[-_]\w+)/i); + if (subtaskMatch && currentPhase === 'coding') { + return { phase: 'coding', currentSubtask: subtaskMatch[1], message: `Working on subtask ${subtaskMatch[1]}...` }; + } + + // Subtask completion detection + if (lowerLog.includes('subtask completed') || lowerLog.includes('subtask done')) { + const completedSubtask = log.match(/subtask[:\s]+"?([^"]+)"?\s+completed/i); + return { + phase: 'coding', + currentSubtask: completedSubtask?.[1], + message: `Subtask ${completedSubtask?.[1] || ''} completed` + }; + } + + // QA Review phase + if (lowerLog.includes('qa reviewer') || lowerLog.includes('qa_reviewer') || lowerLog.includes('starting qa')) { + return { phase: 'qa_review', message: 'Running QA review...' }; + } + + // QA Fixer phase + if (lowerLog.includes('qa fixer') || lowerLog.includes('qa_fixer') || lowerLog.includes('fixing issues')) { + return { phase: 'qa_fixing', message: 'Fixing QA issues...' }; + } + + // Completion detection - be conservative, require explicit success markers + // The AI agent prints "=== BUILD COMPLETE ===" when truly done (from coder.md) + // Only trust this pattern, not generic "all subtasks completed" which could be false positive + if (lowerLog.includes('=== build complete ===') || lowerLog.includes('qa passed')) { + return { phase: 'complete', message: 'Build completed successfully' }; + } + + // "All subtasks completed" is informational - don't change phase based on this alone + // The coordinator may print this even when subtasks are blocked, so we stay in coding phase + // and let the actual implementation_plan.json status drive the UI + if (lowerLog.includes('all subtasks completed')) { + return { phase: 'coding', message: 'Subtasks marked complete' }; + } + + // Incomplete build detection - when coordinator exits with pending subtasks + if (lowerLog.includes('build incomplete') || lowerLog.includes('subtasks still pending')) { + return { phase: 'coding', message: 'Build paused - subtasks still pending' }; + } + + // Error/failure detection + if (lowerLog.includes('build failed') || lowerLog.includes('error:') || lowerLog.includes('fatal')) { + return { phase: 'failed', message: log.trim().substring(0, 200) }; + } + + return null; + } + + /** + * Calculate overall progress based on phase and phase progress + */ + calculateOverallProgress(phase: ExecutionProgressData['phase'], phaseProgress: number): number { + // Phase weight ranges (same as in constants.ts) + const weights: Record = { + idle: { start: 0, end: 0 }, + planning: { start: 0, end: 20 }, + coding: { start: 20, end: 80 }, + qa_review: { start: 80, end: 95 }, + qa_fixing: { start: 80, end: 95 }, + complete: { start: 100, end: 100 }, + failed: { start: 0, end: 0 } + }; + + const phaseWeight = weights[phase] || { start: 0, end: 0 }; + const phaseRange = phaseWeight.end - phaseWeight.start; + return Math.round(phaseWeight.start + (phaseRange * phaseProgress / 100)); + } + + /** + * Parse ideation progress from log output + */ + parseIdeationProgress( + log: string, + currentPhase: string, + currentProgress: number, + completedTypes: Set, + totalTypes: number + ): { phase: string; progress: number } { + let phase = currentPhase; + let progress = currentProgress; + + if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) { + phase = 'analyzing'; + progress = 10; + } else if (log.includes('CONTEXT GATHERING')) { + phase = 'discovering'; + progress = 20; + } else if (log.includes('GENERATING IDEAS (PARALLEL)') || (log.includes('Starting') && log.includes('ideation agents in parallel'))) { + phase = 'generating'; + progress = 30; + } else if (log.includes('MERGE') || log.includes('FINALIZE')) { + phase = 'finalizing'; + progress = 90; + } else if (log.includes('IDEATION COMPLETE')) { + phase = 'complete'; + progress = 100; + } + + // Update progress based on completed types during generation phase + if (phase === 'generating' && completedTypes.size > 0) { + // Progress from 30% to 90% based on completed types + progress = 30 + Math.floor((completedTypes.size / totalTypes) * 60); + } + + return { phase, progress }; + } + + /** + * Parse roadmap progress from log output + */ + parseRoadmapProgress(log: string, currentPhase: string, currentProgress: number): { phase: string; progress: number } { + let phase = currentPhase; + let progress = currentProgress; + + if (log.includes('PROJECT ANALYSIS')) { + phase = 'analyzing'; + progress = 20; + } else if (log.includes('PROJECT DISCOVERY')) { + phase = 'discovering'; + progress = 40; + } else if (log.includes('FEATURE GENERATION')) { + phase = 'generating'; + progress = 70; + } else if (log.includes('ROADMAP GENERATED')) { + phase = 'complete'; + progress = 100; + } + + return { phase, progress }; + } +} diff --git a/auto-claude-ui/src/main/agent/agent-manager.ts b/auto-claude-ui/src/main/agent/agent-manager.ts new file mode 100644 index 00000000..63a0c91c --- /dev/null +++ b/auto-claude-ui/src/main/agent/agent-manager.ts @@ -0,0 +1,229 @@ +import { EventEmitter } from 'events'; +import path from 'path'; +import { existsSync } from 'fs'; +import { AgentState } from './agent-state'; +import { AgentEvents } from './agent-events'; +import { AgentProcessManager } from './agent-process'; +import { AgentQueueManager } from './agent-queue'; +import { + AgentManagerEvents, + ExecutionProgressData, + ProcessType, + SpecCreationMetadata, + TaskExecutionOptions, + IdeationConfig +} from './types'; + +/** + * Main AgentManager - orchestrates agent process lifecycle + * This is a slim facade that delegates to focused modules + */ +export class AgentManager extends EventEmitter { + private state: AgentState; + private events: AgentEvents; + private processManager: AgentProcessManager; + private queueManager: AgentQueueManager; + + constructor() { + super(); + + // Initialize modular components + this.state = new AgentState(); + this.events = new AgentEvents(); + this.processManager = new AgentProcessManager(this.state, this.events, this); + this.queueManager = new AgentQueueManager(this.state, this.events, this.processManager, this); + } + + /** + * Configure paths for Python and auto-claude source + */ + configure(pythonPath?: string, autoBuildSourcePath?: string): void { + this.processManager.configure(pythonPath, autoBuildSourcePath); + } + + /** + * Start spec creation process + */ + startSpecCreation( + taskId: string, + projectPath: string, + taskDescription: string, + specDir?: string, + metadata?: SpecCreationMetadata + ): void { + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py'); + + if (!existsSync(specRunnerPath)) { + this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`); + return; + } + + // Get combined environment variables + const combinedEnv = this.processManager.getCombinedEnv(projectPath); + + // spec_runner.py will auto-start run.py after spec creation completes + const args = [specRunnerPath, '--task', taskDescription, '--project-dir', projectPath]; + + // Pass spec directory if provided (for UI-created tasks that already have a directory) + if (specDir) { + args.push('--spec-dir', specDir); + } + + // Check if user requires review before coding + if (!metadata?.requireReviewBeforeCoding) { + // Auto-approve: When user starts a task from the UI without requiring review + args.push('--auto-approve'); + } + + // Note: This is spec-creation but it chains to task-execution via run.py + this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + } + + /** + * Start task execution (run.py) + */ + startTaskExecution( + taskId: string, + projectPath: string, + specId: string, + options: TaskExecutionOptions = {} + ): void { + console.log('[AgentManager] startTaskExecution called for:', taskId, specId); + + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + console.log('[AgentManager] ERROR: Auto-build source path not found'); + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const runPath = path.join(autoBuildSource, 'run.py'); + console.log('[AgentManager] runPath:', runPath); + + if (!existsSync(runPath)) { + console.log('[AgentManager] ERROR: Run script not found at:', runPath); + this.emit('error', taskId, `Run script not found at: ${runPath}`); + return; + } + + // Get combined environment variables + const combinedEnv = this.processManager.getCombinedEnv(projectPath); + + const args = [runPath, '--spec', specId, '--project-dir', projectPath]; + + // Always use auto-continue when running from UI (non-interactive) + args.push('--auto-continue'); + + // Force: When user starts a task from the UI, that IS their approval + args.push('--force'); + + // Note: --parallel was removed from run.py CLI - parallel execution is handled internally by the agent + // The options.parallel and options.workers are kept for future use or logging purposes + + console.log('[AgentManager] Spawning process with args:', args); + this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution'); + } + + /** + * Start QA process + */ + startQAProcess( + taskId: string, + projectPath: string, + specId: string + ): void { + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emit('error', taskId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const runPath = path.join(autoBuildSource, 'run.py'); + + if (!existsSync(runPath)) { + this.emit('error', taskId, `Run script not found at: ${runPath}`); + return; + } + + // Get combined environment variables + const combinedEnv = this.processManager.getCombinedEnv(projectPath); + + const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa']; + + this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process'); + } + + /** + * Start roadmap generation process + */ + startRoadmapGeneration( + projectId: string, + projectPath: string, + refresh: boolean = false + ): void { + this.queueManager.startRoadmapGeneration(projectId, projectPath, refresh); + } + + /** + * Start ideation generation process + */ + startIdeationGeneration( + projectId: string, + projectPath: string, + config: IdeationConfig, + refresh: boolean = false + ): void { + this.queueManager.startIdeationGeneration(projectId, projectPath, config, refresh); + } + + /** + * Kill a specific task's process + */ + killTask(taskId: string): boolean { + return this.processManager.killProcess(taskId); + } + + /** + * Stop ideation generation for a project + */ + stopIdeation(projectId: string): boolean { + return this.queueManager.stopIdeation(projectId); + } + + /** + * Check if ideation is running for a project + */ + isIdeationRunning(projectId: string): boolean { + return this.queueManager.isIdeationRunning(projectId); + } + + /** + * Kill all running processes + */ + async killAll(): Promise { + await this.processManager.killAllProcesses(); + } + + /** + * Check if a task is running + */ + isRunning(taskId: string): boolean { + return this.state.hasProcess(taskId); + } + + /** + * Get all running task IDs + */ + getRunningTasks(): string[] { + return this.state.getRunningTaskIds(); + } +} diff --git a/auto-claude-ui/src/main/agent/agent-process.ts b/auto-claude-ui/src/main/agent/agent-process.ts new file mode 100644 index 00000000..b2579f2c --- /dev/null +++ b/auto-claude-ui/src/main/agent/agent-process.ts @@ -0,0 +1,372 @@ +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { app } from 'electron'; +import { EventEmitter } from 'events'; +import { AgentState } from './agent-state'; +import { AgentEvents } from './agent-events'; +import { ProcessType, ExecutionProgressData } from './types'; +import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; +import { projectStore } from '../project-store'; + +/** + * Process spawning and lifecycle management + */ +export class AgentProcessManager { + private state: AgentState; + private events: AgentEvents; + private emitter: EventEmitter; + private pythonPath: string = 'python3'; + private autoBuildSourcePath: string = ''; + + constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) { + this.state = state; + this.events = events; + this.emitter = emitter; + } + + /** + * Configure paths for Python and auto-claude source + */ + configure(pythonPath?: string, autoBuildSourcePath?: string): void { + if (pythonPath) { + this.pythonPath = pythonPath; + } + if (autoBuildSourcePath) { + this.autoBuildSourcePath = autoBuildSourcePath; + } + } + + /** + * Get the configured Python path + */ + getPythonPath(): string { + return this.pythonPath; + } + + /** + * Get the auto-claude source path (detects automatically if not configured) + */ + getAutoBuildSourcePath(): string | null { + // If manually configured, use that + if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { + return this.autoBuildSourcePath; + } + + // Auto-detect from app location + const possiblePaths = [ + // Dev mode: from dist/main -> ../../auto-claude (sibling to auto-claude-ui) + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + // Alternative: from app root + path.resolve(app.getAppPath(), '..', 'auto-claude'), + // If running from repo root + path.resolve(process.cwd(), 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; + } + + /** + * Get project-specific environment variables based on project settings + */ + private getProjectEnvVars(projectPath: string): Record { + const env: Record = {}; + + // Find project by path + const projects = projectStore.getProjects(); + const project = projects.find((p) => p.path === projectPath); + + if (project?.settings) { + // Graphiti MCP integration + if (project.settings.graphitiMcpEnabled) { + const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/'; + env['GRAPHITI_MCP_URL'] = graphitiUrl; + } + } + + return env; + } + + /** + * Load environment variables from auto-claude .env file + */ + loadAutoBuildEnv(): Record { + const autoBuildSource = this.getAutoBuildSourcePath(); + if (!autoBuildSource) { + console.log('[loadAutoBuildEnv] No auto-build source path found'); + return {}; + } + + const envPath = path.join(autoBuildSource, '.env'); + console.log('[loadAutoBuildEnv] Looking for .env at:', envPath); + if (!existsSync(envPath)) { + console.log('[loadAutoBuildEnv] .env file does not exist'); + return {}; + } + + try { + const envContent = readFileSync(envPath, 'utf-8'); + const envVars: Record = {}; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + envVars[key] = value; + } + } + + return envVars; + } catch { + return {}; + } + } + + /** + * Spawn a Python process for task execution + */ + spawnProcess( + taskId: string, + cwd: string, + args: string[], + extraEnv: Record = {}, + processType: ProcessType = 'task-execution' + ): void { + const isSpecRunner = processType === 'spec-creation'; + // Kill existing process for this task if any + this.killProcess(taskId); + + // Generate unique spawn ID for this process instance + const spawnId = this.state.generateSpawnId(); + + console.log('[spawnProcess] Spawning with pythonPath:', this.pythonPath); + console.log('[spawnProcess] cwd:', cwd); + console.log('[spawnProcess] processType:', processType); + console.log('[spawnProcess] spawnId:', spawnId); + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const profileEnv = getProfileEnv(); + + const childProcess = spawn(this.pythonPath, args, { + cwd, + env: { + ...process.env, + ...extraEnv, + ...profileEnv, // Include active Claude profile config + PYTHONUNBUFFERED: '1' // Ensure real-time output + } + }); + + console.log('[spawnProcess] Process spawned, pid:', childProcess.pid); + + this.state.addProcess(taskId, { + taskId, + process: childProcess, + startedAt: new Date(), + spawnId + }); + + // Track execution progress + let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning'; + let phaseProgress = 0; + let currentSubtask: string | undefined; + let lastMessage: string | undefined; + // Collect all output for rate limit detection + let allOutput = ''; + + // Emit initial progress + this.emitter.emit('execution-progress', taskId, { + phase: currentPhase, + phaseProgress: 0, + overallProgress: this.events.calculateOverallProgress(currentPhase, 0), + message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...' + }); + + const processLog = (log: string) => { + // Collect output for rate limit detection (keep last 10KB) + allOutput = (allOutput + log).slice(-10000); + // Parse for phase transitions + const phaseUpdate = this.events.parseExecutionPhase(log, currentPhase, isSpecRunner); + + if (phaseUpdate) { + const phaseChanged = phaseUpdate.phase !== currentPhase; + currentPhase = phaseUpdate.phase; + + if (phaseUpdate.currentSubtask) { + currentSubtask = phaseUpdate.currentSubtask; + } + if (phaseUpdate.message) { + lastMessage = phaseUpdate.message; + } + + // Reset phase progress on phase change, otherwise increment + if (phaseChanged) { + phaseProgress = 10; // Start new phase at 10% + } else { + phaseProgress = Math.min(90, phaseProgress + 5); // Increment within phase + } + + const overallProgress = this.events.calculateOverallProgress(currentPhase, phaseProgress); + + this.emitter.emit('execution-progress', taskId, { + phase: currentPhase, + phaseProgress, + overallProgress, + currentSubtask, + message: lastMessage + }); + } + }; + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + console.log('[spawnProcess] stdout:', log.substring(0, 200)); + this.emitter.emit('log', taskId, log); + processLog(log); + }); + + // Handle stderr + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + console.log('[spawnProcess] stderr:', log.substring(0, 200)); + // Some Python output goes to stderr (like progress bars) + // so we treat it as log, not error + this.emitter.emit('log', taskId, log); + processLog(log); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + console.log('[spawnProcess] Process exited with code:', code, 'spawnId:', spawnId); + this.state.deleteProcess(taskId); + + // Check if this specific spawn was killed (vs exited naturally) + // If killed, don't emit exit event to prevent race condition with new process + if (this.state.wasSpawnKilled(spawnId)) { + console.log('[spawnProcess] Process was killed, skipping exit event for spawnId:', spawnId); + this.state.clearKilledSpawn(spawnId); + return; + } + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allOutput); + if (rateLimitDetection.isRateLimited) { + console.log('[spawnProcess] Rate limit detected in task output:', { + taskId, + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + // Determine source type based on processType + const source = processType === 'spec-creation' ? 'task' : 'task'; + + // Emit rate limit event + const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { + taskId + }); + this.emitter.emit('sdk-rate-limit', rateLimitInfo); + } + } + + // Emit final progress + const finalPhase = code === 0 ? 'complete' : 'failed'; + this.emitter.emit('execution-progress', taskId, { + phase: finalPhase, + phaseProgress: 100, + overallProgress: code === 0 ? 100 : this.events.calculateOverallProgress(currentPhase, phaseProgress), + message: code === 0 ? 'Process completed successfully' : `Process exited with code ${code}` + }); + + this.emitter.emit('exit', taskId, code, processType); + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + console.log('[spawnProcess] Process error:', err.message); + this.state.deleteProcess(taskId); + + this.emitter.emit('execution-progress', taskId, { + phase: 'failed', + phaseProgress: 0, + overallProgress: 0, + message: `Error: ${err.message}` + }); + + this.emitter.emit('error', taskId, err.message); + }); + } + + /** + * Kill a specific task's process + */ + killProcess(taskId: string): boolean { + const agentProcess = this.state.getProcess(taskId); + if (agentProcess) { + try { + // Mark this specific spawn as killed so its exit handler knows to ignore + this.state.markSpawnAsKilled(agentProcess.spawnId); + + // Send SIGTERM first for graceful shutdown + agentProcess.process.kill('SIGTERM'); + + // Force kill after timeout + setTimeout(() => { + if (!agentProcess.process.killed) { + agentProcess.process.kill('SIGKILL'); + } + }, 5000); + + this.state.deleteProcess(taskId); + return true; + } catch { + return false; + } + } + return false; + } + + /** + * Kill all running processes + */ + async killAllProcesses(): Promise { + const killPromises = this.state.getRunningTaskIds().map((taskId) => { + return new Promise((resolve) => { + this.killProcess(taskId); + resolve(); + }); + }); + await Promise.all(killPromises); + } + + /** + * Get combined environment variables for a project + */ + getCombinedEnv(projectPath: string): Record { + const autoBuildEnv = this.loadAutoBuildEnv(); + const projectEnv = this.getProjectEnvVars(projectPath); + return { ...autoBuildEnv, ...projectEnv }; + } +} diff --git a/auto-claude-ui/src/main/agent/agent-queue.ts b/auto-claude-ui/src/main/agent/agent-queue.ts new file mode 100644 index 00000000..57863807 --- /dev/null +++ b/auto-claude-ui/src/main/agent/agent-queue.ts @@ -0,0 +1,447 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { EventEmitter } from 'events'; +import { AgentState } from './agent-state'; +import { AgentEvents } from './agent-events'; +import { AgentProcessManager } from './agent-process'; +import { IdeationConfig } from './types'; +import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; + +/** + * Queue management for ideation and roadmap generation + */ +export class AgentQueueManager { + private state: AgentState; + private events: AgentEvents; + private processManager: AgentProcessManager; + private emitter: EventEmitter; + + constructor( + state: AgentState, + events: AgentEvents, + processManager: AgentProcessManager, + emitter: EventEmitter + ) { + this.state = state; + this.events = events; + this.processManager = processManager; + this.emitter = emitter; + } + + /** + * Start roadmap generation process + */ + startRoadmapGeneration( + projectId: string, + projectPath: string, + refresh: boolean = false + ): void { + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emitter.emit('roadmap-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const roadmapRunnerPath = path.join(autoBuildSource, 'roadmap_runner.py'); + + if (!existsSync(roadmapRunnerPath)) { + this.emitter.emit('roadmap-error', projectId, `Roadmap runner not found at: ${roadmapRunnerPath}`); + return; + } + + const args = [roadmapRunnerPath, '--project', projectPath]; + + if (refresh) { + args.push('--refresh'); + } + + // Use projectId as taskId for roadmap operations + this.spawnRoadmapProcess(projectId, projectPath, args); + } + + /** + * Start ideation generation process + */ + startIdeationGeneration( + projectId: string, + projectPath: string, + config: IdeationConfig, + refresh: boolean = false + ): void { + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + + if (!autoBuildSource) { + this.emitter.emit('ideation-error', projectId, 'Auto-build source path not found. Please configure it in App Settings.'); + return; + } + + const ideationRunnerPath = path.join(autoBuildSource, 'ideation_runner.py'); + + if (!existsSync(ideationRunnerPath)) { + this.emitter.emit('ideation-error', projectId, `Ideation runner not found at: ${ideationRunnerPath}`); + return; + } + + const args = [ideationRunnerPath, '--project', projectPath]; + + // Add enabled types as comma-separated list + if (config.enabledTypes.length > 0) { + args.push('--types', config.enabledTypes.join(',')); + } + + // Add context flags (script uses --no-roadmap/--no-kanban negative flags) + if (!config.includeRoadmapContext) { + args.push('--no-roadmap'); + } + if (!config.includeKanbanContext) { + args.push('--no-kanban'); + } + + // Add max ideas per type + if (config.maxIdeasPerType) { + args.push('--max-ideas', config.maxIdeasPerType.toString()); + } + + if (refresh) { + args.push('--refresh'); + } + + // Add append flag to preserve existing ideas + if (config.append) { + args.push('--append'); + } + + // Use projectId as taskId for ideation operations + this.spawnIdeationProcess(projectId, projectPath, args); + } + + /** + * Spawn a Python process for ideation generation + */ + private spawnIdeationProcess( + projectId: string, + projectPath: string, + args: string[] + ): void { + // Kill existing process for this project if any + this.processManager.killProcess(projectId); + + // Generate unique spawn ID for this process instance + const spawnId = this.state.generateSpawnId(); + + // Run from auto-claude source directory so imports work correctly + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + const cwd = autoBuildSource || process.cwd(); + + // Get combined environment variables + const combinedEnv = this.processManager.getCombinedEnv(projectPath); + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const profileEnv = getProfileEnv(); + + // Get Python path from process manager (uses venv if configured) + const pythonPath = this.processManager.getPythonPath(); + + const childProcess = spawn(pythonPath, args, { + cwd, + env: { + ...process.env, + ...combinedEnv, + ...profileEnv, + PYTHONUNBUFFERED: '1' + } + }); + + this.state.addProcess(projectId, { + taskId: projectId, + process: childProcess, + startedAt: new Date(), + projectPath, // Store project path for loading session on completion + spawnId + }); + + // Track progress through output + let progressPhase = 'analyzing'; + let progressPercent = 10; + // Collect output for rate limit detection + let allOutput = ''; + + // Helper to emit logs - split multi-line output into individual log lines + const emitLogs = (log: string) => { + const lines = log.split('\n').filter(line => line.trim().length > 0); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + console.log('[Ideation]', trimmed); + this.emitter.emit('ideation-log', projectId, trimmed); + } + } + }; + + console.log('[Ideation] Starting ideation process with args:', args); + console.log('[Ideation] CWD:', cwd); + + // Track completed types for progress calculation + const completedTypes = new Set(); + const totalTypes = 7; // Default all types + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect output for rate limit detection (keep last 10KB) + allOutput = (allOutput + log).slice(-10000); + + // Emit all log lines for the activity log + emitLogs(log); + + // Check for streaming type completion signals + const typeCompleteMatch = log.match(/IDEATION_TYPE_COMPLETE:(\w+):(\d+)/); + if (typeCompleteMatch) { + const [, ideationType, ideasCount] = typeCompleteMatch; + completedTypes.add(ideationType); + console.log(`[Ideation] Type complete: ${ideationType} with ${ideasCount} ideas`); + + // Emit event for UI to load this type's ideas immediately + this.emitter.emit('ideation-type-complete', projectId, ideationType, parseInt(ideasCount, 10)); + } + + const typeFailedMatch = log.match(/IDEATION_TYPE_FAILED:(\w+)/); + if (typeFailedMatch) { + const [, ideationType] = typeFailedMatch; + completedTypes.add(ideationType); + console.log(`[Ideation] Type failed: ${ideationType}`); + this.emitter.emit('ideation-type-failed', projectId, ideationType); + } + + // Parse progress using AgentEvents + const progressUpdate = this.events.parseIdeationProgress( + log, + progressPhase, + progressPercent, + completedTypes, + totalTypes + ); + progressPhase = progressUpdate.phase; + progressPercent = progressUpdate.progress; + + // Emit progress update with a clean message for the status bar + const statusMessage = log.trim().split('\n')[0].substring(0, 200); + this.emitter.emit('ideation-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: statusMessage, + completedTypes: Array.from(completedTypes) + }); + }); + + // Handle stderr - also emit as logs + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect stderr for rate limit detection too + allOutput = (allOutput + log).slice(-10000); + console.error('[Ideation STDERR]', log); + emitLogs(log); + this.emitter.emit('ideation-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().split('\n')[0].substring(0, 200) + }); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + console.log('[Ideation] Process exited with code:', code); + + // Get the stored project path before deleting from map + const processInfo = this.state.getProcess(projectId); + const storedProjectPath = processInfo?.projectPath; + this.state.deleteProcess(projectId); + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allOutput); + if (rateLimitDetection.isRateLimited) { + const rateLimitInfo = createSDKRateLimitInfo('ideation', rateLimitDetection, { + projectId + }); + this.emitter.emit('sdk-rate-limit', rateLimitInfo); + } + } + + if (code === 0) { + this.emitter.emit('ideation-progress', projectId, { + phase: 'complete', + progress: 100, + message: 'Ideation generation complete' + }); + + // Load and emit the complete ideation session + if (storedProjectPath) { + try { + const ideationFilePath = path.join( + storedProjectPath, + '.auto-claude', + 'ideation', + 'ideation.json' + ); + if (existsSync(ideationFilePath)) { + const content = readFileSync(ideationFilePath, 'utf-8'); + const session = JSON.parse(content); + console.log('[Ideation] Emitting ideation-complete with session data'); + this.emitter.emit('ideation-complete', projectId, session); + } else { + console.warn('[Ideation] ideation.json not found at:', ideationFilePath); + } + } catch (err) { + console.error('[Ideation] Failed to load ideation session:', err); + } + } + } else { + this.emitter.emit('ideation-error', projectId, `Ideation generation failed with exit code ${code}`); + } + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + console.error('[Ideation] Process error:', err.message); + this.state.deleteProcess(projectId); + this.emitter.emit('ideation-error', projectId, err.message); + }); + } + + /** + * Spawn a Python process for roadmap generation + */ + private spawnRoadmapProcess( + projectId: string, + projectPath: string, + args: string[] + ): void { + // Kill existing process for this project if any + this.processManager.killProcess(projectId); + + // Generate unique spawn ID for this process instance + const spawnId = this.state.generateSpawnId(); + + // Run from auto-claude source directory so imports work correctly + const autoBuildSource = this.processManager.getAutoBuildSourcePath(); + const cwd = autoBuildSource || process.cwd(); + + // Get combined environment variables + const combinedEnv = this.processManager.getCombinedEnv(projectPath); + + // Get active Claude profile environment (CLAUDE_CONFIG_DIR if not default) + const profileEnv = getProfileEnv(); + + // Get Python path from process manager (uses venv if configured) + const pythonPath = this.processManager.getPythonPath(); + + const childProcess = spawn(pythonPath, args, { + cwd, + env: { + ...process.env, + ...combinedEnv, + ...profileEnv, + PYTHONUNBUFFERED: '1' + } + }); + + this.state.addProcess(projectId, { + taskId: projectId, + process: childProcess, + startedAt: new Date(), + spawnId + }); + + // Track progress through output + let progressPhase = 'analyzing'; + let progressPercent = 10; + // Collect output for rate limit detection + let allRoadmapOutput = ''; + + // Handle stdout + childProcess.stdout?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect output for rate limit detection (keep last 10KB) + allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); + + // Parse progress using AgentEvents + const progressUpdate = this.events.parseRoadmapProgress(log, progressPhase, progressPercent); + progressPhase = progressUpdate.phase; + progressPercent = progressUpdate.progress; + + // Emit progress update + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().substring(0, 200) // Truncate long messages + }); + }); + + // Handle stderr + childProcess.stderr?.on('data', (data: Buffer) => { + const log = data.toString(); + // Collect stderr for rate limit detection too + allRoadmapOutput = (allRoadmapOutput + log).slice(-10000); + this.emitter.emit('roadmap-progress', projectId, { + phase: progressPhase, + progress: progressPercent, + message: log.trim().substring(0, 200) + }); + }); + + // Handle process exit + childProcess.on('exit', (code: number | null) => { + this.state.deleteProcess(projectId); + + // Check for rate limit if process failed + if (code !== 0) { + const rateLimitDetection = detectRateLimit(allRoadmapOutput); + if (rateLimitDetection.isRateLimited) { + const rateLimitInfo = createSDKRateLimitInfo('roadmap', rateLimitDetection, { + projectId + }); + this.emitter.emit('sdk-rate-limit', rateLimitInfo); + } + } + + if (code === 0) { + this.emitter.emit('roadmap-progress', projectId, { + phase: 'complete', + progress: 100, + message: 'Roadmap generation complete' + }); + } else { + this.emitter.emit('roadmap-error', projectId, `Roadmap generation failed with exit code ${code}`); + } + }); + + // Handle process error + childProcess.on('error', (err: Error) => { + this.state.deleteProcess(projectId); + this.emitter.emit('roadmap-error', projectId, err.message); + }); + } + + /** + * Stop ideation generation for a project + */ + stopIdeation(projectId: string): boolean { + const wasRunning = this.state.hasProcess(projectId); + if (wasRunning) { + this.processManager.killProcess(projectId); + this.emitter.emit('ideation-stopped', projectId); + return true; + } + return false; + } + + /** + * Check if ideation is running for a project + */ + isIdeationRunning(projectId: string): boolean { + return this.state.hasProcess(projectId); + } +} diff --git a/auto-claude-ui/src/main/agent/agent-state.ts b/auto-claude-ui/src/main/agent/agent-state.ts new file mode 100644 index 00000000..d0781212 --- /dev/null +++ b/auto-claude-ui/src/main/agent/agent-state.ts @@ -0,0 +1,88 @@ +import { AgentProcess } from './types'; + +/** + * Agent state tracking and process map management + */ +export class AgentState { + private processes: Map = new Map(); + private killedSpawnIds: Set = new Set(); + private spawnCounter: number = 0; + + /** + * Generate a unique spawn ID + */ + generateSpawnId(): number { + return ++this.spawnCounter; + } + + /** + * Add a process to the tracking map + */ + addProcess(taskId: string, process: AgentProcess): void { + this.processes.set(taskId, process); + } + + /** + * Get a process by task ID + */ + getProcess(taskId: string): AgentProcess | undefined { + return this.processes.get(taskId); + } + + /** + * Remove a process from tracking + */ + deleteProcess(taskId: string): boolean { + return this.processes.delete(taskId); + } + + /** + * Check if a task has a running process + */ + hasProcess(taskId: string): boolean { + return this.processes.has(taskId); + } + + /** + * Get all running task IDs + */ + getRunningTaskIds(): string[] { + return Array.from(this.processes.keys()); + } + + /** + * Mark a spawn ID as killed + */ + markSpawnAsKilled(spawnId: number): void { + this.killedSpawnIds.add(spawnId); + } + + /** + * Check if a spawn ID was killed + */ + wasSpawnKilled(spawnId: number): boolean { + return this.killedSpawnIds.has(spawnId); + } + + /** + * Remove a spawn ID from killed set + */ + clearKilledSpawn(spawnId: number): void { + this.killedSpawnIds.delete(spawnId); + } + + /** + * Get all processes + */ + getAllProcesses(): Map { + return this.processes; + } + + /** + * Clear all state (for testing or cleanup) + */ + clear(): void { + this.processes.clear(); + this.killedSpawnIds.clear(); + } +} diff --git a/auto-claude-ui/src/main/agent/index.ts b/auto-claude-ui/src/main/agent/index.ts new file mode 100644 index 00000000..cb63a216 --- /dev/null +++ b/auto-claude-ui/src/main/agent/index.ts @@ -0,0 +1,28 @@ +/** + * Agent module - modular agent management system + * + * This module provides a clean separation of concerns for agent process management: + * - AgentManager: Main facade for orchestrating agent lifecycle + * - AgentState: Process tracking and state management + * - AgentEvents: Event handling and progress parsing + * - AgentProcessManager: Process spawning and lifecycle + * - AgentQueueManager: Ideation and roadmap queue management + */ + +export { AgentManager } from './agent-manager'; +export { AgentState } from './agent-state'; +export { AgentEvents } from './agent-events'; +export { AgentProcessManager } from './agent-process'; +export { AgentQueueManager } from './agent-queue'; + +export type { + AgentProcess, + ExecutionProgressData, + ProcessType, + AgentManagerEvents, + IdeationConfig, + TaskExecutionOptions, + SpecCreationMetadata, + IdeationProgressData, + RoadmapProgressData +} from './types'; diff --git a/auto-claude-ui/src/main/agent/types.ts b/auto-claude-ui/src/main/agent/types.ts new file mode 100644 index 00000000..527d484d --- /dev/null +++ b/auto-claude-ui/src/main/agent/types.ts @@ -0,0 +1,60 @@ +import { ChildProcess } from 'child_process'; + +/** + * Agent-specific types for process and state management + */ + +export interface AgentProcess { + taskId: string; + process: ChildProcess; + startedAt: Date; + projectPath?: string; // For ideation processes to load session on completion + spawnId: number; // Unique ID to identify this specific spawn +} + +export interface ExecutionProgressData { + phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed'; + phaseProgress: number; + overallProgress: number; + currentSubtask?: string; + message?: string; +} + +export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process'; + +export interface AgentManagerEvents { + log: (taskId: string, log: string) => void; + error: (taskId: string, error: string) => void; + exit: (taskId: string, code: number | null, processType: ProcessType) => void; + 'execution-progress': (taskId: string, progress: ExecutionProgressData) => void; +} + +export interface IdeationConfig { + enabledTypes: string[]; + includeRoadmapContext: boolean; + includeKanbanContext: boolean; + maxIdeasPerType: number; + append?: boolean; +} + +export interface TaskExecutionOptions { + parallel?: boolean; + workers?: number; +} + +export interface SpecCreationMetadata { + requireReviewBeforeCoding?: boolean; +} + +export interface IdeationProgressData { + phase: string; + progress: number; + message: string; + completedTypes?: string[]; +} + +export interface RoadmapProgressData { + phase: string; + progress: number; + message: string; +} diff --git a/auto-claude-ui/src/main/auto-claude-updater.ts b/auto-claude-ui/src/main/auto-claude-updater.ts index f26c82b4..0092032a 100644 --- a/auto-claude-ui/src/main/auto-claude-updater.ts +++ b/auto-claude-ui/src/main/auto-claude-updater.ts @@ -1,14 +1,20 @@ /** * Auto Claude Source Updater * - * Checks GitHub for updates to the auto-claude framework and downloads them. - * This allows users to get new auto-claude features without requiring a full app update. + * Checks GitHub Releases for updates and downloads them. + * GitHub Releases are the single source of truth for versioning. * * Update flow: - * 1. Check GitHub for latest VERSION file - * 2. Compare with bundled source version - * 3. If update available, download and replace bundled source + * 1. Check GitHub Releases API for the latest release + * 2. Compare release tag with current app version + * 3. If update available, download release tarball and apply * 4. Existing project update system handles pushing to individual projects + * + * Versioning: + * - Single source of truth: GitHub Releases + * - Current version: app.getVersion() (from package.json at build time) + * - Latest version: Fetched from GitHub Releases API + * - To release: Create a GitHub release with tag (e.g., v1.2.0) */ import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, statSync, copyFileSync } from 'fs'; @@ -27,10 +33,25 @@ const execAsync = promisify(exec); const GITHUB_CONFIG = { owner: 'AndyMik90', repo: 'Auto-Claude', - branch: 'main', - autoBuildPath: 'auto-claude' // Path within repo + autoBuildPath: 'auto-claude' // Path within repo where auto-claude lives }; +/** + * GitHub Release API response (partial) + */ +interface GitHubRelease { + tag_name: string; + name: string; + body: string; + tarball_url: string; + published_at: string; + prerelease: boolean; + draft: boolean; +} + +// Cache for the latest release info (used by download) +let cachedLatestRelease: GitHubRelease | null = null; + /** * Result of checking for updates */ @@ -96,35 +117,31 @@ function getUpdateCachePath(): string { } /** - * Read the current bundled version + * Get the current app/framework version + * + * Uses app.getVersion() (from package.json) as the single source of truth. + * Both the Electron app and auto-claude framework share the same version. */ export function getBundledVersion(): string { - const sourcePath = getBundledSourcePath(); - const versionFile = path.join(sourcePath, 'VERSION'); - - if (existsSync(versionFile)) { - return readFileSync(versionFile, 'utf-8').trim(); - } - - return '0.0.0'; + return app.getVersion(); } /** - * Fetch content from a URL using https + * Fetch JSON from a URL using https */ -function fetchUrl(url: string): Promise { +function fetchJson(url: string): Promise { return new Promise((resolve, reject) => { const request = https.get(url, { headers: { 'User-Agent': 'Auto-Claude-UI', - 'Accept': 'application/vnd.github.v3.raw' + 'Accept': 'application/vnd.github.v3+json' } }, (response) => { // Handle redirects if (response.statusCode === 301 || response.statusCode === 302) { const redirectUrl = response.headers.location; if (redirectUrl) { - fetchUrl(redirectUrl).then(resolve).catch(reject); + fetchJson(redirectUrl).then(resolve).catch(reject); return; } } @@ -136,7 +153,13 @@ function fetchUrl(url: string): Promise { let data = ''; response.on('data', chunk => data += chunk); - response.on('end', () => resolve(data)); + response.on('end', () => { + try { + resolve(JSON.parse(data) as T); + } catch (e) { + reject(new Error('Failed to parse JSON response')); + } + }); response.on('error', reject); }); @@ -149,37 +172,44 @@ function fetchUrl(url: string): Promise { } /** - * Check GitHub for the latest version + * Parse version from GitHub release tag + * Handles tags like "v1.2.0", "1.2.0", "v1.2.0-beta" + */ +function parseVersionFromTag(tag: string): string { + // Remove leading 'v' if present + return tag.replace(/^v/, ''); +} + +/** + * Check GitHub Releases for the latest version */ export async function checkForUpdates(): Promise { const currentVersion = getBundledVersion(); try { - // Fetch VERSION file from GitHub - const versionUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.autoBuildPath}/VERSION`; - const latestVersion = (await fetchUrl(versionUrl)).trim(); + // Fetch latest release from GitHub Releases API + const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`; + const release = await fetchJson(releaseUrl); + + // Cache for download function + cachedLatestRelease = release; + + // Parse version from tag (e.g., "v1.2.0" -> "1.2.0") + const latestVersion = parseVersionFromTag(release.tag_name); // Compare versions const updateAvailable = compareVersions(latestVersion, currentVersion) > 0; - // If update available, try to fetch release notes - let releaseNotes: string | undefined; - if (updateAvailable) { - try { - const changelogUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${GITHUB_CONFIG.autoBuildPath}/CHANGELOG.md`; - releaseNotes = await fetchUrl(changelogUrl); - } catch { - // Changelog is optional - } - } - return { updateAvailable, currentVersion, latestVersion, - releaseNotes + releaseNotes: release.body || undefined }; } catch (error) { + // Clear cache on error + cachedLatestRelease = null; + return { updateAvailable: false, currentVersion, @@ -276,7 +306,7 @@ function downloadFile( } /** - * Download and apply the latest auto-claude update + * Download and apply the latest auto-claude update from GitHub Releases * * Note: In production, this updates the bundled source in userData. * For packaged apps, we can't modify resourcesPath directly, @@ -290,7 +320,7 @@ export async function downloadAndApplyUpdate( try { onProgress?.({ stage: 'checking', - message: 'Checking for updates...' + message: 'Fetching release info...' }); // Ensure cache directory exists @@ -298,8 +328,17 @@ export async function downloadAndApplyUpdate( mkdirSync(cachePath, { recursive: true }); } - // Get download URL for the tarball - const tarballUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/tarball/${GITHUB_CONFIG.branch}`; + // Get release info (use cache or fetch fresh) + let release = cachedLatestRelease; + if (!release) { + const releaseUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/releases/latest`; + release = await fetchJson(releaseUrl); + cachedLatestRelease = release; + } + + // Use the release tarball URL + const tarballUrl = release.tarball_url; + const releaseVersion = parseVersionFromTag(release.tag_name); const tarballPath = path.join(cachePath, 'auto-claude-update.tar.gz'); const extractPath = path.join(cachePath, 'extracted'); @@ -405,21 +444,22 @@ export async function downloadAndApplyUpdate( copyDirectoryRecursive(autoBuildSource, targetPath, false); } - // Read the new version - const versionFile = path.join(targetPath, 'VERSION'); - const newVersion = existsSync(versionFile) - ? readFileSync(versionFile, 'utf-8').trim() - : 'unknown'; + // Use the release version we already have + const newVersion = releaseVersion; // Write update metadata const metadataPath = path.join(targetPath, '.update-metadata.json'); writeFileSync(metadataPath, JSON.stringify({ version: newVersion, updatedAt: new Date().toISOString(), - source: 'github', - branch: GITHUB_CONFIG.branch + source: 'github-release', + releaseTag: release.tag_name, + releaseName: release.name }, null, 2)); + // Clear the cache after successful update + cachedLatestRelease = null; + // Cleanup rmSync(tarballPath, { force: true }); rmSync(extractPath, { recursive: true, force: true }); diff --git a/auto-claude-ui/src/main/changelog-service.ts b/auto-claude-ui/src/main/changelog-service.ts index e33c00b7..51994e6e 100644 --- a/auto-claude-ui/src/main/changelog-service.ts +++ b/auto-claude-ui/src/main/changelog-service.ts @@ -1,1279 +1,16 @@ -import { EventEmitter } from 'events'; -import path from 'path'; -import os from 'os'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { spawn } from 'child_process'; -import { app } from 'electron'; -import { AUTO_BUILD_PATHS, DEFAULT_CHANGELOG_PATH } from '../shared/constants'; -import { execSync } from 'child_process'; -import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, getActiveProfileId } from './rate-limit-detector'; -import type { - ChangelogTask, - TaskSpecContent, - ChangelogGenerationRequest, - ChangelogGenerationResult, - ChangelogSaveRequest, - ChangelogSaveResult, - ChangelogGenerationProgress, - ExistingChangelog, - Task, - ImplementationPlan, - GitBranchInfo, - GitTagInfo, - GitCommit, - GitHistoryOptions, - BranchDiffOptions -} from '../shared/types'; - /** - * Service for generating changelogs from completed tasks + * Changelog Service - Re-export facade + * + * This file maintains backward compatibility by re-exporting from the modular changelog directory. + * The actual implementation has been split into focused modules: + * + * - changelog/changelog-service.ts: Main orchestrator + * - changelog/generator.ts: AI generation logic + * - changelog/parser.ts: Parsing and extraction + * - changelog/formatter.ts: Prompt building and formatting + * - changelog/git-integration.ts: Git operations + * - changelog/types.ts: Type definitions */ -export class ChangelogService extends EventEmitter { - private pythonPath: string = 'python3'; - private claudePath: string = 'claude'; - private autoBuildSourcePath: string = ''; - private generationProcesses: Map> = new Map(); - private cachedEnv: Record | null = null; - private debugEnabled: boolean | null = null; - constructor() { - super(); - this.detectClaudePath(); - this.debug('ChangelogService initialized'); - } - - /** - * Detect the full path to the claude CLI - * Electron apps don't inherit shell PATH, so we need to find it explicitly - */ - private detectClaudePath(): void { - const homeDir = os.homedir(); - - // Platform-specific possible paths - const possiblePaths = process.platform === 'win32' - ? [ - // Windows paths - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), - path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), - path.join(homeDir, '.local', 'bin', 'claude.exe'), - 'C:\\Program Files\\Claude\\claude.exe', - 'C:\\Program Files (x86)\\Claude\\claude.exe', - // Also check if claude is in system PATH - 'claude' - ] - : [ - // Unix paths (macOS/Linux) - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - path.join(homeDir, '.local/bin/claude'), - path.join(homeDir, 'bin/claude'), - // Also check if claude is in system PATH - 'claude' - ]; - - for (const claudePath of possiblePaths) { - if (claudePath === 'claude' || existsSync(claudePath)) { - this.claudePath = claudePath; - this.debug('Claude CLI found at:', claudePath); - return; - } - } - - this.debug('Claude CLI not found in common locations, using default'); - } - - /** - * Check if debug mode is enabled - * Checks DEBUG from auto-claude/.env and AUTO_CLAUDE_DEBUG from process.env - */ - private isDebugEnabled(): boolean { - // Cache the result after first check - if (this.debugEnabled !== null) { - return this.debugEnabled; - } - - // Check process.env first - if ( - process.env.DEBUG === 'true' || - process.env.DEBUG === '1' || - process.env.AUTO_CLAUDE_DEBUG === 'true' || - process.env.AUTO_CLAUDE_DEBUG === '1' - ) { - this.debugEnabled = true; - return true; - } - - // Check auto-claude .env file - const env = this.loadAutoBuildEnv(); - this.debugEnabled = env.DEBUG === 'true' || env.DEBUG === '1'; - return this.debugEnabled; - } - - /** - * Debug logging - only logs when DEBUG=true in auto-claude/.env or AUTO_CLAUDE_DEBUG is set - */ - private debug(...args: unknown[]): void { - if (this.isDebugEnabled()) { - console.log('[ChangelogService]', ...args); - } - } - - /** - * Configure paths for Python and auto-claude source - */ - configure(pythonPath?: string, autoBuildSourcePath?: string): void { - if (pythonPath) { - this.pythonPath = pythonPath; - } - if (autoBuildSourcePath) { - this.autoBuildSourcePath = autoBuildSourcePath; - } - } - - /** - * Get the auto-claude source path (detects automatically if not configured) - */ - private getAutoBuildSourcePath(): string | null { - if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { - return this.autoBuildSourcePath; - } - - const possiblePaths = [ - path.resolve(__dirname, '..', '..', '..', 'auto-claude'), - path.resolve(app.getAppPath(), '..', 'auto-claude'), - path.resolve(process.cwd(), 'auto-claude') - ]; - - for (const p of possiblePaths) { - if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { - return p; - } - } - return null; - } - - /** - * Load environment variables from auto-claude .env file - */ - private loadAutoBuildEnv(): Record { - const autoBuildSource = this.getAutoBuildSourcePath(); - if (!autoBuildSource) return {}; - - const envPath = path.join(autoBuildSource, '.env'); - if (!existsSync(envPath)) return {}; - - try { - const envContent = readFileSync(envPath, 'utf-8'); - const envVars: Record = {}; - - // Handle both Unix (\n) and Windows (\r\n) line endings - for (const line of envContent.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.substring(0, eqIndex).trim(); - let value = trimmed.substring(eqIndex + 1).trim(); - - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - - envVars[key] = value; - } - } - - return envVars; - } catch { - return {}; - } - } - - /** - * Get completed tasks from a project - * @param projectPath - Project root path - * @param tasks - List of tasks - * @param specsBaseDir - Base specs directory (e.g., 'auto-claude/specs' or 'dev/auto-claude/specs') - */ - getCompletedTasks(projectPath: string, tasks: Task[], specsBaseDir?: string): ChangelogTask[] { - const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR); - - return tasks - .filter(task => task.status === 'done' && !task.metadata?.archivedAt) - .map(task => { - const specDir = path.join(specsDir, task.specId); - const hasSpecs = existsSync(specDir) && existsSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE)); - - return { - id: task.id, - specId: task.specId, - title: task.title, - description: task.description, - completedAt: task.updatedAt, - hasSpecs - }; - }) - .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()); - } - - /** - * Load spec files for given tasks - * @param projectPath - Project root path - * @param taskIds - IDs of tasks to load specs for - * @param tasks - List of all tasks - * @param specsBaseDir - Base specs directory (e.g., 'auto-claude/specs' or 'dev/auto-claude/specs') - */ - async loadTaskSpecs(projectPath: string, taskIds: string[], tasks: Task[], specsBaseDir?: string): Promise { - const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR); - this.debug('loadTaskSpecs called', { projectPath, specsDir, taskCount: taskIds.length }); - - const results: TaskSpecContent[] = []; - - for (const taskId of taskIds) { - const task = tasks.find(t => t.id === taskId); - if (!task) { - this.debug('Task not found:', taskId); - continue; - } - - const specDir = path.join(specsDir, task.specId); - this.debug('Loading spec for task', { taskId, specId: task.specId, specDir }); - - const content: TaskSpecContent = { - taskId, - specId: task.specId - }; - - try { - // Load spec.md - const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); - if (existsSync(specPath)) { - content.spec = readFileSync(specPath, 'utf-8'); - this.debug('Loaded spec.md', { specId: task.specId, length: content.spec.length }); - } - - // Load requirements.json - const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); - if (existsSync(requirementsPath)) { - content.requirements = JSON.parse(readFileSync(requirementsPath, 'utf-8')); - } - - // Load qa_report.md - const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT); - if (existsSync(qaReportPath)) { - content.qaReport = readFileSync(qaReportPath, 'utf-8'); - } - - // Load implementation_plan.json - const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); - if (existsSync(planPath)) { - content.implementationPlan = JSON.parse(readFileSync(planPath, 'utf-8')) as ImplementationPlan; - } - } catch (error) { - content.error = error instanceof Error ? error.message : 'Failed to load spec files'; - this.debug('Error loading spec', { specId: task.specId, error: content.error }); - } - - results.push(content); - } - - this.debug('loadTaskSpecs complete', { loadedCount: results.length }); - return results; - } - - // ============================================ - // Git Data Retrieval Methods - // ============================================ - - /** - * Get list of branches for changelog git mode - */ - getBranches(projectPath: string): GitBranchInfo[] { - try { - // Get current branch - let currentBranch = ''; - try { - currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: projectPath, - encoding: 'utf-8' - }).trim(); - } catch { - // Ignore - might be in detached HEAD - } - - // Get all branches (local and remote) - const output = execSync('git branch -a --format="%(refname:short)|%(HEAD)"', { - cwd: projectPath, - encoding: 'utf-8' - }); - - const branches: GitBranchInfo[] = []; - const seenNames = new Set(); - - // Handle both Unix (\n) and Windows (\r\n) line endings - for (const line of output.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const [name, head] = trimmed.split('|'); - if (!name) continue; - - // Skip HEAD references - if (name === 'HEAD' || name.includes('HEAD')) continue; - - // Parse remote branches (origin/xxx) and mark as remote - const isRemote = name.startsWith('origin/') || name.includes('/'); - const displayName = isRemote ? name.replace(/^origin\//, '') : name; - - // Skip duplicates (prefer local over remote) - if (seenNames.has(displayName) && isRemote) continue; - seenNames.add(displayName); - - branches.push({ - name: displayName, - isRemote, - isCurrent: head === '*' || displayName === currentBranch - }); - } - - // Sort: current first, then local branches, then remote - return branches.sort((a, b) => { - if (a.isCurrent && !b.isCurrent) return -1; - if (!a.isCurrent && b.isCurrent) return 1; - if (!a.isRemote && b.isRemote) return -1; - if (a.isRemote && !b.isRemote) return 1; - return a.name.localeCompare(b.name); - }); - } catch (error) { - this.debug('Error getting branches:', error); - return []; - } - } - - /** - * Get list of tags for changelog git mode - */ - getTags(projectPath: string): GitTagInfo[] { - try { - // Get tags sorted by creation date (newest first) - const output = execSync( - 'git tag -l --sort=-creatordate --format="%(refname:short)|%(creatordate:iso-strict)|%(objectname:short)"', - { - cwd: projectPath, - encoding: 'utf-8' - } - ); - - const tags: GitTagInfo[] = []; - - // Handle both Unix (\n) and Windows (\r\n) line endings - for (const line of output.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const parts = trimmed.split('|'); - const name = parts[0]; - const date = parts[1] || undefined; - const commit = parts[2] || undefined; - - if (name) { - tags.push({ name, date, commit }); - } - } - - return tags; - } catch (error) { - this.debug('Error getting tags:', error); - return []; - } - } - - /** - * Get current branch name - */ - getCurrentBranch(projectPath: string): string { - try { - return execSync('git rev-parse --abbrev-ref HEAD', { - cwd: projectPath, - encoding: 'utf-8' - }).trim(); - } catch { - return 'main'; - } - } - - /** - * Get the default/main branch name - */ - getDefaultBranch(projectPath: string): string { - try { - // Try to get from origin/HEAD - const result = execSync('git rev-parse --abbrev-ref origin/HEAD', { - cwd: projectPath, - encoding: 'utf-8' - }).trim(); - return result.replace('origin/', ''); - } catch { - // Fallback: check if main or master exists - try { - execSync('git rev-parse --verify main', { - cwd: projectPath, - encoding: 'utf-8' - }); - return 'main'; - } catch { - try { - execSync('git rev-parse --verify master', { - cwd: projectPath, - encoding: 'utf-8' - }); - return 'master'; - } catch { - return 'main'; - } - } - } - } - - /** - * Get commits for git-history mode - */ - getCommits(projectPath: string, options: GitHistoryOptions): GitCommit[] { - try { - // Build the git log command based on options - const format = '%h|%H|%s|%an|%ae|%aI'; - let command = `git log --pretty=format:"${format}"`; - - // Add merge commit handling - if (!options.includeMergeCommits) { - command += ' --no-merges'; - } - - // Add range/filters based on type - switch (options.type) { - case 'recent': - command += ` -n ${options.count || 25}`; - break; - case 'since-date': - if (options.sinceDate) { - command += ` --since="${options.sinceDate}"`; - } - break; - case 'tag-range': - if (options.fromTag) { - const toRef = options.toTag || 'HEAD'; - command += ` ${options.fromTag}..${toRef}`; - } - break; - case 'since-version': - // Get all commits since the specified version/tag up to HEAD - if (options.fromTag) { - command += ` ${options.fromTag}..HEAD`; - } - break; - } - - this.debug('Getting commits with command:', command); - - const output = execSync(command, { - cwd: projectPath, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large histories - }); - - return this.parseGitLogOutput(output); - } catch (error) { - this.debug('Error getting commits:', error); - return []; - } - } - - /** - * Get commits between two branches (for branch-diff mode) - */ - getBranchDiffCommits(projectPath: string, options: BranchDiffOptions): GitCommit[] { - try { - const format = '%h|%H|%s|%an|%ae|%aI'; - // Get commits in compareBranch that are not in baseBranch - const command = `git log --pretty=format:"${format}" --no-merges ${options.baseBranch}..${options.compareBranch}`; - - this.debug('Getting branch diff commits with command:', command); - - const output = execSync(command, { - cwd: projectPath, - encoding: 'utf-8', - maxBuffer: 10 * 1024 * 1024 - }); - - return this.parseGitLogOutput(output); - } catch (error) { - this.debug('Error getting branch diff commits:', error); - return []; - } - } - - /** - * Parse git log output into GitCommit objects - */ - private parseGitLogOutput(output: string): GitCommit[] { - const commits: GitCommit[] = []; - - // Handle both Unix (\n) and Windows (\r\n) line endings - for (const line of output.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - - const parts = trimmed.split('|'); - if (parts.length < 6) continue; - - const [hash, fullHash, subject, author, authorEmail, date] = parts; - - commits.push({ - hash, - fullHash, - subject, - body: undefined, // We don't fetch body for performance - author, - authorEmail, - date - }); - } - - return commits; - } - - // ============================================ - // Changelog Generation - // ============================================ - - /** - * Generate changelog using Claude AI - * Supports multiple source modes: tasks (specs), git-history, or branch-diff - */ - generateChangelog( - projectId: string, - projectPath: string, - request: ChangelogGenerationRequest, - specs?: TaskSpecContent[] - ): void { - const sourceMode = request.sourceMode || 'tasks'; - - this.debug('generateChangelog called', { - projectId, - projectPath, - sourceMode, - taskCount: request.taskIds?.length || 0, - version: request.version, - format: request.format, - audience: request.audience - }); - - // Kill existing process if any - this.cancelGeneration(projectId); - - let prompt: string; - let itemCount: number; - - // Handle different source modes - if (sourceMode === 'git-history' && request.gitHistory) { - // Git history mode - this.emitProgress(projectId, { - stage: 'loading_commits', - progress: 10, - message: 'Loading commits from git history...' - }); - - const commits = this.getCommits(projectPath, request.gitHistory); - if (commits.length === 0) { - this.emitError(projectId, 'No commits found for the specified range'); - return; - } - - prompt = this.buildGitPrompt(request, commits); - itemCount = commits.length; - - } else if (sourceMode === 'branch-diff' && request.branchDiff) { - // Branch diff mode - this.emitProgress(projectId, { - stage: 'loading_commits', - progress: 10, - message: `Loading commits between ${request.branchDiff.baseBranch} and ${request.branchDiff.compareBranch}...` - }); - - const commits = this.getBranchDiffCommits(projectPath, request.branchDiff); - if (commits.length === 0) { - this.emitError(projectId, 'No commits found between the specified branches'); - return; - } - - prompt = this.buildGitPrompt(request, commits); - itemCount = commits.length; - - } else { - // Tasks mode (original behavior) - if (!specs || specs.length === 0) { - this.emitError(projectId, 'No specs provided for changelog generation'); - return; - } - - this.emitProgress(projectId, { - stage: 'loading_specs', - progress: 10, - message: 'Preparing changelog generation...' - }); - - prompt = this.buildChangelogPrompt(request, specs); - itemCount = specs.length; - } - this.debug('Prompt built', { - promptLength: prompt.length, - promptPreview: prompt.substring(0, 500) + '...' - }); - - // Use Claude Code SDK via subprocess - const autoBuildSource = this.getAutoBuildSourcePath(); - if (!autoBuildSource) { - this.debug('ERROR: Auto-build source path not found'); - this.emitError(projectId, 'Auto-build source path not found'); - return; - } - this.debug('Auto-build source path:', autoBuildSource); - - // Verify claude CLI is available - if (this.claudePath !== 'claude' && !existsSync(this.claudePath)) { - this.debug('ERROR: Claude CLI not found at:', this.claudePath); - this.emitError(projectId, `Claude CLI not found. Please ensure Claude Code is installed. Looked for: ${this.claudePath}`); - return; - } - this.debug('Using Claude CLI at:', this.claudePath); - - // Create a temporary Python script to call Claude - const script = this.createGenerationScript(prompt, request); - this.debug('Python script created', { scriptLength: script.length }); - - this.emitProgress(projectId, { - stage: 'generating', - progress: 30, - message: 'Generating changelog with Claude AI...' - }); - - const autoBuildEnv = this.loadAutoBuildEnv(); - this.debug('Environment loaded', { - hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN, - envKeys: Object.keys(autoBuildEnv) - }); - - const startTime = Date.now(); - this.debug('Spawning Python process...'); - - // Build environment with explicit critical variables - // Electron apps may not inherit shell environment correctly - const homeDir = os.homedir(); - const isWindows = process.platform === 'win32'; - - // Build PATH with platform-appropriate separator and locations - const pathAdditions = isWindows - ? [ - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude'), - path.join(homeDir, 'AppData', 'Roaming', 'npm'), - path.join(homeDir, '.local', 'bin'), - 'C:\\Program Files\\Claude', - 'C:\\Program Files (x86)\\Claude' - ] - : [ - '/usr/local/bin', - '/opt/homebrew/bin', - path.join(homeDir, '.local/bin'), - path.join(homeDir, 'bin') - ]; - - // Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR) - const profileEnv = getProfileEnv(); - this.debug('Active profile environment', { - hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, - hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR, - authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default') - }); - - const spawnEnv: Record = { - ...process.env as Record, - ...autoBuildEnv, - ...profileEnv, // Include active Claude profile config - // Ensure critical env vars are set for claude CLI - // Use USERPROFILE on Windows, HOME on Unix - ...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }), - USER: process.env.USER || process.env.USERNAME || 'user', - // Add common binary locations to PATH for claude CLI - PATH: [process.env.PATH || '', ...pathAdditions].filter(Boolean).join(path.delimiter), - PYTHONUNBUFFERED: '1' - }; - - this.debug('Spawn environment', { - HOME: spawnEnv.HOME, - USER: spawnEnv.USER, - pathDirs: spawnEnv.PATH?.split(':').length, - authMethod: spawnEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (spawnEnv.CLAUDE_CONFIG_DIR ? `config-dir:${spawnEnv.CLAUDE_CONFIG_DIR}` : 'default') - }); - - const childProcess = spawn(this.pythonPath, ['-c', script], { - cwd: autoBuildSource, - env: spawnEnv - }); - - this.generationProcesses.set(projectId, childProcess); - this.debug('Process spawned with PID:', childProcess.pid); - - let output = ''; - let errorOutput = ''; - - childProcess.stdout?.on('data', (data: Buffer) => { - const chunk = data.toString(); - output += chunk; - this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length }); - - this.emitProgress(projectId, { - stage: 'generating', - progress: 50, - message: 'Generating changelog content...' - }); - }); - - childProcess.stderr?.on('data', (data: Buffer) => { - const chunk = data.toString(); - errorOutput += chunk; - this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) }); - }); - - childProcess.on('exit', (code: number | null) => { - const duration = Date.now() - startTime; - this.debug('Process exited', { - code, - duration: `${duration}ms`, - outputLength: output.length, - errorLength: errorOutput.length - }); - - this.generationProcesses.delete(projectId); - - if (code === 0 && output.trim()) { - this.emitProgress(projectId, { - stage: 'formatting', - progress: 90, - message: 'Formatting changelog...' - }); - - // Extract changelog from output - const changelog = this.extractChangelog(output.trim()); - this.debug('Changelog extracted', { changelogLength: changelog.length }); - - this.emitProgress(projectId, { - stage: 'complete', - progress: 100, - message: 'Changelog generation complete' - }); - - const result: ChangelogGenerationResult = { - success: true, - changelog, - version: request.version, - tasksIncluded: itemCount - }; - - this.debug('Generation complete, emitting result'); - this.emit('generation-complete', projectId, result); - } else { - // Combine all output for error analysis - const combinedOutput = `${output}\n${errorOutput}`; - const error = errorOutput || `Generation failed with exit code ${code}`; - - // Check for rate limit - const rateLimitDetection = detectRateLimit(combinedOutput); - if (rateLimitDetection.isRateLimited) { - this.debug('Rate limit detected in changelog generation', { - resetTime: rateLimitDetection.resetTime, - limitType: rateLimitDetection.limitType, - suggestedProfile: rateLimitDetection.suggestedProfile?.name - }); - - // Emit rate limit event - const rateLimitInfo = createSDKRateLimitInfo('changelog', rateLimitDetection, { projectId }); - this.emit('rate-limit', projectId, rateLimitInfo); - } - - this.debug('Generation failed', { error: error.substring(0, 500), isRateLimited: rateLimitDetection.isRateLimited }); - this.emitError(projectId, error); - } - }); - - childProcess.on('error', (err: Error) => { - this.debug('Process error', { error: err.message }); - this.generationProcesses.delete(projectId); - this.emitError(projectId, err.message); - }); - } - - /** - * Extract the overview section from a spec (typically the first meaningful section) - */ - private extractSpecOverview(spec: string): string { - // Split into lines and find the Overview section - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = spec.split(/\r?\n/); - let inOverview = false; - let overview: string[] = []; - - for (const line of lines) { - // Start capturing at Overview heading - if (/^##\s*Overview/i.test(line)) { - inOverview = true; - continue; - } - // Stop at next major heading - if (inOverview && /^##\s/.test(line)) { - break; - } - if (inOverview && line.trim()) { - overview.push(line); - } - } - - // If no overview found, take first paragraph after title - if (overview.length === 0) { - const paragraphs = spec.split(/\n\n+/).filter(p => !p.startsWith('#') && p.trim().length > 20); - if (paragraphs.length > 0) { - return paragraphs[0].substring(0, 300); - } - } - - return overview.join(' ').substring(0, 400); - } - - /** - * Build the prompt for changelog generation - * Optimized to keep prompt size manageable for faster generation - */ - private buildChangelogPrompt( - request: ChangelogGenerationRequest, - specs: TaskSpecContent[] - ): string { - const audienceInstructions = { - 'technical': `You are a technical documentation specialist creating a changelog for developers. Use precise technical language.`, - 'user-facing': `You are a product manager writing release notes for end users. Use clear, non-technical language focusing on user benefits.`, - 'marketing': `You are a marketing specialist writing release notes. Focus on outcomes and user impact with compelling language.` - }; - - const formatInstructions = { - 'keep-a-changelog': `## [${request.version}] - ${request.date} - -### Added -- [New features] - -### Changed -- [Modifications] - -### Fixed -- [Bug fixes]`, - 'simple-list': `# Release v${request.version} (${request.date}) - -**New Features:** -- [List features] - -**Improvements:** -- [List improvements] - -**Bug Fixes:** -- [List fixes]`, - 'github-release': `## What's New in v${request.version} - -### New Features -- **Feature Name**: Description - -### Improvements -- Description - -### Bug Fixes -- Fixed [issue]` - }; - - // Build CONCISE task summaries (key to avoiding timeout) - const taskSummaries = specs.map(spec => { - const parts: string[] = [`- **${spec.specId}**`]; - - // Get workflow type if available - if (spec.implementationPlan?.workflow_type) { - parts.push(`(${spec.implementationPlan.workflow_type})`); - } - - // Extract just the overview/purpose - if (spec.spec) { - const overview = this.extractSpecOverview(spec.spec); - if (overview) { - parts.push(`: ${overview}`); - } - } - - return parts.join(''); - }).join('\n'); - - return `${audienceInstructions[request.audience]} - -Format: -${formatInstructions[request.format]} - -Completed tasks: -${taskSummaries} - -${request.customInstructions ? `Note: ${request.customInstructions}` : ''} - -CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases.`; - } - - /** - * Build the prompt for git-based changelog generation - * Categorizes commits by type (conventional commits) or keywords - */ - private buildGitPrompt( - request: ChangelogGenerationRequest, - commits: GitCommit[] - ): string { - const audienceInstructions = { - 'technical': `You are a technical documentation specialist creating a changelog for developers. Use precise technical language.`, - 'user-facing': `You are a product manager writing release notes for end users. Use clear, non-technical language focusing on user benefits.`, - 'marketing': `You are a marketing specialist writing release notes. Focus on outcomes and user impact with compelling language.` - }; - - const formatInstructions = { - 'keep-a-changelog': `## [${request.version}] - ${request.date} - -### Added -- [New features] - -### Changed -- [Modifications] - -### Fixed -- [Bug fixes]`, - 'simple-list': `# Release v${request.version} (${request.date}) - -**New Features:** -- [List features] - -**Improvements:** -- [List improvements] - -**Bug Fixes:** -- [List fixes]`, - 'github-release': `## What's New in v${request.version} - -### New Features -- **Feature Name**: Description - -### Improvements -- Description - -### Bug Fixes -- Fixed [issue]` - }; - - // Format commits for the prompt - // Group by conventional commit type if detected - const commitLines = commits.map(commit => { - const hash = commit.hash; - const subject = commit.subject; - // Detect conventional commit format: type(scope): message - const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/); - if (conventionalMatch) { - const [, type, scope, message] = conventionalMatch; - return `- ${hash}: [${type}${scope ? `/${scope}` : ''}] ${message}`; - } - return `- ${hash}: ${subject}`; - }).join('\n'); - - // Add context about branch/range if available - let sourceContext = ''; - if (request.branchDiff) { - sourceContext = `These commits are from branch "${request.branchDiff.compareBranch}" that are not in "${request.branchDiff.baseBranch}".`; - } else if (request.gitHistory) { - switch (request.gitHistory.type) { - case 'recent': - sourceContext = `These are the ${commits.length} most recent commits.`; - break; - case 'since-date': - sourceContext = `These are commits since ${request.gitHistory.sinceDate}.`; - break; - case 'tag-range': - sourceContext = `These are commits between tag "${request.gitHistory.fromTag}" and "${request.gitHistory.toTag || 'HEAD'}".`; - break; - } - } - - return `${audienceInstructions[request.audience]} - -${sourceContext} - -Generate a changelog from these git commits. Group related changes together and categorize them appropriately. - -Conventional commit types to recognize: -- feat/feature: New features → Added section -- fix/bugfix: Bug fixes → Fixed section -- docs: Documentation → Changed or separate Documentation section -- style: Styling/formatting → Changed section -- refactor: Code refactoring → Changed section -- perf: Performance → Changed or Performance section -- test: Tests → (usually omit unless significant) -- chore: Maintenance → (usually omit unless significant) - -Format: -${formatInstructions[request.format]} - -Git commits (${commits.length} total): -${commitLines} - -${request.customInstructions ? `Note: ${request.customInstructions}` : ''} - -CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases. Intelligently group and summarize related commits - don't just list each commit individually.`; - } - - /** - * Create Python script for Claude generation - */ - private createGenerationScript(prompt: string, _request: ChangelogGenerationRequest): string { - // Convert prompt to base64 to avoid any string escaping issues in Python - const base64Prompt = Buffer.from(prompt, 'utf-8').toString('base64'); - - // Escape the claude path for Python string - const escapedClaudePath = this.claudePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - - return ` -import subprocess -import sys -import base64 - -try: - # Decode the base64 prompt to avoid string escaping issues - prompt = base64.b64decode('${base64Prompt}').decode('utf-8') - - # Use Claude Code CLI to generate - # stdin=DEVNULL prevents hanging when claude checks for interactive input - result = subprocess.run( - ['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - timeout=300 - ) - - if result.returncode == 0: - print(result.stdout) - else: - # Print more detailed error info - print(f"Claude CLI error (code {result.returncode}):", file=sys.stderr) - if result.stderr: - print(result.stderr, file=sys.stderr) - if result.stdout: - print(f"stdout: {result.stdout}", file=sys.stderr) - sys.exit(1) -except Exception as e: - print(f"Python error: {type(e).__name__}: {e}", file=sys.stderr) - sys.exit(1) -`; - } - - /** - * Extract changelog content from Claude output - */ - private extractChangelog(output: string): string { - // Claude output should be the changelog directly - // Clean up any potential wrapper text - let changelog = output.trim(); - - // Find where the actual changelog starts (look for markdown heading) - // This handles cases where AI includes preamble like "I'll analyze..." or "Here's the changelog:" - const changelogStartPatterns = [ - /^(##\s*\[[\d.]+\])/m, // Keep-a-changelog: ## [1.0.0] - /^(##\s*What['']?s\s+New)/im, // GitHub release: ## What's New - /^(#\s*Release\s+v?[\d.]+)/im, // Simple: # Release v1.0.0 - /^(#\s*Changelog)/im, // # Changelog - /^(##\s*v?[\d.]+)/m // ## v1.0.0 or ## 1.0.0 - ]; - - for (const pattern of changelogStartPatterns) { - const match = changelog.match(pattern); - if (match && match.index !== undefined) { - // Found a changelog heading - extract from there - changelog = changelog.substring(match.index); - break; - } - } - - // Additional cleanup - remove common AI preambles if they somehow remain - const prefixes = [ - /^I['']ll\s+analyze[^#]*(?=#)/is, - /^I['']ll\s+generate[^#]*(?=#)/is, - /^Here['']s the changelog[:\s]*/i, - /^The changelog[:\s]*/i, - /^Changelog[:\s]*/i, - /^Based on[^#]*(?=#)/is, - /^Let me[^#]*(?=#)/is - ]; - - for (const prefix of prefixes) { - changelog = changelog.replace(prefix, ''); - } - - return changelog.trim(); - } - - /** - * Save changelog to file - */ - saveChangelog( - projectPath: string, - request: ChangelogSaveRequest - ): ChangelogSaveResult { - const filePath = request.filePath - ? path.join(projectPath, request.filePath) - : path.join(projectPath, DEFAULT_CHANGELOG_PATH); - - let finalContent = request.content; - - if (request.mode === 'prepend' && existsSync(filePath)) { - const existing = readFileSync(filePath, 'utf-8'); - // Add separator between new and existing content - finalContent = `${request.content}\n\n${existing}`; - } else if (request.mode === 'append' && existsSync(filePath)) { - const existing = readFileSync(filePath, 'utf-8'); - finalContent = `${existing}\n\n${request.content}`; - } - - writeFileSync(filePath, finalContent, 'utf-8'); - - return { - filePath, - bytesWritten: Buffer.byteLength(finalContent, 'utf-8') - }; - } - - /** - * Read existing changelog file - */ - readExistingChangelog(projectPath: string): ExistingChangelog { - const filePath = path.join(projectPath, DEFAULT_CHANGELOG_PATH); - - if (!existsSync(filePath)) { - return { exists: false }; - } - - try { - const content = readFileSync(filePath, 'utf-8'); - - // Try to extract last version using common patterns - const versionPatterns = [ - /##\s*\[(\d+\.\d+\.\d+)\]/, // Keep-a-changelog format - /v(\d+\.\d+\.\d+)/, // v1.2.3 format - /Version\s+(\d+\.\d+\.\d+)/i // Version 1.2.3 format - ]; - - let lastVersion: string | undefined; - for (const pattern of versionPatterns) { - const match = content.match(pattern); - if (match) { - lastVersion = match[1]; - break; - } - } - - return { - exists: true, - content, - lastVersion - }; - } catch (error) { - return { - exists: true, - error: error instanceof Error ? error.message : 'Failed to read changelog' - }; - } - } - - /** - * Suggest next version based on task types - */ - suggestVersion(specs: TaskSpecContent[], currentVersion?: string): string { - // Default starting version - if (!currentVersion) { - return '1.0.0'; - } - - const parts = currentVersion.split('.').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { - return '1.0.0'; - } - - let [major, minor, patch] = parts; - - // Analyze specs for version increment decision - let hasBreakingChanges = false; - let hasNewFeatures = false; - - for (const spec of specs) { - const content = (spec.spec || '').toLowerCase(); - - if (content.includes('breaking change') || content.includes('breaking:')) { - hasBreakingChanges = true; - } - - if (spec.implementationPlan?.workflow_type === 'new_feature' || - content.includes('new feature') || - content.includes('## added')) { - hasNewFeatures = true; - } - } - - if (hasBreakingChanges) { - return `${major + 1}.0.0`; - } else if (hasNewFeatures) { - return `${major}.${minor + 1}.0`; - } else { - return `${major}.${minor}.${patch + 1}`; - } - } - - /** - * Cancel ongoing generation - */ - cancelGeneration(projectId: string): boolean { - const process = this.generationProcesses.get(projectId); - if (process) { - process.kill('SIGTERM'); - this.generationProcesses.delete(projectId); - return true; - } - return false; - } - - /** - * Emit progress update - */ - private emitProgress(projectId: string, progress: ChangelogGenerationProgress): void { - this.emit('generation-progress', projectId, progress); - } - - /** - * Emit error - */ - private emitError(projectId: string, error: string): void { - this.emit('generation-progress', projectId, { - stage: 'error', - progress: 0, - message: error, - error - }); - this.emit('generation-error', projectId, error); - } -} - -// Export singleton instance -export const changelogService = new ChangelogService(); +export { ChangelogService, changelogService } from './changelog/changelog-service'; +export type { ChangelogConfig, PromptBuildOptions, VersionSuggestion, GenerationScriptParams } from './changelog/types'; diff --git a/auto-claude-ui/src/main/changelog/README.md b/auto-claude-ui/src/main/changelog/README.md new file mode 100644 index 00000000..b87c8553 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/README.md @@ -0,0 +1,99 @@ +# Changelog Module + +This directory contains the refactored changelog generation system, split into focused, maintainable modules. + +## Architecture + +The changelog service has been decomposed from a monolithic 1,279-line file into specialized modules: + +### Module Structure + +``` +changelog/ +├── changelog-service.ts # Main orchestrator (slim facade) +├── generator.ts # AI-powered changelog generation +├── parser.ts # Parsing and extraction logic +├── formatter.ts # Prompt building and formatting +├── git-integration.ts # Git operations (branches, tags, commits) +├── types.ts # Module-specific type definitions +└── index.ts # Clean module exports +``` + +### Responsibilities + +#### `changelog-service.ts` (Main Facade) +- Orchestrates all changelog operations +- Manages configuration and environment setup +- Delegates to specialized modules +- Provides public API for IPC handlers +- ~465 lines (down from 1,279) + +#### `generator.ts` (AI Generation) +- Handles Claude CLI subprocess spawning +- Manages generation lifecycle and progress +- Rate limit detection and error handling +- Environment configuration for subprocess +- ~340 lines + +#### `parser.ts` (Parsing & Extraction) +- Extract spec overviews +- Extract changelog from AI output +- Parse existing changelog files +- Parse git log output into structured data +- ~160 lines + +#### `formatter.ts` (Prompt Building) +- Build prompts for task-based changelogs +- Build prompts for git-based changelogs +- Format templates (keep-a-changelog, simple-list, github-release) +- Audience-specific instructions (technical, user-facing, marketing) +- Python script generation for Claude CLI +- ~190 lines + +#### `git-integration.ts` (Git Operations) +- Get branches (local and remote) +- Get tags with metadata +- Get current and default branch +- Get commits for various scenarios (recent, since-date, tag-range) +- Get branch diff commits +- ~230 lines + +#### `types.ts` (Type Definitions) +- Changelog-specific types +- Configuration interfaces +- Internal type definitions +- ~25 lines + +## Usage + +### Import the Service + +```typescript +import { changelogService } from './changelog-service'; +// or +import { changelogService } from './changelog'; +``` + +### Backward Compatibility + +The original `/src/main/changelog-service.ts` now serves as a re-export facade, maintaining full backward compatibility with existing code. + +## Benefits of Refactoring + +1. **Single Responsibility**: Each module has one clear purpose +2. **Easier Testing**: Smaller, focused modules are easier to test +3. **Better Maintainability**: Changes are isolated to relevant modules +4. **Reduced Complexity**: No single file exceeds 500 lines +5. **Improved Readability**: Clear separation of concerns +6. **Easier Navigation**: Developers can quickly find relevant code + +## Design Patterns Used + +- **Facade Pattern**: `changelog-service.ts` provides unified interface +- **Delegation**: Service delegates to specialized modules +- **Single Responsibility**: Each module has one clear concern +- **Event Emitter**: Generator emits progress and error events + +## Migration Notes + +No changes required for existing code - the refactoring maintains full API compatibility through the facade pattern. diff --git a/auto-claude-ui/src/main/changelog/changelog-service.ts b/auto-claude-ui/src/main/changelog/changelog-service.ts new file mode 100644 index 00000000..18cc4cf2 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/changelog-service.ts @@ -0,0 +1,479 @@ +import { EventEmitter } from 'events'; +import * as path from 'path'; +import * as os from 'os'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { app } from 'electron'; +import { AUTO_BUILD_PATHS, DEFAULT_CHANGELOG_PATH } from '../../shared/constants'; +import type { + ChangelogTask, + TaskSpecContent, + ChangelogGenerationRequest, + ChangelogSaveRequest, + ChangelogSaveResult, + ExistingChangelog, + Task, + ImplementationPlan, + GitBranchInfo, + GitTagInfo +} from '../../shared/types'; +import { ChangelogGenerator } from './generator'; +import { parseExistingChangelog } from './parser'; +import { + getBranches, + getTags, + getCurrentBranch, + getDefaultBranch, + getCommits, + getBranchDiffCommits +} from './git-integration'; + +/** + * Main changelog service - orchestrates all changelog operations + * Delegates to specialized modules for specific concerns + */ +export class ChangelogService extends EventEmitter { + private pythonPath: string = 'python3'; + private claudePath: string = 'claude'; + private autoBuildSourcePath: string = ''; + private cachedEnv: Record | null = null; + private debugEnabled: boolean | null = null; + private generator: ChangelogGenerator | null = null; + + constructor() { + super(); + this.detectClaudePath(); + this.debug('ChangelogService initialized'); + } + + /** + * Detect the full path to the claude CLI + * Electron apps don't inherit shell PATH, so we need to find it explicitly + */ + private detectClaudePath(): void { + const homeDir = os.homedir(); + + // Platform-specific possible paths + const possiblePaths = process.platform === 'win32' + ? [ + // Windows paths + path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), + path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + path.join(homeDir, '.local', 'bin', 'claude.exe'), + 'C:\\Program Files\\Claude\\claude.exe', + 'C:\\Program Files (x86)\\Claude\\claude.exe', + // Also check if claude is in system PATH + 'claude' + ] + : [ + // Unix paths (macOS/Linux) + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + path.join(homeDir, '.local/bin/claude'), + path.join(homeDir, 'bin/claude'), + // Also check if claude is in system PATH + 'claude' + ]; + + for (const claudePath of possiblePaths) { + if (claudePath === 'claude' || existsSync(claudePath)) { + this.claudePath = claudePath; + this.debug('Claude CLI found at:', claudePath); + return; + } + } + + this.debug('Claude CLI not found in common locations, using default'); + } + + /** + * Check if debug mode is enabled + * Checks DEBUG from auto-claude/.env and AUTO_CLAUDE_DEBUG from process.env + */ + private isDebugEnabled(): boolean { + // Cache the result after first check + if (this.debugEnabled !== null) { + return this.debugEnabled; + } + + // Check process.env first + if ( + process.env.DEBUG === 'true' || + process.env.DEBUG === '1' || + process.env.AUTO_CLAUDE_DEBUG === 'true' || + process.env.AUTO_CLAUDE_DEBUG === '1' + ) { + this.debugEnabled = true; + return true; + } + + // Check auto-claude .env file + const env = this.loadAutoBuildEnv(); + this.debugEnabled = env.DEBUG === 'true' || env.DEBUG === '1'; + return this.debugEnabled; + } + + /** + * Debug logging - only logs when DEBUG=true in auto-claude/.env or AUTO_CLAUDE_DEBUG is set + */ + private debug(...args: unknown[]): void { + if (this.isDebugEnabled()) { + console.log('[ChangelogService]', ...args); + } + } + + /** + * Configure paths for Python and auto-claude source + */ + configure(pythonPath?: string, autoBuildSourcePath?: string): void { + if (pythonPath) { + this.pythonPath = pythonPath; + } + if (autoBuildSourcePath) { + this.autoBuildSourcePath = autoBuildSourcePath; + } + } + + /** + * Get the auto-claude source path (detects automatically if not configured) + */ + private getAutoBuildSourcePath(): string | null { + if (this.autoBuildSourcePath && existsSync(this.autoBuildSourcePath)) { + return this.autoBuildSourcePath; + } + + const possiblePaths = [ + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + path.resolve(app.getAppPath(), '..', 'auto-claude'), + path.resolve(process.cwd(), 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; + } + + /** + * Load environment variables from auto-claude .env file + */ + private loadAutoBuildEnv(): Record { + const autoBuildSource = this.getAutoBuildSourcePath(); + if (!autoBuildSource) return {}; + + const envPath = path.join(autoBuildSource, '.env'); + if (!existsSync(envPath)) return {}; + + try { + const envContent = readFileSync(envPath, 'utf-8'); + const envVars: Record = {}; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + envVars[key] = value; + } + } + + return envVars; + } catch { + return {}; + } + } + + /** + * Get or create the generator instance + */ + private getGenerator(): ChangelogGenerator { + if (!this.generator) { + const autoBuildSource = this.getAutoBuildSourcePath(); + if (!autoBuildSource) { + throw new Error('Auto-build source path not found'); + } + + // Verify claude CLI is available + if (this.claudePath !== 'claude' && !existsSync(this.claudePath)) { + throw new Error(`Claude CLI not found. Please ensure Claude Code is installed. Looked for: ${this.claudePath}`); + } + + const autoBuildEnv = this.loadAutoBuildEnv(); + + this.generator = new ChangelogGenerator( + this.pythonPath, + this.claudePath, + autoBuildSource, + autoBuildEnv, + this.isDebugEnabled() + ); + + // Forward events from generator + this.generator.on('generation-complete', (projectId, result) => { + this.emit('generation-complete', projectId, result); + }); + + this.generator.on('generation-progress', (projectId, progress) => { + this.emit('generation-progress', projectId, progress); + }); + + this.generator.on('generation-error', (projectId, error) => { + this.emit('generation-error', projectId, error); + }); + + this.generator.on('rate-limit', (projectId, rateLimitInfo) => { + this.emit('rate-limit', projectId, rateLimitInfo); + }); + } + + return this.generator; + } + + // ============================================ + // Task Management + // ============================================ + + /** + * Get completed tasks from a project + */ + getCompletedTasks(projectPath: string, tasks: Task[], specsBaseDir?: string): ChangelogTask[] { + const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR); + + return tasks + .filter(task => task.status === 'done' && !task.metadata?.archivedAt) + .map(task => { + const specDir = path.join(specsDir, task.specId); + const hasSpecs = existsSync(specDir) && existsSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE)); + + return { + id: task.id, + specId: task.specId, + title: task.title, + description: task.description, + completedAt: task.updatedAt, + hasSpecs + }; + }) + .sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()); + } + + /** + * Load spec files for given tasks + */ + async loadTaskSpecs(projectPath: string, taskIds: string[], tasks: Task[], specsBaseDir?: string): Promise { + const specsDir = path.join(projectPath, specsBaseDir || AUTO_BUILD_PATHS.SPECS_DIR); + this.debug('loadTaskSpecs called', { projectPath, specsDir, taskCount: taskIds.length }); + + const results: TaskSpecContent[] = []; + + for (const taskId of taskIds) { + const task = tasks.find(t => t.id === taskId); + if (!task) { + this.debug('Task not found:', taskId); + continue; + } + + const specDir = path.join(specsDir, task.specId); + this.debug('Loading spec for task', { taskId, specId: task.specId, specDir }); + + const content: TaskSpecContent = { + taskId, + specId: task.specId + }; + + try { + // Load spec.md + const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + if (existsSync(specPath)) { + content.spec = readFileSync(specPath, 'utf-8'); + this.debug('Loaded spec.md', { specId: task.specId, length: content.spec.length }); + } + + // Load requirements.json + const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); + if (existsSync(requirementsPath)) { + content.requirements = JSON.parse(readFileSync(requirementsPath, 'utf-8')); + } + + // Load qa_report.md + const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT); + if (existsSync(qaReportPath)) { + content.qaReport = readFileSync(qaReportPath, 'utf-8'); + } + + // Load implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + if (existsSync(planPath)) { + content.implementationPlan = JSON.parse(readFileSync(planPath, 'utf-8')) as ImplementationPlan; + } + } catch (error) { + content.error = error instanceof Error ? error.message : 'Failed to load spec files'; + this.debug('Error loading spec', { specId: task.specId, error: content.error }); + } + + results.push(content); + } + + this.debug('loadTaskSpecs complete', { loadedCount: results.length }); + return results; + } + + // ============================================ + // Git Data Retrieval + // ============================================ + + getBranches(projectPath: string): GitBranchInfo[] { + return getBranches(projectPath, this.isDebugEnabled()); + } + + getTags(projectPath: string): GitTagInfo[] { + return getTags(projectPath, this.isDebugEnabled()); + } + + getCurrentBranch(projectPath: string): string { + return getCurrentBranch(projectPath); + } + + getDefaultBranch(projectPath: string): string { + return getDefaultBranch(projectPath); + } + + getCommits(projectPath: string, options: import('../../shared/types').GitHistoryOptions): import('../../shared/types').GitCommit[] { + return getCommits(projectPath, options, this.isDebugEnabled()); + } + + getBranchDiffCommits(projectPath: string, options: import('../../shared/types').BranchDiffOptions): import('../../shared/types').GitCommit[] { + return getBranchDiffCommits(projectPath, options, this.isDebugEnabled()); + } + + // ============================================ + // Changelog Generation + // ============================================ + + generateChangelog( + projectId: string, + projectPath: string, + request: ChangelogGenerationRequest, + specs?: TaskSpecContent[] + ): void { + try { + const generator = this.getGenerator(); + generator.generate(projectId, projectPath, request, specs); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to initialize generator'; + this.debug('ERROR:', errorMessage); + this.emit('generation-error', projectId, errorMessage); + } + } + + cancelGeneration(projectId: string): boolean { + if (this.generator) { + return this.generator.cancel(projectId); + } + return false; + } + + // ============================================ + // File Operations + // ============================================ + + /** + * Save changelog to file + */ + saveChangelog( + projectPath: string, + request: ChangelogSaveRequest + ): ChangelogSaveResult { + const filePath = request.filePath + ? path.join(projectPath, request.filePath) + : path.join(projectPath, DEFAULT_CHANGELOG_PATH); + + let finalContent = request.content; + + if (request.mode === 'prepend' && existsSync(filePath)) { + const existing = readFileSync(filePath, 'utf-8'); + // Add separator between new and existing content + finalContent = `${request.content}\n\n${existing}`; + } else if (request.mode === 'append' && existsSync(filePath)) { + const existing = readFileSync(filePath, 'utf-8'); + finalContent = `${existing}\n\n${request.content}`; + } + + writeFileSync(filePath, finalContent, 'utf-8'); + + return { + filePath, + bytesWritten: Buffer.byteLength(finalContent, 'utf-8') + }; + } + + /** + * Read existing changelog file + */ + readExistingChangelog(projectPath: string): ExistingChangelog { + const filePath = path.join(projectPath, DEFAULT_CHANGELOG_PATH); + + if (!existsSync(filePath)) { + return { exists: false }; + } + + return parseExistingChangelog(filePath); + } + + /** + * Suggest next version based on task types + */ + suggestVersion(specs: TaskSpecContent[], currentVersion?: string): string { + // Default starting version + if (!currentVersion) { + return '1.0.0'; + } + + const parts = currentVersion.split('.').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + return '1.0.0'; + } + + let [major, minor, patch] = parts; + + // Analyze specs for version increment decision + let hasBreakingChanges = false; + let hasNewFeatures = false; + + for (const spec of specs) { + const content = (spec.spec || '').toLowerCase(); + + if (content.includes('breaking change') || content.includes('breaking:')) { + hasBreakingChanges = true; + } + + if (spec.implementationPlan?.workflow_type === 'new_feature' || + content.includes('new feature') || + content.includes('## added')) { + hasNewFeatures = true; + } + } + + if (hasBreakingChanges) { + return `${major + 1}.0.0`; + } else if (hasNewFeatures) { + return `${major}.${minor + 1}.0`; + } else { + return `${major}.${minor}.${patch + 1}`; + } + } +} + +// Export singleton instance +export const changelogService = new ChangelogService(); diff --git a/auto-claude-ui/src/main/changelog/formatter.ts b/auto-claude-ui/src/main/changelog/formatter.ts new file mode 100644 index 00000000..cf3c7947 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/formatter.ts @@ -0,0 +1,210 @@ +import type { + ChangelogGenerationRequest, + TaskSpecContent, + GitCommit +} from '../../shared/types'; +import { extractSpecOverview } from './parser'; + +/** + * Format instructions for different changelog styles + */ +const FORMAT_TEMPLATES = { + 'keep-a-changelog': (version: string, date: string) => `## [${version}] - ${date} + +### Added +- [New features] + +### Changed +- [Modifications] + +### Fixed +- [Bug fixes]`, + + 'simple-list': (version: string, date: string) => `# Release v${version} (${date}) + +**New Features:** +- [List features] + +**Improvements:** +- [List improvements] + +**Bug Fixes:** +- [List fixes]`, + + 'github-release': (version: string) => `## What's New in v${version} + +### New Features +- **Feature Name**: Description + +### Improvements +- Description + +### Bug Fixes +- Fixed [issue]` +}; + +/** + * Audience-specific writing instructions + */ +const AUDIENCE_INSTRUCTIONS = { + 'technical': 'You are a technical documentation specialist creating a changelog for developers. Use precise technical language.', + 'user-facing': 'You are a product manager writing release notes for end users. Use clear, non-technical language focusing on user benefits.', + 'marketing': 'You are a marketing specialist writing release notes. Focus on outcomes and user impact with compelling language.' +}; + +/** + * Build changelog prompt from task specs + */ +export function buildChangelogPrompt( + request: ChangelogGenerationRequest, + specs: TaskSpecContent[] +): string { + const audienceInstruction = AUDIENCE_INSTRUCTIONS[request.audience]; + const formatInstruction = FORMAT_TEMPLATES[request.format](request.version, request.date); + + // Build CONCISE task summaries (key to avoiding timeout) + const taskSummaries = specs.map(spec => { + const parts: string[] = [`- **${spec.specId}**`]; + + // Get workflow type if available + if (spec.implementationPlan?.workflow_type) { + parts.push(`(${spec.implementationPlan.workflow_type})`); + } + + // Extract just the overview/purpose + if (spec.spec) { + const overview = extractSpecOverview(spec.spec); + if (overview) { + parts.push(`: ${overview}`); + } + } + + return parts.join(''); + }).join('\n'); + + return `${audienceInstruction} + +Format: +${formatInstruction} + +Completed tasks: +${taskSummaries} + +${request.customInstructions ? `Note: ${request.customInstructions}` : ''} + +CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases.`; +} + +/** + * Build changelog prompt from git commits + */ +export function buildGitPrompt( + request: ChangelogGenerationRequest, + commits: GitCommit[] +): string { + const audienceInstruction = AUDIENCE_INSTRUCTIONS[request.audience]; + const formatInstruction = FORMAT_TEMPLATES[request.format](request.version, request.date); + + // Format commits for the prompt + // Group by conventional commit type if detected + const commitLines = commits.map(commit => { + const hash = commit.hash; + const subject = commit.subject; + // Detect conventional commit format: type(scope): message + const conventionalMatch = subject.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/); + if (conventionalMatch) { + const [, type, scope, message] = conventionalMatch; + return `- ${hash}: [${type}${scope ? `/${scope}` : ''}] ${message}`; + } + return `- ${hash}: ${subject}`; + }).join('\n'); + + // Add context about branch/range if available + let sourceContext = ''; + if (request.branchDiff) { + sourceContext = `These commits are from branch "${request.branchDiff.compareBranch}" that are not in "${request.branchDiff.baseBranch}".`; + } else if (request.gitHistory) { + switch (request.gitHistory.type) { + case 'recent': + sourceContext = `These are the ${commits.length} most recent commits.`; + break; + case 'since-date': + sourceContext = `These are commits since ${request.gitHistory.sinceDate}.`; + break; + case 'tag-range': + sourceContext = `These are commits between tag "${request.gitHistory.fromTag}" and "${request.gitHistory.toTag || 'HEAD'}".`; + break; + } + } + + return `${audienceInstruction} + +${sourceContext} + +Generate a changelog from these git commits. Group related changes together and categorize them appropriately. + +Conventional commit types to recognize: +- feat/feature: New features → Added section +- fix/bugfix: Bug fixes → Fixed section +- docs: Documentation → Changed or separate Documentation section +- style: Styling/formatting → Changed section +- refactor: Code refactoring → Changed section +- perf: Performance → Changed or Performance section +- test: Tests → (usually omit unless significant) +- chore: Maintenance → (usually omit unless significant) + +Format: +${formatInstruction} + +Git commits (${commits.length} total): +${commitLines} + +${request.customInstructions ? `Note: ${request.customInstructions}` : ''} + +CRITICAL: Output ONLY the raw changelog content. Do NOT include ANY introductory text, analysis, or explanation. Start directly with the changelog heading (## or #). No "Here's the changelog" or similar phrases. Intelligently group and summarize related commits - don't just list each commit individually.`; +} + +/** + * Create Python script for Claude generation + */ +export function createGenerationScript(prompt: string, claudePath: string): string { + // Convert prompt to base64 to avoid any string escaping issues in Python + const base64Prompt = Buffer.from(prompt, 'utf-8').toString('base64'); + + // Escape the claude path for Python string + const escapedClaudePath = claudePath.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + + return ` +import subprocess +import sys +import base64 + +try: + # Decode the base64 prompt to avoid string escaping issues + prompt = base64.b64decode('${base64Prompt}').decode('utf-8') + + # Use Claude Code CLI to generate + # stdin=DEVNULL prevents hanging when claude checks for interactive input + result = subprocess.run( + ['${escapedClaudePath}', '-p', prompt, '--output-format', 'text', '--model', 'haiku'], + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=300 + ) + + if result.returncode == 0: + print(result.stdout) + else: + # Print more detailed error info + print(f"Claude CLI error (code {result.returncode}):", file=sys.stderr) + if result.stderr: + print(result.stderr, file=sys.stderr) + if result.stdout: + print(f"stdout: {result.stdout}", file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"Python error: {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(1) +`; +} diff --git a/auto-claude-ui/src/main/changelog/generator.ts b/auto-claude-ui/src/main/changelog/generator.ts new file mode 100644 index 00000000..72c88e5e --- /dev/null +++ b/auto-claude-ui/src/main/changelog/generator.ts @@ -0,0 +1,325 @@ +import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; +import * as path from 'path'; +import * as os from 'os'; +import type { + ChangelogGenerationRequest, + ChangelogGenerationResult, + ChangelogGenerationProgress, + TaskSpecContent +} from '../../shared/types'; +import { buildChangelogPrompt, buildGitPrompt, createGenerationScript } from './formatter'; +import { extractChangelog } from './parser'; +import { getCommits, getBranchDiffCommits } from './git-integration'; +import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector'; + +/** + * Core changelog generation logic + * Handles AI generation via Claude CLI subprocess + */ +export class ChangelogGenerator extends EventEmitter { + private generationProcesses: Map> = new Map(); + private debugEnabled: boolean; + + constructor( + private pythonPath: string, + private claudePath: string, + private autoBuildSourcePath: string, + private autoBuildEnv: Record, + debugEnabled: boolean + ) { + super(); + this.debugEnabled = debugEnabled; + } + + private debug(...args: unknown[]): void { + if (this.debugEnabled) { + console.log('[ChangelogGenerator]', ...args); + } + } + + /** + * Generate changelog using Claude AI + * Supports multiple source modes: tasks (specs), git-history, or branch-diff + */ + async generate( + projectId: string, + projectPath: string, + request: ChangelogGenerationRequest, + specs?: TaskSpecContent[] + ): Promise { + const sourceMode = request.sourceMode || 'tasks'; + + this.debug('generate called', { + projectId, + projectPath, + sourceMode, + taskCount: request.taskIds?.length || 0, + version: request.version, + format: request.format, + audience: request.audience + }); + + // Kill existing process if any + this.cancel(projectId); + + let prompt: string; + let itemCount: number; + + // Handle different source modes + if (sourceMode === 'git-history' && request.gitHistory) { + // Git history mode + this.emitProgress(projectId, { + stage: 'loading_commits', + progress: 10, + message: 'Loading commits from git history...' + }); + + const commits = getCommits(projectPath, request.gitHistory, this.debugEnabled); + if (commits.length === 0) { + this.emitError(projectId, 'No commits found for the specified range'); + return; + } + + prompt = buildGitPrompt(request, commits); + itemCount = commits.length; + + } else if (sourceMode === 'branch-diff' && request.branchDiff) { + // Branch diff mode + this.emitProgress(projectId, { + stage: 'loading_commits', + progress: 10, + message: `Loading commits between ${request.branchDiff.baseBranch} and ${request.branchDiff.compareBranch}...` + }); + + const commits = getBranchDiffCommits(projectPath, request.branchDiff, this.debugEnabled); + if (commits.length === 0) { + this.emitError(projectId, 'No commits found between the specified branches'); + return; + } + + prompt = buildGitPrompt(request, commits); + itemCount = commits.length; + + } else { + // Tasks mode (original behavior) + if (!specs || specs.length === 0) { + this.emitError(projectId, 'No specs provided for changelog generation'); + return; + } + + this.emitProgress(projectId, { + stage: 'loading_specs', + progress: 10, + message: 'Preparing changelog generation...' + }); + + prompt = buildChangelogPrompt(request, specs); + itemCount = specs.length; + } + + this.debug('Prompt built', { + promptLength: prompt.length, + promptPreview: prompt.substring(0, 500) + '...' + }); + + // Create Python script + const script = createGenerationScript(prompt, this.claudePath); + this.debug('Python script created', { scriptLength: script.length }); + + this.emitProgress(projectId, { + stage: 'generating', + progress: 30, + message: 'Generating changelog with Claude AI...' + }); + + const startTime = Date.now(); + this.debug('Spawning Python process...'); + + // Build environment with explicit critical variables + const spawnEnv = this.buildSpawnEnvironment(); + + const childProcess = spawn(this.pythonPath, ['-c', script], { + cwd: this.autoBuildSourcePath, + env: spawnEnv + }); + + this.generationProcesses.set(projectId, childProcess); + this.debug('Process spawned with PID:', childProcess.pid); + + let output = ''; + let errorOutput = ''; + + childProcess.stdout?.on('data', (data: Buffer) => { + const chunk = data.toString(); + output += chunk; + this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length }); + + this.emitProgress(projectId, { + stage: 'generating', + progress: 50, + message: 'Generating changelog content...' + }); + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + const chunk = data.toString(); + errorOutput += chunk; + this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) }); + }); + + childProcess.on('exit', (code: number | null) => { + const duration = Date.now() - startTime; + this.debug('Process exited', { + code, + duration: `${duration}ms`, + outputLength: output.length, + errorLength: errorOutput.length + }); + + this.generationProcesses.delete(projectId); + + if (code === 0 && output.trim()) { + this.emitProgress(projectId, { + stage: 'formatting', + progress: 90, + message: 'Formatting changelog...' + }); + + // Extract changelog from output + const changelog = extractChangelog(output.trim()); + this.debug('Changelog extracted', { changelogLength: changelog.length }); + + this.emitProgress(projectId, { + stage: 'complete', + progress: 100, + message: 'Changelog generation complete' + }); + + const result: ChangelogGenerationResult = { + success: true, + changelog, + version: request.version, + tasksIncluded: itemCount + }; + + this.debug('Generation complete, emitting result'); + this.emit('generation-complete', projectId, result); + } else { + // Combine all output for error analysis + const combinedOutput = `${output}\n${errorOutput}`; + const error = errorOutput || `Generation failed with exit code ${code}`; + + // Check for rate limit + const rateLimitDetection = detectRateLimit(combinedOutput); + if (rateLimitDetection.isRateLimited) { + this.debug('Rate limit detected in changelog generation', { + resetTime: rateLimitDetection.resetTime, + limitType: rateLimitDetection.limitType, + suggestedProfile: rateLimitDetection.suggestedProfile?.name + }); + + // Emit rate limit event + const rateLimitInfo = createSDKRateLimitInfo('changelog', rateLimitDetection, { projectId }); + this.emit('rate-limit', projectId, rateLimitInfo); + } + + this.debug('Generation failed', { error: error.substring(0, 500), isRateLimited: rateLimitDetection.isRateLimited }); + this.emitError(projectId, error); + } + }); + + childProcess.on('error', (err: Error) => { + this.debug('Process error', { error: err.message }); + this.generationProcesses.delete(projectId); + this.emitError(projectId, err.message); + }); + } + + /** + * Build spawn environment with proper PATH and auth settings + */ + private buildSpawnEnvironment(): Record { + const homeDir = os.homedir(); + const isWindows = process.platform === 'win32'; + + // Build PATH with platform-appropriate separator and locations + const pathAdditions = isWindows + ? [ + path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude'), + path.join(homeDir, 'AppData', 'Roaming', 'npm'), + path.join(homeDir, '.local', 'bin'), + 'C:\\Program Files\\Claude', + 'C:\\Program Files (x86)\\Claude' + ] + : [ + '/usr/local/bin', + '/opt/homebrew/bin', + path.join(homeDir, '.local', 'bin'), + path.join(homeDir, 'bin') + ]; + + // Get active Claude profile environment (OAuth token preferred, falls back to CLAUDE_CONFIG_DIR) + const profileEnv = getProfileEnv(); + this.debug('Active profile environment', { + hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, + hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR, + authMethod: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (profileEnv.CLAUDE_CONFIG_DIR ? 'config-dir' : 'default') + }); + + const spawnEnv: Record = { + ...process.env as Record, + ...this.autoBuildEnv, + ...profileEnv, // Include active Claude profile config + // Ensure critical env vars are set for claude CLI + // Use USERPROFILE on Windows, HOME on Unix + ...(isWindows ? { USERPROFILE: homeDir } : { HOME: homeDir }), + USER: process.env.USER || process.env.USERNAME || 'user', + // Add common binary locations to PATH for claude CLI + PATH: [process.env.PATH || '', ...pathAdditions].filter(Boolean).join(path.delimiter), + PYTHONUNBUFFERED: '1' + }; + + this.debug('Spawn environment', { + HOME: spawnEnv.HOME, + USER: spawnEnv.USER, + pathDirs: spawnEnv.PATH?.split(':').length, + authMethod: spawnEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'oauth-token' : (spawnEnv.CLAUDE_CONFIG_DIR ? `config-dir:${spawnEnv.CLAUDE_CONFIG_DIR}` : 'default') + }); + + return spawnEnv; + } + + /** + * Cancel ongoing generation + */ + cancel(projectId: string): boolean { + const process = this.generationProcesses.get(projectId); + if (process) { + process.kill('SIGTERM'); + this.generationProcesses.delete(projectId); + return true; + } + return false; + } + + /** + * Emit progress update + */ + private emitProgress(projectId: string, progress: ChangelogGenerationProgress): void { + this.emit('generation-progress', projectId, progress); + } + + /** + * Emit error + */ + private emitError(projectId: string, error: string): void { + this.emit('generation-progress', projectId, { + stage: 'error', + progress: 0, + message: error, + error + }); + this.emit('generation-error', projectId, error); + } +} diff --git a/auto-claude-ui/src/main/changelog/git-integration.ts b/auto-claude-ui/src/main/changelog/git-integration.ts new file mode 100644 index 00000000..f7e56cd3 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/git-integration.ts @@ -0,0 +1,253 @@ +import { execSync } from 'child_process'; +import type { + GitBranchInfo, + GitTagInfo, + GitCommit, + GitHistoryOptions, + BranchDiffOptions +} from '../../shared/types'; +import { parseGitLogOutput } from './parser'; + +/** + * Debug logging helper + */ +function debug(enabled: boolean, ...args: unknown[]): void { + if (enabled) { + console.log('[GitIntegration]', ...args); + } +} + +/** + * Get list of branches for changelog git mode + */ +export function getBranches(projectPath: string, debugEnabled = false): GitBranchInfo[] { + try { + // Get current branch + let currentBranch = ''; + try { + currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + encoding: 'utf-8' + }).trim(); + } catch { + // Ignore - might be in detached HEAD + } + + // Get all branches (local and remote) + const output = execSync('git branch -a --format="%(refname:short)|%(HEAD)"', { + cwd: projectPath, + encoding: 'utf-8' + }); + + const branches: GitBranchInfo[] = []; + const seenNames = new Set(); + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const [name, head] = trimmed.split('|'); + if (!name) continue; + + // Skip HEAD references + if (name === 'HEAD' || name.includes('HEAD')) continue; + + // Parse remote branches (origin/xxx) and mark as remote + const isRemote = name.startsWith('origin/') || name.includes('/'); + const displayName = isRemote ? name.replace(/^origin\//, '') : name; + + // Skip duplicates (prefer local over remote) + if (seenNames.has(displayName) && isRemote) continue; + seenNames.add(displayName); + + branches.push({ + name: displayName, + isRemote, + isCurrent: head === '*' || displayName === currentBranch + }); + } + + // Sort: current first, then local branches, then remote + return branches.sort((a, b) => { + if (a.isCurrent && !b.isCurrent) return -1; + if (!a.isCurrent && b.isCurrent) return 1; + if (!a.isRemote && b.isRemote) return -1; + if (a.isRemote && !b.isRemote) return 1; + return a.name.localeCompare(b.name); + }); + } catch (error) { + debug(debugEnabled, 'Error getting branches:', error); + return []; + } +} + +/** + * Get list of tags for changelog git mode + */ +export function getTags(projectPath: string, debugEnabled = false): GitTagInfo[] { + try { + // Get tags sorted by creation date (newest first) + const output = execSync( + 'git tag -l --sort=-creatordate --format="%(refname:short)|%(creatordate:iso-strict)|%(objectname:short)"', + { + cwd: projectPath, + encoding: 'utf-8' + } + ); + + const tags: GitTagInfo[] = []; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split('|'); + const name = parts[0]; + const date = parts[1] || undefined; + const commit = parts[2] || undefined; + + if (name) { + tags.push({ name, date, commit }); + } + } + + return tags; + } catch (error) { + debug(debugEnabled, 'Error getting tags:', error); + return []; + } +} + +/** + * Get current branch name + */ +export function getCurrentBranch(projectPath: string): string { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + encoding: 'utf-8' + }).trim(); + } catch { + return 'main'; + } +} + +/** + * Get the default/main branch name + */ +export function getDefaultBranch(projectPath: string): string { + try { + // Try to get from origin/HEAD + const result = execSync('git rev-parse --abbrev-ref origin/HEAD', { + cwd: projectPath, + encoding: 'utf-8' + }).trim(); + return result.replace('origin/', ''); + } catch { + // Fallback: check if main or master exists + try { + execSync('git rev-parse --verify main', { + cwd: projectPath, + encoding: 'utf-8' + }); + return 'main'; + } catch { + try { + execSync('git rev-parse --verify master', { + cwd: projectPath, + encoding: 'utf-8' + }); + return 'master'; + } catch { + return 'main'; + } + } + } +} + +/** + * Get commits for git-history mode + */ +export function getCommits( + projectPath: string, + options: GitHistoryOptions, + debugEnabled = false +): GitCommit[] { + try { + // Build the git log command based on options + const format = '%h|%H|%s|%an|%ae|%aI'; + let command = `git log --pretty=format:"${format}"`; + + // Add merge commit handling + if (!options.includeMergeCommits) { + command += ' --no-merges'; + } + + // Add range/filters based on type + switch (options.type) { + case 'recent': + command += ` -n ${options.count || 25}`; + break; + case 'since-date': + if (options.sinceDate) { + command += ` --since="${options.sinceDate}"`; + } + break; + case 'tag-range': + if (options.fromTag) { + const toRef = options.toTag || 'HEAD'; + command += ` ${options.fromTag}..${toRef}`; + } + break; + case 'since-version': + // Get all commits since the specified version/tag up to HEAD + if (options.fromTag) { + command += ` ${options.fromTag}..HEAD`; + } + break; + } + + debug(debugEnabled, 'Getting commits with command:', command); + + const output = execSync(command, { + cwd: projectPath, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large histories + }); + + return parseGitLogOutput(output); + } catch (error) { + debug(debugEnabled, 'Error getting commits:', error); + return []; + } +} + +/** + * Get commits between two branches (for branch-diff mode) + */ +export function getBranchDiffCommits( + projectPath: string, + options: BranchDiffOptions, + debugEnabled = false +): GitCommit[] { + try { + const format = '%h|%H|%s|%an|%ae|%aI'; + // Get commits in compareBranch that are not in baseBranch + const command = `git log --pretty=format:"${format}" --no-merges ${options.baseBranch}..${options.compareBranch}`; + + debug(debugEnabled, 'Getting branch diff commits with command:', command); + + const output = execSync(command, { + cwd: projectPath, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024 + }); + + return parseGitLogOutput(output); + } catch (error) { + debug(debugEnabled, 'Error getting branch diff commits:', error); + return []; + } +} diff --git a/auto-claude-ui/src/main/changelog/index.ts b/auto-claude-ui/src/main/changelog/index.ts new file mode 100644 index 00000000..88ec2d42 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/index.ts @@ -0,0 +1,18 @@ +/** + * Changelog module - clean exports + * + * Architecture: + * - changelog-service.ts: Main service facade (orchestrates all operations) + * - generator.ts: AI-powered changelog generation + * - parser.ts: Changelog and spec parsing logic + * - formatter.ts: Prompt building and formatting + * - git-integration.ts: Git operations (branches, tags, commits) + * - types.ts: Module-specific types + */ + +export { ChangelogService, changelogService } from './changelog-service'; +export { ChangelogGenerator } from './generator'; +export * from './parser'; +export * from './formatter'; +export * from './git-integration'; +export * from './types'; diff --git a/auto-claude-ui/src/main/changelog/parser.ts b/auto-claude-ui/src/main/changelog/parser.ts new file mode 100644 index 00000000..baabb1f4 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/parser.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'fs'; +import type { ExistingChangelog } from '../../shared/types'; + +/** + * Extract the overview section from a spec (typically the first meaningful section) + */ +export function extractSpecOverview(spec: string): string { + // Split into lines and find the Overview section + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = spec.split(/\r?\n/); + let inOverview = false; + let overview: string[] = []; + + for (const line of lines) { + // Start capturing at Overview heading + if (/^##\s*Overview/i.test(line)) { + inOverview = true; + continue; + } + // Stop at next major heading + if (inOverview && /^##\s/.test(line)) { + break; + } + if (inOverview && line.trim()) { + overview.push(line); + } + } + + // If no overview found, take first paragraph after title + if (overview.length === 0) { + const paragraphs = spec.split(/\n\n+/).filter(p => !p.startsWith('#') && p.trim().length > 20); + if (paragraphs.length > 0) { + return paragraphs[0].substring(0, 300); + } + } + + return overview.join(' ').substring(0, 400); +} + +/** + * Extract changelog content from Claude output + * Removes AI preambles and finds the actual changelog content + */ +export function extractChangelog(output: string): string { + // Claude output should be the changelog directly + // Clean up any potential wrapper text + let changelog = output.trim(); + + // Find where the actual changelog starts (look for markdown heading) + // This handles cases where AI includes preamble like "I'll analyze..." or "Here's the changelog:" + const changelogStartPatterns = [ + /^(##\s*\[[\d.]+\])/m, // Keep-a-changelog: ## [1.0.0] + /^(##\s*What['']?s\s+New)/im, // GitHub release: ## What's New + /^(#\s*Release\s+v?[\d.]+)/im, // Simple: # Release v1.0.0 + /^(#\s*Changelog)/im, // # Changelog + /^(##\s*v?[\d.]+)/m // ## v1.0.0 or ## 1.0.0 + ]; + + for (const pattern of changelogStartPatterns) { + const match = changelog.match(pattern); + if (match && match.index !== undefined) { + // Found a changelog heading - extract from there + changelog = changelog.substring(match.index); + break; + } + } + + // Additional cleanup - remove common AI preambles if they somehow remain + const prefixes = [ + /^I['']ll\s+analyze[^#]*(?=#)/is, + /^I['']ll\s+generate[^#]*(?=#)/is, + /^Here['']s the changelog[:\s]*/i, + /^The changelog[:\s]*/i, + /^Changelog[:\s]*/i, + /^Based on[^#]*(?=#)/is, + /^Let me[^#]*(?=#)/is + ]; + + for (const prefix of prefixes) { + changelog = changelog.replace(prefix, ''); + } + + return changelog.trim(); +} + +/** + * Parse existing changelog file and extract metadata + */ +export function parseExistingChangelog(filePath: string): ExistingChangelog { + try { + const content = readFileSync(filePath, 'utf-8'); + + // Try to extract last version using common patterns + const versionPatterns = [ + /##\s*\[(\d+\.\d+\.\d+)\]/, // Keep-a-changelog format + /v(\d+\.\d+\.\d+)/, // v1.2.3 format + /Version\s+(\d+\.\d+\.\d+)/i // Version 1.2.3 format + ]; + + let lastVersion: string | undefined; + for (const pattern of versionPatterns) { + const match = content.match(pattern); + if (match) { + lastVersion = match[1]; + break; + } + } + + return { + exists: true, + content, + lastVersion + }; + } catch (error) { + return { + exists: true, + error: error instanceof Error ? error.message : 'Failed to read changelog' + }; + } +} + +/** + * Parse git log output into GitCommit objects + */ +export function parseGitLogOutput(output: string): Array<{ + hash: string; + fullHash: string; + subject: string; + body?: string; + author: string; + authorEmail: string; + date: string; +}> { + const commits: Array<{ + hash: string; + fullHash: string; + subject: string; + body?: string; + author: string; + authorEmail: string; + date: string; + }> = []; + + // Handle both Unix (\n) and Windows (\r\n) line endings + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split('|'); + if (parts.length < 6) continue; + + const [hash, fullHash, subject, author, authorEmail, date] = parts; + + commits.push({ + hash, + fullHash, + subject, + body: undefined, // We don't fetch body for performance + author, + authorEmail, + date + }); + } + + return commits; +} diff --git a/auto-claude-ui/src/main/changelog/types.ts b/auto-claude-ui/src/main/changelog/types.ts new file mode 100644 index 00000000..b89fb898 --- /dev/null +++ b/auto-claude-ui/src/main/changelog/types.ts @@ -0,0 +1,30 @@ +/** + * Changelog-specific types + * These types extend the base types from shared/types.ts + */ + +export interface ChangelogConfig { + pythonPath: string; + claudePath: string; + autoBuildSourcePath: string; +} + +export interface PromptBuildOptions { + version: string; + date: string; + audience: 'technical' | 'user-facing' | 'marketing'; + format: 'keep-a-changelog' | 'simple-list' | 'github-release'; + customInstructions?: string; +} + +export interface VersionSuggestion { + suggested: string; + reason: string; + hasBreakingChanges: boolean; + hasNewFeatures: boolean; +} + +export interface GenerationScriptParams { + prompt: string; + claudePath: string; +} diff --git a/auto-claude-ui/src/main/claude-profile-manager.ts b/auto-claude-ui/src/main/claude-profile-manager.ts index b552352b..8cbb677c 100644 --- a/auto-claude-ui/src/main/claude-profile-manager.ts +++ b/auto-claude-ui/src/main/claude-profile-manager.ts @@ -776,7 +776,7 @@ export class ClaudeProfileManager { */ isProfileAuthenticated(profile: ClaudeProfile): boolean { const configDir = profile.configDir; - if (!existsSync(configDir)) { + if (!configDir || !existsSync(configDir)) { return false; } @@ -833,6 +833,11 @@ export class ClaudeProfileManager { return {}; } + // Only set CLAUDE_CONFIG_DIR if configDir is defined + if (!profile.configDir) { + return {}; + } + return { CLAUDE_CONFIG_DIR: profile.configDir }; diff --git a/auto-claude-ui/src/main/index.ts b/auto-claude-ui/src/main/index.ts index f021ca46..03b27cfe 100644 --- a/auto-claude-ui/src/main/index.ts +++ b/auto-claude-ui/src/main/index.ts @@ -1,8 +1,8 @@ import { app, BrowserWindow, shell, nativeImage } from 'electron'; import { join } from 'path'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; -import { setupIpcHandlers } from './ipc-handlers'; -import { AgentManager } from './agent-manager'; +import { setupIpcHandlers } from './ipc-setup'; +import { AgentManager } from './agent'; import { TerminalManager } from './terminal-manager'; import { pythonEnvManager } from './python-env-manager'; diff --git a/auto-claude-ui/src/main/ipc-handlers.ts b/auto-claude-ui/src/main/ipc-handlers.ts.backup similarity index 99% rename from auto-claude-ui/src/main/ipc-handlers.ts rename to auto-claude-ui/src/main/ipc-handlers.ts.backup index 9dd95191..b0fd5db5 100644 --- a/auto-claude-ui/src/main/ipc-handlers.ts +++ b/auto-claude-ui/src/main/ipc-handlers.ts.backup @@ -53,7 +53,7 @@ import type { } from '../shared/types'; import { projectStore } from './project-store'; import { fileWatcher } from './file-watcher'; -import { AgentManager } from './agent-manager'; +import { AgentManager } from './agent'; import { TerminalManager } from './terminal-manager'; import { getClaudeProfileManager } from './claude-profile-manager'; import { @@ -119,7 +119,15 @@ export function setupIpcHandlers( ipcMain.handle( IPC_CHANNELS.PROJECT_LIST, async (): Promise> => { + // Validate that .auto-claude folders still exist for all projects + // If a folder was deleted, reset autoBuildPath so UI prompts for reinitialization + const resetIds = projectStore.validateProjects(); + if (resetIds.length > 0) { + console.log('[IPC] PROJECT_LIST: Detected missing .auto-claude folders for', resetIds.length, 'project(s)'); + } + const projects = projectStore.getProjects(); + console.log('[IPC] PROJECT_LIST returning', projects.length, 'projects'); return { success: true, data: projects }; } ); @@ -377,7 +385,9 @@ export function setupIpcHandlers( ipcMain.handle( IPC_CHANNELS.TASK_LIST, async (_, projectId: string): Promise> => { + console.log('[IPC] TASK_LIST called with projectId:', projectId); const tasks = projectStore.getTasks(projectId); + console.log('[IPC] TASK_LIST returning', tasks.length, 'tasks'); return { success: true, data: tasks }; } ); @@ -2710,7 +2720,7 @@ export function setupIpcHandlers( } }); - agentManager.on('exit', (taskId: string, code: number | null, processType: import('./agent-manager').ProcessType) => { + agentManager.on('exit', (taskId: string, code: number | null, processType: import('./agent').ProcessType) => { const mainWindow = getMainWindow(); if (mainWindow) { // Stop file watcher @@ -2793,7 +2803,7 @@ export function setupIpcHandlers( } }); - agentManager.on('execution-progress', (taskId: string, progress: import('./agent-manager').ExecutionProgressData) => { + agentManager.on('execution-progress', (taskId: string, progress: import('./agent').ExecutionProgressData) => { const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress); @@ -6200,9 +6210,8 @@ ${idea.rationale} const metadataPath = path.join(specDir, 'task_metadata.json'); writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); - // Start spec creation to fill in the details - pass the existing spec directory - // so spec_runner uses it instead of creating a new pending folder - agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + // Task is created in Planning (backlog) - user must manually start it + // Previously auto-started spec creation here, but user should control when to start // Create task object to return const task: Task = { diff --git a/auto-claude-ui/src/main/ipc-handlers/README.md b/auto-claude-ui/src/main/ipc-handlers/README.md new file mode 100644 index 00000000..6ae86748 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/README.md @@ -0,0 +1,249 @@ +# IPC Handlers - Modular Architecture + +This directory contains the refactored IPC (Inter-Process Communication) handlers for Auto Claude UI, organized into domain-specific modules for better maintainability and code organization. + +## Overview + +The original monolithic `ipc-handlers.ts` file (6,913 lines, 220KB) has been refactored into 16 focused modules, each handling a specific domain of the application. + +## Module Structure + +### Core Modules + +#### `project-handlers.ts` (10KB) +Handles project lifecycle and Python environment management: +- `PROJECT_ADD` - Add new project to workspace +- `PROJECT_REMOVE` - Remove project +- `PROJECT_LIST` - List all projects +- `PROJECT_UPDATE_SETTINGS` - Update project settings +- `PROJECT_INITIALIZE` - Initialize .auto-claude directory +- `PROJECT_CHECK_VERSION` - Check initialization status +- `project:has-local-source` - Check if project has local auto-claude source +- Python environment initialization and status events + +#### `task-handlers.ts` (52KB) - Largest module +Manages task lifecycle and execution: +- `TASK_LIST` - List tasks for project +- `TASK_CREATE` - Create new task with auto-generated title +- `TASK_DELETE` - Delete task +- `TASK_UPDATE` - Update task properties +- `TASK_START` - Start task execution +- `TASK_STOP` - Stop running task +- `TASK_REVIEW` - Review task results +- `TASK_UPDATE_STATUS` - Update task status +- `TASK_RECOVER_STUCK` - Recover stuck tasks +- `TASK_CHECK_RUNNING` - Check if task is running +- `TASK_ARCHIVE` / `TASK_UNARCHIVE` - Archive management +- Worktree operations (status, diff, merge, discard) +- Task logs (get, watch, unwatch) + +#### `terminal-handlers.ts` (16KB) +Terminal and Claude profile management: +- `TERMINAL_CREATE` - Create terminal session +- `TERMINAL_DESTROY` - Destroy terminal +- `TERMINAL_INPUT` - Send input to terminal +- `TERMINAL_RESIZE` - Resize terminal +- `TERMINAL_INVOKE_CLAUDE` - Invoke Claude in terminal +- Claude profile management (CRUD operations) +- Profile auto-switching and usage tracking +- Terminal session persistence and restoration + +#### `settings-handlers.ts` (6.3KB) +Application settings and dialogs: +- `SETTINGS_GET` - Get app settings +- `SETTINGS_SAVE` - Save app settings +- `DIALOG_SELECT_DIRECTORY` - Directory selection dialog +- `DIALOG_CREATE_PROJECT_FOLDER` - Create project folder +- `DIALOG_GET_DEFAULT_PROJECT_LOCATION` - Get default location +- `APP_VERSION` - Get application version + +#### `file-handlers.ts` (2.0KB) +File system operations: +- `FILE_EXPLORER_LIST` - List directory contents with filtering + +### Feature Modules + +#### `roadmap-handlers.ts` (12KB) +Roadmap generation and management: +- `ROADMAP_GET` - Get project roadmap +- `ROADMAP_GENERATE` - Generate roadmap with AI +- `ROADMAP_REFRESH` - Refresh roadmap +- `ROADMAP_UPDATE_FEATURE` - Update feature status +- `ROADMAP_CONVERT_TO_SPEC` - Convert feature to task spec + +#### `ideation-handlers.ts` (22KB) +AI-powered ideation system: +- `IDEATION_GET` - Get ideation session +- `IDEATION_GENERATE` - Generate ideas with AI +- `IDEATION_REFRESH` - Refresh ideas +- `IDEATION_STOP` - Stop generation +- `IDEATION_UPDATE_IDEA` - Update idea +- `IDEATION_CONVERT_TO_TASK` - Convert idea to task +- `IDEATION_DISMISS` / `IDEATION_DISMISS_ALL` - Dismiss ideas + +#### `insights-handlers.ts` (9.4KB) +AI insights chat system: +- `INSIGHTS_GET_SESSION` - Get chat session +- `INSIGHTS_SEND_MESSAGE` - Send chat message +- `INSIGHTS_CLEAR_SESSION` - Clear session +- `INSIGHTS_CREATE_TASK` - Create task from insights +- Session management (list, new, switch, delete, rename) + +#### `changelog-handlers.ts` (8.2KB) +Changelog generation: +- `CHANGELOG_GET_DONE_TASKS` - Get completed tasks +- `CHANGELOG_LOAD_TASK_SPECS` - Load task specifications +- `CHANGELOG_GENERATE` - Generate changelog with AI +- `CHANGELOG_SAVE` - Save changelog +- `CHANGELOG_READ_EXISTING` - Read existing changelog +- `CHANGELOG_SUGGEST_VERSION` - Suggest version number +- Git operations (branches, tags, commits) + +#### `context-handlers.ts` (20KB) +Project context and memory: +- `CONTEXT_GET` - Get project context +- `CONTEXT_REFRESH_INDEX` - Refresh project index +- `CONTEXT_MEMORY_STATUS` - Get Graphiti memory status +- `CONTEXT_SEARCH_MEMORIES` - Search memory episodes +- `CONTEXT_GET_MEMORIES` - Get memory episodes + +### Integration Modules + +#### `github-handlers.ts` (23KB) +GitHub integration: +- `GITHUB_GET_REPOSITORIES` - List repositories +- `GITHUB_GET_ISSUES` - List issues +- `GITHUB_GET_ISSUE` - Get single issue +- `GITHUB_CHECK_CONNECTION` - Test connection +- `GITHUB_INVESTIGATE_ISSUE` - AI investigation +- `GITHUB_IMPORT_ISSUES` - Import issues as tasks +- `GITHUB_CREATE_RELEASE` - Create GitHub release + +#### `linear-handlers.ts` (15KB) +Linear integration: +- `LINEAR_GET_TEAMS` - List teams +- `LINEAR_GET_PROJECTS` - List projects +- `LINEAR_GET_ISSUES` - List issues +- `LINEAR_IMPORT_ISSUES` - Import issues as tasks +- `LINEAR_CHECK_CONNECTION` - Test connection + +#### `env-handlers.ts` (16KB) +Environment configuration: +- `ENV_GET` - Get project environment +- `ENV_UPDATE` - Update environment variables +- `ENV_CHECK_CLAUDE_AUTH` - Check Claude authentication +- `ENV_INVOKE_CLAUDE_SETUP` - Run Claude setup + +#### `autobuild-source-handlers.ts` (8.9KB) +Auto-build source updates: +- `AUTOBUILD_SOURCE_CHECK` - Check for updates +- `AUTOBUILD_SOURCE_DOWNLOAD` - Download updates +- `AUTOBUILD_SOURCE_VERSION` - Get version info +- Source environment configuration + +### Event Handlers + +#### `agent-events-handlers.ts` (6.1KB) +Agent event forwarding to renderer: +- Agent log events +- Agent error events +- SDK rate limit events +- Agent exit events with status transitions +- Execution progress events +- File watcher events +- Implementation plan updates + +## Entry Point + +### `index.ts` (3.6KB) +Central registration point that: +- Imports all handler modules +- Exports `setupIpcHandlers()` function +- Configures services with dependencies +- Registers all handlers in organized sequence +- Re-exports individual registration functions + +### Main IPC Handlers File +The refactored `ipc-handlers.ts` now: +- Imports `setupIpcHandlers` from `./ipc-handlers` +- Delegates all registration to modular handlers +- Provides clear documentation of module organization +- Reduced from 6,913 lines to ~50 lines + +## Benefits of Modular Architecture + +### Maintainability +- **Focused Modules**: Each module has a single responsibility +- **Clear Boundaries**: Domain separation makes code easier to understand +- **Reduced Complexity**: Smaller files are easier to navigate and modify +- **Better Organization**: Related handlers grouped together + +### Testability +- **Isolated Testing**: Each module can be tested independently +- **Mock Dependencies**: Easier to mock services for unit tests +- **Focused Test Suites**: Test files can mirror module structure + +### Developer Experience +- **Easier Navigation**: Find handlers by domain, not by scrolling +- **Reduced Conflicts**: Multiple developers can work on different modules +- **Clear Imports**: Each module declares its dependencies explicitly +- **Better IDE Support**: Faster intellisense and type checking + +### Code Quality +- **Explicit Dependencies**: Each module imports only what it needs +- **Type Safety**: Proper TypeScript imports throughout +- **Consistent Patterns**: Uniform registration pattern across modules +- **Documentation**: Each module has clear purpose and scope + +## Usage + +To register all IPC handlers: + +```typescript +import { setupIpcHandlers } from './ipc-handlers'; +import { AgentManager } from './agent-manager'; +import { TerminalManager } from './terminal-manager'; +import { PythonEnvManager } from './python-env-manager'; + +// Initialize services +const agentManager = new AgentManager(); +const terminalManager = new TerminalManager(); +const pythonEnvManager = new PythonEnvManager(); + +// Register all handlers +setupIpcHandlers( + agentManager, + terminalManager, + () => mainWindow, + pythonEnvManager +); +``` + +To register individual modules: + +```typescript +import { registerTaskHandlers } from './ipc-handlers/task-handlers'; + +registerTaskHandlers(agentManager, () => mainWindow); +``` + +## Migration Notes + +The refactoring maintains 100% backward compatibility: +- All IPC channel names unchanged +- All handler signatures unchanged +- All service dependencies preserved +- Event forwarding logic unchanged + +Original file backed up as `ipc-handlers.ts.backup`. + +## Module Dependencies + +Each module may depend on: +- **Services**: AgentManager, TerminalManager, ChangelogService, etc. +- **Stores**: projectStore +- **Utilities**: fileWatcher, titleGenerator +- **Constants**: IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir +- **Types**: Extensive TypeScript types from shared/types + +All dependencies are explicitly imported at the module level. diff --git a/auto-claude-ui/src/main/ipc-handlers/agent-events-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/agent-events-handlers.ts new file mode 100644 index 00000000..aaf556a3 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/agent-events-handlers.ts @@ -0,0 +1,179 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; +import type { + IPCResult, + SDKRateLimitInfo, + Task, + TaskStatus, + Project, + ImplementationPlan, + ExecutionProgress +} from '../../shared/types'; +import { AgentManager } from '../agent'; +import type { ProcessType, ExecutionProgressData } from '../agent'; +import { titleGenerator } from '../title-generator'; +import { fileWatcher } from '../file-watcher'; +import { projectStore } from '../project-store'; + + +/** + * Register all agent-events-related IPC handlers + */ +export function registerAgenteventsHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Agent Manager Events → Renderer + // ============================================ + + agentManager.on('log', (taskId: string, log: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_LOG, taskId, log); + } + }); + + agentManager.on('error', (taskId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error); + } + }); + + // Handle SDK rate limit events from agent manager + agentManager.on('sdk-rate-limit', (rateLimitInfo: SDKRateLimitInfo) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); + } + }); + + // Handle SDK rate limit events from title generator + titleGenerator.on('sdk-rate-limit', (rateLimitInfo: SDKRateLimitInfo) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); + } + }); + + agentManager.on('exit', (taskId: string, code: number | null, processType: ProcessType) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + // Stop file watcher + fileWatcher.unwatch(taskId); + + // Determine new status based on process type and exit code + // Flow: Planning → In Progress → AI Review (QA agent) → Human Review (QA passed) + let newStatus: TaskStatus; + + if (processType === 'task-execution') { + // Task execution completed (includes spec_runner → run.py chain) + // Success (code 0) = QA agent signed off → Human Review + // Failure = needs human attention → Human Review + newStatus = 'human_review'; + } else if (processType === 'qa-process') { + // QA retry process completed + newStatus = 'human_review'; + } else if (processType === 'spec-creation') { + // Pure spec creation (shouldn't happen with current flow, but handle it) + // Stay in backlog/planning + console.log(`[Task ${taskId}] Spec creation completed with code ${code}`); + return; + } else { + // Unknown process type + newStatus = 'human_review'; + } + + // Persist status to disk so it survives hot reload + // This is a backup in case the Python backend didn't sync properly + try { + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (task && project) { + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, task.specId); + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + // Only update if not already set to a "further along" status + // (e.g., don't override 'done' with 'human_review') + const currentStatus = plan.status; + const shouldUpdate = !currentStatus || + currentStatus === 'in_progress' || + currentStatus === 'ai_review' || + currentStatus === 'backlog' || + currentStatus === 'pending'; + + if (shouldUpdate) { + plan.status = newStatus; + plan.planStatus = 'review'; + plan.updated_at = new Date().toISOString(); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + console.log(`[Task ${taskId}] Persisted status '${newStatus}' to implementation_plan.json`); + } + } + } + } catch (persistError) { + console.error(`[Task ${taskId}] Failed to persist status:`, persistError); + } + + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + newStatus + ); + } + }); + + agentManager.on('execution-progress', (taskId: string, progress: ExecutionProgressData) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, taskId, progress); + + // Auto-move task to AI Review when entering qa_review phase + if (progress.phase === 'qa_review') { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'ai_review' + ); + } + } + }); + + // ============================================ + // File Watcher Events → Renderer + // ============================================ + + fileWatcher.on('progress', (taskId: string, plan: ImplementationPlan) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_PROGRESS, taskId, plan); + } + }); + + fileWatcher.on('error', (taskId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_ERROR, taskId, error); + } + }); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts new file mode 100644 index 00000000..cd690716 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/autobuild-source-handlers.ts @@ -0,0 +1,303 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types'; +import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveSourcePath } from '../auto-claude-updater'; + + +/** + * Register all autobuild-source-related IPC handlers + */ +export function registerAutobuildSourceHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Auto Claude Source Update Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK, + async (): Promise> => { + try { + const result = await checkSourceUpdates(); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check for updates' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD, + () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + // Start download in background + downloadAndApplyUpdate((progress) => { + mainWindow.webContents.send( + IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, + progress + ); + }).then((result) => { + if (result.success) { + mainWindow.webContents.send( + IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, + { + stage: 'complete', + message: `Updated to version ${result.version}` + } as AutoBuildSourceUpdateProgress + ); + } else { + mainWindow.webContents.send( + IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, + { + stage: 'error', + message: result.error || 'Update failed' + } as AutoBuildSourceUpdateProgress + ); + } + }).catch((error) => { + mainWindow.webContents.send( + IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, + { + stage: 'error', + message: error instanceof Error ? error.message : 'Update failed' + } as AutoBuildSourceUpdateProgress + ); + }); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, + { + stage: 'checking', + message: 'Starting update...' + } as AutoBuildSourceUpdateProgress + ); + } + ); + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION, + async (): Promise> => { + try { + const version = getBundledVersion(); + return { success: true, data: version }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get version' + }; + } + } + ); + + // ============================================ + // Auto Claude Source Environment Operations + // ============================================ + + /** + * Parse an .env file content into a key-value object + */ + const parseSourceEnvFile = (content: string): Record => { + const vars: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + vars[key] = value; + } + } + return vars; + }; + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET, + async (): Promise> => { + try { + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { + success: true, + data: { + hasClaudeToken: false, + envExists: false, + sourcePath: undefined + } + }; + } + + const envPath = path.join(sourcePath, '.env'); + const envExists = existsSync(envPath); + + if (!envExists) { + return { + success: true, + data: { + hasClaudeToken: false, + envExists: false, + sourcePath + } + }; + } + + const content = readFileSync(envPath, 'utf-8'); + const vars = parseSourceEnvFile(content); + const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN']; + + return { + success: true, + data: { + hasClaudeToken: hasToken, + claudeOAuthToken: hasToken ? vars['CLAUDE_CODE_OAUTH_TOKEN'] : undefined, + envExists: true, + sourcePath + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get source env' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, + async (_, config: { claudeOAuthToken?: string }): Promise => { + try { + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { + success: false, + error: 'Auto-Claude source path not found. Please configure it in App Settings.' + }; + } + + const envPath = path.join(sourcePath, '.env'); + + // Read existing content or start fresh + let existingContent = ''; + const existingVars: Record = {}; + + if (existsSync(envPath)) { + existingContent = readFileSync(envPath, 'utf-8'); + Object.assign(existingVars, parseSourceEnvFile(existingContent)); + } + + // Update the token + if (config.claudeOAuthToken !== undefined) { + existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken; + } + + // Rebuild the .env file preserving comments and structure + const lines = existingContent.split('\n'); + const processedKeys = new Set(); + const outputLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + outputLines.push(line); + continue; + } + + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + if (key in existingVars) { + outputLines.push(`${key}=${existingVars[key]}`); + processedKeys.add(key); + } else { + outputLines.push(line); + } + } else { + outputLines.push(line); + } + } + + // Add any new keys that weren't in the original file + for (const [key, value] of Object.entries(existingVars)) { + if (!processedKeys.has(key)) { + outputLines.push(`${key}=${value}`); + } + } + + writeFileSync(envPath, outputLines.join('\n')); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update source env' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN, + async (): Promise> => { + try { + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { + success: true, + data: { + hasToken: false, + sourcePath: undefined, + error: 'Auto-Claude source path not found' + } + }; + } + + const envPath = path.join(sourcePath, '.env'); + if (!existsSync(envPath)) { + return { + success: true, + data: { + hasToken: false, + sourcePath, + error: '.env file does not exist' + } + }; + } + + const content = readFileSync(envPath, 'utf-8'); + const vars = parseSourceEnvFile(content); + const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'] && vars['CLAUDE_CODE_OAUTH_TOKEN'].length > 0; + + return { + success: true, + data: { + hasToken, + sourcePath + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check source token' + }; + } + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts new file mode 100644 index 00000000..321107c6 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts @@ -0,0 +1,297 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { IPC_CHANNELS, getSpecsDir } from '../../shared/constants'; +import type { + IPCResult, + Task, + ChangelogTask, + TaskSpecContent, + ChangelogGenerationRequest, + ChangelogSaveRequest, + ChangelogSaveResult, + ExistingChangelog, + GitBranchInfo, + GitTagInfo, + GitCommit, + GitHistoryOptions, + BranchDiffOptions +} from '../../shared/types'; +import { projectStore } from '../project-store'; +import { changelogService } from '../changelog-service'; + +/** + * Register all changelog-related IPC handlers + */ +export function registerChangelogHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Changelog Event Handlers + // ============================================ + + changelogService.on('generation-progress', (projectId: string, progress: import('../../shared/types').ChangelogGenerationProgress) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, projectId, progress); + } + }); + + changelogService.on('generation-complete', (projectId: string, result: import('../../shared/types').ChangelogGenerationResult) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, projectId, result); + } + }); + + changelogService.on('generation-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, projectId, error); + } + }); + + changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); + } + }); + + // ============================================ + // Changelog Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, + async (_, projectId: string, rendererTasks?: Task[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Use renderer tasks if provided (they have the correct UI status), + // otherwise fall back to reading from filesystem + const tasks = rendererTasks || projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const doneTasks = changelogService.getCompletedTasks(project.path, tasks, specsBaseDir); + + return { success: true, data: doneTasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const tasks = projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + return { success: true, data: specs }; + } + ); + + ipcMain.on( + IPC_CHANNELS.CHANGELOG_GENERATE, + async (_, request: ChangelogGenerationRequest) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(request.projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, + request.projectId, + 'Project not found' + ); + return; + } + + // Load specs for selected tasks (only in tasks mode) + let specs: TaskSpecContent[] = []; + if (request.sourceMode === 'tasks' && request.taskIds && request.taskIds.length > 0) { + const tasks = projectStore.getTasks(request.projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + specs = await changelogService.loadTaskSpecs(project.path, request.taskIds, tasks, specsBaseDir); + } + + // Start generation + changelogService.generateChangelog(request.projectId, project.path, request, specs); + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SAVE, + async (_, request: ChangelogSaveRequest): Promise> => { + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const result = changelogService.saveChangelog(project.path, request); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save changelog' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_READ_EXISTING, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const result = changelogService.readExistingChangelog(project.path); + return { success: true, data: result }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Get current version from existing changelog + const existing = changelogService.readExistingChangelog(project.path); + const currentVersion = existing.lastVersion; + + // Load specs for selected tasks to analyze change types + const tasks = projectStore.getTasks(projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + // Analyze specs and suggest version + const suggestedVersion = changelogService.suggestVersion(specs, currentVersion); + + // Determine reason for the suggestion + let reason = 'patch'; + if (currentVersion) { + const [oldMajor, oldMinor] = currentVersion.split('.').map(Number); + const [newMajor, newMinor] = suggestedVersion.split('.').map(Number); + if (newMajor > oldMajor) { + reason = 'breaking'; + } else if (newMinor > oldMinor) { + reason = 'feature'; + } + } + + return { + success: true, + data: { version: suggestedVersion, reason } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to suggest version' + }; + } + } + ); + + // ============================================ + // Changelog Git Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_BRANCHES, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const branches = changelogService.getBranches(project.path); + return { success: true, data: branches }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get branches' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_TAGS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const tags = changelogService.getTags(project.path); + return { success: true, data: tags }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get tags' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, + async ( + _, + projectId: string, + options: GitHistoryOptions | BranchDiffOptions, + mode: 'git-history' | 'branch-diff' + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + let commits: GitCommit[]; + + if (mode === 'git-history') { + commits = changelogService.getCommits( + project.path, + options as GitHistoryOptions + ); + } else { + commits = changelogService.getBranchDiffCommits( + project.path, + options as BranchDiffOptions + ); + } + + return { success: true, data: commits }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get commits preview' + }; + } + } + ); + + // ============================================ + // Changelog Agent Events → Renderer +} diff --git a/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts.bk b/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts.bk new file mode 100644 index 00000000..21bc2874 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/changelog-handlers.ts.bk @@ -0,0 +1,264 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { execSync } from 'child_process'; +import { IPC_CHANNELS, getSpecsDir } from '../../shared/constants'; +import type { + IPCResult, + Task, + ChangelogTask, + TaskSpecContent, + ChangelogGenerationRequest, + ChangelogSaveRequest, + ChangelogSaveResult, + ExistingChangelog, + GitBranchInfo, + GitTagInfo, + GitCommit, + GitHistoryOptions, + BranchDiffOptions +} from '../../shared/types'; +import { projectStore } from '../project-store'; +import { changelogService } from '../changelog-service'; + +/** + * Register all changelog-related IPC handlers + */ +export function registerChangelogHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // Changelog Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, + async (_, projectId: string, rendererTasks?: import('../shared/types').Task[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Use renderer tasks if provided (they have the correct UI status), + // otherwise fall back to reading from filesystem + const tasks = rendererTasks || projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const doneTasks = changelogService.getCompletedTasks(project.path, tasks, specsBaseDir); + + return { success: true, data: doneTasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const tasks = projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + return { success: true, data: specs }; + } + ); + + ipcMain.on( + IPC_CHANNELS.CHANGELOG_GENERATE, + async (_, request: import('../shared/types').ChangelogGenerationRequest) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(request.projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, + request.projectId, + 'Project not found' + ); + return; + } + + // Load specs for selected tasks (only in tasks mode) + let specs: import('../shared/types').TaskSpecContent[] = []; + if (request.sourceMode === 'tasks' && request.taskIds && request.taskIds.length > 0) { + const tasks = projectStore.getTasks(request.projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + specs = await changelogService.loadTaskSpecs(project.path, request.taskIds, tasks, specsBaseDir); + } + + // Start generation + changelogService.generateChangelog(request.projectId, project.path, request, specs); + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SAVE, + async (_, request: import('../shared/types').ChangelogSaveRequest): Promise> => { + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const result = changelogService.saveChangelog(project.path, request); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save changelog' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_READ_EXISTING, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const result = changelogService.readExistingChangelog(project.path); + return { success: true, data: result }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Get current version from existing changelog + const existing = changelogService.readExistingChangelog(project.path); + const currentVersion = existing.lastVersion; + + // Load specs for selected tasks to analyze change types + const tasks = projectStore.getTasks(projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + // Analyze specs and suggest version + const suggestedVersion = changelogService.suggestVersion(specs, currentVersion); + + // Determine reason for the suggestion + let reason = 'patch'; + if (currentVersion) { + const [oldMajor, oldMinor] = currentVersion.split('.').map(Number); + const [newMajor, newMinor] = suggestedVersion.split('.').map(Number); + if (newMajor > oldMajor) { + reason = 'breaking'; + } else if (newMinor > oldMinor) { + reason = 'feature'; + } + } + + return { + success: true, + data: { version: suggestedVersion, reason } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to suggest version' + }; + } + } + ); + + // ============================================ + // Changelog Git Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_BRANCHES, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const branches = changelogService.getBranches(project.path); + return { success: true, data: branches }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get branches' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_TAGS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const tags = changelogService.getTags(project.path); + return { success: true, data: tags }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get tags' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, + async ( + _, + projectId: string, + options: import('../shared/types').GitHistoryOptions | import('../shared/types').BranchDiffOptions, + mode: 'git-history' | 'branch-diff' + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + let commits: import('../shared/types').GitCommit[]; + + if (mode === 'git-history') { + commits = changelogService.getCommits( + project.path, + options as import('../shared/types').GitHistoryOptions + ); + } else { + commits = changelogService.getBranchDiffCommits( + project.path, + options as import('../shared/types').BranchDiffOptions + ); + } + + return { success: true, data: commits }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get commits preview' + }; + } + } + ); + + // ============================================ + // Changelog Agent Events → Renderer +} diff --git a/auto-claude-ui/src/main/ipc-handlers/context-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/context-handlers.ts new file mode 100644 index 00000000..3750c81e --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/context-handlers.ts @@ -0,0 +1,551 @@ +import { ipcMain, app } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { spawn } from 'child_process'; +import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; +import type { + IPCResult, + ProjectContextData, + GraphitiMemoryStatus, + MemoryEpisode, + ContextSearchResult, + ProjectIndex, + GraphitiMemoryState +} from '../../shared/types'; +import { projectStore } from '../project-store'; + +const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + +const getAutoBuildSourcePath = (): string | null => { + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + if (settings.autoBuildPath && existsSync(settings.autoBuildPath)) { + return settings.autoBuildPath; + } + } catch { + // Fall through to null + } + } + return null; +}; + + +/** + * Register all context-related IPC handlers + */ +export function registerContextHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Context Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Load project index + let projectIndex: ProjectIndex | null = null; + const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + if (existsSync(indexPath)) { + const content = readFileSync(indexPath, 'utf-8'); + projectIndex = JSON.parse(content); + } + + // Load graphiti state from most recent spec or project root + let memoryState: GraphitiMemoryState | null = null; + let memoryStatus: GraphitiMemoryStatus = { + enabled: false, + available: false, + reason: 'Graphiti not configured' + }; + + // Check for graphiti state in specs + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const specDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of specDirs) { + const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE); + if (existsSync(statePath)) { + const stateContent = readFileSync(statePath, 'utf-8'); + memoryState = JSON.parse(stateContent); + + // If we found a state, update memory status + if (memoryState?.initialized) { + memoryStatus = { + enabled: true, + available: true, + database: memoryState.database || 'auto_build_memory', + host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost', + port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10) + }; + } + break; + } + } + } + + // Check environment for Graphiti config if not found in specs + if (!memoryState) { + // Load project .env file and global settings to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (graphitiEnabled && hasOpenAI) { + memoryStatus = { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + }; + } else if (graphitiEnabled && !hasOpenAI) { + memoryStatus = { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)' + }; + } + } + + // Load recent memories from file-based memory (session insights) + const recentMemories: MemoryEpisode[] = []; + if (existsSync(specsDir)) { + const recentSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse() + .slice(0, 10); // Last 10 specs + + for (const specDir of recentSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + // Load session insights from session_insights subdirectory + const sessionInsightsDir = path.join(memoryDir, 'session_insights'); + if (existsSync(sessionInsightsDir)) { + const sessionFiles = readdirSync(sessionInsightsDir) + .filter((f: string) => f.startsWith('session_') && f.endsWith('.json')) + .sort() + .reverse(); + + for (const sessionFile of sessionFiles.slice(0, 3)) { + try { + const sessionPath = path.join(sessionInsightsDir, sessionFile); + const sessionContent = readFileSync(sessionPath, 'utf-8'); + const sessionData = JSON.parse(sessionContent); + + // Session files have: session_number, timestamp, subtasks_completed, + // discoveries, what_worked, what_failed, recommendations_for_next_session + if (sessionData.session_number !== undefined) { + recentMemories.push({ + id: `${specDir}-${sessionFile}`, + type: 'session_insight', + timestamp: sessionData.timestamp || new Date().toISOString(), + content: JSON.stringify({ + discoveries: sessionData.discoveries, + what_worked: sessionData.what_worked, + what_failed: sessionData.what_failed, + recommendations: sessionData.recommendations_for_next_session, + subtasks_completed: sessionData.subtasks_completed + }, null, 2), + session_number: sessionData.session_number + }); + } + } catch { + // Skip invalid files + } + } + } + + // Also load codebase_map.json as a memory item + const codebaseMapPath = path.join(memoryDir, 'codebase_map.json'); + if (existsSync(codebaseMapPath)) { + try { + const mapContent = readFileSync(codebaseMapPath, 'utf-8'); + const mapData = JSON.parse(mapContent); + if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) { + recentMemories.push({ + id: `${specDir}-codebase_map`, + type: 'codebase_map', + timestamp: mapData.last_updated || new Date().toISOString(), + content: JSON.stringify(mapData.discovered_files, null, 2), + session_number: undefined + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { + success: true, + data: { + projectIndex, + memoryStatus, + memoryState, + recentMemories: recentMemories.slice(0, 20), + isLoading: false + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load project context' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_REFRESH_INDEX, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Run the analyzer script to regenerate project_index.json + const autoBuildSource = getAutoBuildSourcePath(); + + if (!autoBuildSource) { + return { + success: false, + error: 'Auto-build source path not configured' + }; + } + + const analyzerPath = path.join(autoBuildSource, 'analyzer.py'); + const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + + // Run analyzer + await new Promise((resolve, reject) => { + const proc = spawn('python', [ + analyzerPath, + '--project-dir', project.path, + '--output', indexOutputPath + ], { + cwd: project.path, + env: { ...process.env } + }); + + proc.on('close', (code: number) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Analyzer exited with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + + // Read the new index + if (existsSync(indexOutputPath)) { + const content = readFileSync(indexOutputPath, 'utf-8'); + const projectIndex = JSON.parse(content); + return { success: true, data: projectIndex }; + } + + return { success: false, error: 'Failed to generate project index' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to refresh project index' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_MEMORY_STATUS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Load project .env file to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (!graphitiEnabled) { + return { + success: true, + data: { + enabled: false, + available: false, + reason: 'GRAPHITI_ENABLED not set to true' + } + }; + } + + if (!hasOpenAI) { + return { + success: true, + data: { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for embeddings)' + } + }; + } + + return { + success: true, + data: { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + } + }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, + async (_, projectId: string, query: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // For now, do simple text search in file-based memories + // Graphiti search would require running Python subprocess + const results: ContextSearchResult[] = []; + const queryLower = query.toLowerCase(); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const allSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }); + + for (const specDir of allSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + + if (memContent.toLowerCase().includes(queryLower)) { + const memData = JSON.parse(memContent); + results.push({ + content: JSON.stringify(memData.insights || memData, null, 2), + score: 1.0, + type: 'session_insight' + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { success: true, data: results.slice(0, 20) }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET_MEMORIES, + async (_, projectId: string, limit: number = 20): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const memories: MemoryEpisode[] = []; + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + if (existsSync(specsDir)) { + const sortedSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of sortedSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')) + .sort() + .reverse(); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + const memData = JSON.parse(memContent); + + memories.push({ + id: `${specDir}-${memFile}`, + type: memData.type || 'session_insight', + timestamp: memData.timestamp || new Date().toISOString(), + content: JSON.stringify(memData.insights || memData, null, 2), + session_number: memData.session_number + }); + + if (memories.length >= limit) { + break; + } + } catch { + // Skip invalid files + } + } + } + + if (memories.length >= limit) { + break; + } + } + } + + return { success: true, data: memories }; + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/env-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/env-handlers.ts new file mode 100644 index 00000000..f1fac050 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/env-handlers.ts @@ -0,0 +1,422 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants'; +import type { IPCResult, ProjectEnvConfig, ClaudeAuthResult, AppSettings } from '../../shared/types'; +import path from 'path'; +import { app } from 'electron'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { execSync, spawn } from 'child_process'; +import { projectStore } from '../project-store'; +import { parseEnvFile } from './utils'; + + +/** + * Register all env-related IPC handlers + */ +export function registerEnvHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Environment Configuration Operations + // ============================================ + + // Get settings file path + const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + + /** + * Generate .env file content from config + */ + const generateEnvContent = ( + config: Partial, + existingContent?: string + ): string => { + // Parse existing content to preserve comments and structure + const existingVars = existingContent ? parseEnvFile(existingContent) : {}; + + // Update with new values + if (config.claudeOAuthToken !== undefined) { + existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken; + } + if (config.autoBuildModel !== undefined) { + existingVars['AUTO_BUILD_MODEL'] = config.autoBuildModel; + } + if (config.linearApiKey !== undefined) { + existingVars['LINEAR_API_KEY'] = config.linearApiKey; + } + if (config.linearTeamId !== undefined) { + existingVars['LINEAR_TEAM_ID'] = config.linearTeamId; + } + if (config.linearProjectId !== undefined) { + existingVars['LINEAR_PROJECT_ID'] = config.linearProjectId; + } + if (config.linearRealtimeSync !== undefined) { + existingVars['LINEAR_REALTIME_SYNC'] = config.linearRealtimeSync ? 'true' : 'false'; + } + // GitHub Integration + if (config.githubToken !== undefined) { + existingVars['GITHUB_TOKEN'] = config.githubToken; + } + if (config.githubRepo !== undefined) { + existingVars['GITHUB_REPO'] = config.githubRepo; + } + if (config.githubAutoSync !== undefined) { + existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false'; + } + if (config.graphitiEnabled !== undefined) { + existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false'; + } + if (config.openaiApiKey !== undefined) { + existingVars['OPENAI_API_KEY'] = config.openaiApiKey; + } + if (config.graphitiFalkorDbHost !== undefined) { + existingVars['GRAPHITI_FALKORDB_HOST'] = config.graphitiFalkorDbHost; + } + if (config.graphitiFalkorDbPort !== undefined) { + existingVars['GRAPHITI_FALKORDB_PORT'] = String(config.graphitiFalkorDbPort); + } + if (config.graphitiFalkorDbPassword !== undefined) { + existingVars['GRAPHITI_FALKORDB_PASSWORD'] = config.graphitiFalkorDbPassword; + } + if (config.graphitiDatabase !== undefined) { + existingVars['GRAPHITI_DATABASE'] = config.graphitiDatabase; + } + if (config.enableFancyUi !== undefined) { + existingVars['ENABLE_FANCY_UI'] = config.enableFancyUi ? 'true' : 'false'; + } + + // Generate content with sections + let content = `# Auto Claude Framework Environment Variables +# Managed by Auto Claude UI + +# Claude Code OAuth Token (REQUIRED) +CLAUDE_CODE_OAUTH_TOKEN=${existingVars['CLAUDE_CODE_OAUTH_TOKEN'] || ''} + +# Model override (OPTIONAL) +${existingVars['AUTO_BUILD_MODEL'] ? `AUTO_BUILD_MODEL=${existingVars['AUTO_BUILD_MODEL']}` : '# AUTO_BUILD_MODEL=claude-opus-4-5-20251101'} + +# ============================================================================= +# LINEAR INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['LINEAR_API_KEY'] ? `LINEAR_API_KEY=${existingVars['LINEAR_API_KEY']}` : '# LINEAR_API_KEY='} +${existingVars['LINEAR_TEAM_ID'] ? `LINEAR_TEAM_ID=${existingVars['LINEAR_TEAM_ID']}` : '# LINEAR_TEAM_ID='} +${existingVars['LINEAR_PROJECT_ID'] ? `LINEAR_PROJECT_ID=${existingVars['LINEAR_PROJECT_ID']}` : '# LINEAR_PROJECT_ID='} +${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${existingVars['LINEAR_REALTIME_SYNC']}` : '# LINEAR_REALTIME_SYNC=false'} + +# ============================================================================= +# GITHUB INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='} +${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'} +${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'} + +# ============================================================================= +# UI SETTINGS (OPTIONAL) +# ============================================================================= +${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVars['ENABLE_FANCY_UI']}` : '# ENABLE_FANCY_UI=true'} + +# ============================================================================= +# GRAPHITI MEMORY INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['GRAPHITI_ENABLED'] ? `GRAPHITI_ENABLED=${existingVars['GRAPHITI_ENABLED']}` : '# GRAPHITI_ENABLED=false'} +${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KEY']}` : '# OPENAI_API_KEY='} +${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'} +${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'} +${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='} +${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHITI_DATABASE']}` : '# GRAPHITI_DATABASE=auto_build_memory'} +`; + + return content; + }; + + ipcMain.handle( + IPC_CHANNELS.ENV_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Project not initialized' }; + } + + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + + // Load global settings for fallbacks + let globalSettings: AppSettings = { ...DEFAULT_APP_SETTINGS }; + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + globalSettings = { ...globalSettings, ...JSON.parse(content) }; + } catch { + // Use defaults + } + } + + // Default config + const config: ProjectEnvConfig = { + claudeAuthStatus: 'not_configured', + linearEnabled: false, + githubEnabled: false, + graphitiEnabled: false, + enableFancyUi: true, + claudeTokenIsGlobal: false, + openaiKeyIsGlobal: false + }; + + // Parse project-specific .env if it exists + let vars: Record = {}; + if (existsSync(envPath)) { + try { + const content = readFileSync(envPath, 'utf-8'); + vars = parseEnvFile(content); + } catch { + // Continue with empty vars + } + } + + // Claude OAuth Token: project-specific takes precedence, then global + if (vars['CLAUDE_CODE_OAUTH_TOKEN']) { + config.claudeOAuthToken = vars['CLAUDE_CODE_OAUTH_TOKEN']; + config.claudeAuthStatus = 'token_set'; + config.claudeTokenIsGlobal = false; + } else if (globalSettings.globalClaudeOAuthToken) { + config.claudeOAuthToken = globalSettings.globalClaudeOAuthToken; + config.claudeAuthStatus = 'token_set'; + config.claudeTokenIsGlobal = true; + } + + if (vars['AUTO_BUILD_MODEL']) { + config.autoBuildModel = vars['AUTO_BUILD_MODEL']; + } + + if (vars['LINEAR_API_KEY']) { + config.linearEnabled = true; + config.linearApiKey = vars['LINEAR_API_KEY']; + } + if (vars['LINEAR_TEAM_ID']) { + config.linearTeamId = vars['LINEAR_TEAM_ID']; + } + if (vars['LINEAR_PROJECT_ID']) { + config.linearProjectId = vars['LINEAR_PROJECT_ID']; + } + if (vars['LINEAR_REALTIME_SYNC']?.toLowerCase() === 'true') { + config.linearRealtimeSync = true; + } + + // GitHub config + if (vars['GITHUB_TOKEN']) { + config.githubEnabled = true; + config.githubToken = vars['GITHUB_TOKEN']; + } + if (vars['GITHUB_REPO']) { + config.githubRepo = vars['GITHUB_REPO']; + } + if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') { + config.githubAutoSync = true; + } + + if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') { + config.graphitiEnabled = true; + } + + // OpenAI API Key: project-specific takes precedence, then global + if (vars['OPENAI_API_KEY']) { + config.openaiApiKey = vars['OPENAI_API_KEY']; + config.openaiKeyIsGlobal = false; + } else if (globalSettings.globalOpenAIApiKey) { + config.openaiApiKey = globalSettings.globalOpenAIApiKey; + config.openaiKeyIsGlobal = true; + } + + if (vars['GRAPHITI_FALKORDB_HOST']) { + config.graphitiFalkorDbHost = vars['GRAPHITI_FALKORDB_HOST']; + } + if (vars['GRAPHITI_FALKORDB_PORT']) { + config.graphitiFalkorDbPort = parseInt(vars['GRAPHITI_FALKORDB_PORT'], 10); + } + if (vars['GRAPHITI_FALKORDB_PASSWORD']) { + config.graphitiFalkorDbPassword = vars['GRAPHITI_FALKORDB_PASSWORD']; + } + if (vars['GRAPHITI_DATABASE']) { + config.graphitiDatabase = vars['GRAPHITI_DATABASE']; + } + + if (vars['ENABLE_FANCY_UI']?.toLowerCase() === 'false') { + config.enableFancyUi = false; + } + + return { success: true, data: config }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_UPDATE, + async (_, projectId: string, config: Partial): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Project not initialized' }; + } + + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + + try { + // Read existing content if file exists + let existingContent: string | undefined; + if (existsSync(envPath)) { + existingContent = readFileSync(envPath, 'utf-8'); + } + + // Generate new content + const newContent = generateEnvContent(config, existingContent); + + // Write to file + writeFileSync(envPath, newContent); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update .env file' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Check if Claude CLI is available and authenticated + const result = await new Promise((resolve) => { + const proc = spawn('claude', ['--version'], { + cwd: project.path, + env: { ...process.env }, + shell: true + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + // Claude CLI is available, check if authenticated + // Run a simple command that requires auth + const authCheck = spawn('claude', ['api', '--help'], { + cwd: project.path, + env: { ...process.env }, + shell: true + }); + + authCheck.on('close', (authCode: number | null) => { + resolve({ + success: true, + authenticated: authCode === 0 + }); + }); + + authCheck.on('error', () => { + resolve({ + success: true, + authenticated: false, + error: 'Could not verify authentication' + }); + }); + } else { + resolve({ + success: false, + authenticated: false, + error: 'Claude CLI not found. Please install it first.' + }); + } + }); + + proc.on('error', () => { + resolve({ + success: false, + authenticated: false, + error: 'Claude CLI not found. Please install it first.' + }); + }); + }); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check Claude auth' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Run claude setup-token which will open browser for OAuth + const result = await new Promise((resolve) => { + const proc = spawn('claude', ['setup-token'], { + cwd: project.path, + env: { ...process.env }, + shell: true, + stdio: 'inherit' // This allows the terminal to handle the interactive auth + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve({ + success: true, + authenticated: true + }); + } else { + resolve({ + success: false, + authenticated: false, + error: 'Setup cancelled or failed' + }); + } + }); + + proc.on('error', (err: Error) => { + resolve({ + success: false, + authenticated: false, + error: err.message + }); + }); + }); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to invoke Claude setup' + }; + } + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/file-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/file-handlers.ts new file mode 100644 index 00000000..8f4d48a1 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/file-handlers.ts @@ -0,0 +1,60 @@ +import { ipcMain } from 'electron'; +import { readdirSync } from 'fs'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult, FileNode } from '../../shared/types'; + +// Directories to ignore when listing +const IGNORED_DIRS = new Set([ + 'node_modules', '.git', '__pycache__', 'dist', 'build', + '.next', '.nuxt', 'coverage', '.cache', '.venv', 'venv', + '.idea', '.vscode', 'out', '.turbo', '.auto-claude', + '.worktrees', 'vendor', 'target', '.gradle', '.maven' +]); + +/** + * Register all file-related IPC handlers + */ +export function registerFileHandlers(): void { + // ============================================ + // File Explorer Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.FILE_EXPLORER_LIST, + async (_, dirPath: string): Promise> => { + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + + // Filter and map entries + const nodes: FileNode[] = []; + for (const entry of entries) { + // Skip hidden files (except .env which is often useful) + if (entry.name.startsWith('.') && entry.name !== '.env') continue; + // Skip ignored directories + if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; + + nodes.push({ + path: path.join(dirPath, entry.name), + name: entry.name, + isDirectory: entry.isDirectory() + }); + } + + // Sort: directories first, then alphabetically + nodes.sort((a, b) => { + if (a.isDirectory && !b.isDirectory) return -1; + if (!a.isDirectory && b.isDirectory) return 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + + return { success: true, data: nodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list directory' + }; + } + } + ); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/github-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/github-handlers.ts new file mode 100644 index 00000000..1b7f446d --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/github-handlers.ts @@ -0,0 +1,742 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants'; +import type { IPCResult, GitHubRepository, GitHubIssue, GitHubSyncStatus, GitHubImportResult, GitHubInvestigationResult, Project, Task, TaskMetadata, GitHubInvestigationStatus } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; +import { execSync, spawn } from 'child_process'; +import { projectStore } from '../project-store'; +import { AgentManager } from '../agent'; +import { parseEnvFile } from './utils'; + + +/** + * Register all github-related IPC handlers + */ +export function registerGithubHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // GitHub Integration Operations + // ============================================ + + /** + * Helper to get GitHub config from project env + */ + const getGitHubConfig = (project: Project): { token: string; repo: string } | null => { + if (!project.autoBuildPath) return null; + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + if (!existsSync(envPath)) return null; + + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + const token = vars['GITHUB_TOKEN']; + const repo = vars['GITHUB_REPO']; + + if (!token || !repo) return null; + return { token, repo }; + } catch { + return null; + } + }; + + /** + * Make a request to the GitHub API + */ + const githubFetch = async ( + token: string, + endpoint: string, + options: RequestInit = {} + ): Promise => { + const url = endpoint.startsWith('http') + ? endpoint + : `https://api.github.com${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `Bearer ${token}`, + 'User-Agent': 'Auto-Claude-UI', + ...options.headers + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorBody}`); + } + + return response.json(); + }; + + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_CONNECTION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { + success: true, + data: { + connected: false, + error: 'No GitHub token or repository configured' + } + }; + } + + try { + // Fetch repo info + const repoData = await githubFetch( + config.token, + `/repos/${config.repo}` + ) as { full_name: string; description?: string }; + + // Count open issues + const issuesData = await githubFetch( + config.token, + `/repos/${config.repo}/issues?state=open&per_page=1` + ) as unknown[]; + + const openCount = Array.isArray(issuesData) ? issuesData.length : 0; + + return { + success: true, + data: { + connected: true, + repoFullName: repoData.full_name, + repoDescription: repoData.description, + issueCount: openCount, + lastSyncedAt: new Date().toISOString() + } + }; + } catch (error) { + return { + success: true, + data: { + connected: false, + error: error instanceof Error ? error.message : 'Failed to connect to GitHub' + } + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_REPOSITORIES, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token configured' }; + } + + try { + const repos = await githubFetch( + config.token, + '/user/repos?per_page=100&sort=updated' + ) as Array<{ + id: number; + name: string; + full_name: string; + description?: string; + html_url: string; + default_branch: string; + private: boolean; + owner: { login: string; avatar_url?: string }; + }>; + + const result: GitHubRepository[] = repos.map(repo => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + url: repo.html_url, + defaultBranch: repo.default_branch, + private: repo.private, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url + } + })); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch repositories' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_ISSUES, + async (_, projectId: string, state: 'open' | 'closed' | 'all' = 'open'): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + try { + const issues = await githubFetch( + config.token, + `/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated` + ) as Array<{ + id: number; + number: number; + title: string; + body?: string; + state: 'open' | 'closed'; + labels: Array<{ id: number; name: string; color: string; description?: string }>; + assignees: Array<{ login: string; avatar_url?: string }>; + user: { login: string; avatar_url?: string }; + milestone?: { id: number; title: string; state: 'open' | 'closed' }; + created_at: string; + updated_at: string; + closed_at?: string; + comments: number; + url: string; + html_url: string; + pull_request?: unknown; + }>; + + // Filter out pull requests + const issuesOnly = issues.filter(issue => !issue.pull_request); + + const result: GitHubIssue[] = issuesOnly.map(issue => ({ + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + labels: issue.labels, + assignees: issue.assignees.map(a => ({ + login: a.login, + avatarUrl: a.avatar_url + })), + author: { + login: issue.user.login, + avatarUrl: issue.user.avatar_url + }, + milestone: issue.milestone, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + closedAt: issue.closed_at, + commentsCount: issue.comments, + url: issue.url, + htmlUrl: issue.html_url, + repoFullName: config.repo + })); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issues' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_ISSUE, + async (_, projectId: string, issueNumber: number): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + try { + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + id: number; + number: number; + title: string; + body?: string; + state: 'open' | 'closed'; + labels: Array<{ id: number; name: string; color: string; description?: string }>; + assignees: Array<{ login: string; avatar_url?: string }>; + user: { login: string; avatar_url?: string }; + milestone?: { id: number; title: string; state: 'open' | 'closed' }; + created_at: string; + updated_at: string; + closed_at?: string; + comments: number; + url: string; + html_url: string; + }; + + const result: GitHubIssue = { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + labels: issue.labels, + assignees: issue.assignees.map(a => ({ + login: a.login, + avatarUrl: a.avatar_url + })), + author: { + login: issue.user.login, + avatarUrl: issue.user.avatar_url + }, + milestone: issue.milestone, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + closedAt: issue.closed_at, + commentsCount: issue.comments, + url: issue.url, + htmlUrl: issue.html_url, + repoFullName: config.repo + }; + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issue' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, + async (_, projectId: string, issueNumber: number) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + const config = getGitHubConfig(project); + if (!config) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + 'No GitHub token or repository configured' + ); + return; + } + + try { + // Send progress update: fetching issue + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'fetching', + issueNumber, + progress: 10, + message: 'Fetching issue details...' + } as GitHubInvestigationStatus + ); + + // Fetch the issue + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + number: number; + title: string; + body?: string; + labels: Array<{ name: string }>; + html_url: string; + }; + + // Fetch issue comments for more context + const comments = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}/comments` + ) as Array<{ body: string; user: { login: string } }>; + + // Build context for the AI investigation + const issueContext = ` +# GitHub Issue #${issue.number}: ${issue.title} + +${issue.body || 'No description provided.'} + +${comments.length > 0 ? `## Comments (${comments.length}): +${comments.map(c => `**${c.user.login}:** ${c.body}`).join('\n\n')}` : ''} + +**Labels:** ${issue.labels.map(l => l.name).join(', ') || 'None'} +**URL:** ${issue.html_url} +`; + + // Send progress update: analyzing + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'analyzing', + issueNumber, + progress: 30, + message: 'AI is analyzing the issue...' + } as GitHubInvestigationStatus + ); + + // Build task description + const taskDescription = `Investigate GitHub Issue #${issue.number}: ${issue.title} + +${issueContext} + +Please analyze this issue and provide: +1. A brief summary of what the issue is about +2. A proposed solution approach +3. The files that would likely need to be modified +4. Estimated complexity (simple/standard/complex) +5. Acceptance criteria for resolving this issue`; + + // Create proper spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: taskDescription, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: taskDescription, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'github', + githubIssueNumber: issue.number, + githubUrl: issue.html_url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + + // Send progress update: creating task + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'creating_task', + issueNumber, + progress: 70, + message: 'Creating task from investigation...' + } as GitHubInvestigationStatus + ); + + const investigationResult: GitHubInvestigationResult = { + success: true, + issueNumber, + analysis: { + summary: `Investigation of issue #${issueNumber}: ${issue.title}`, + proposedSolution: 'Task has been created for AI agent to implement the solution.', + affectedFiles: [], + estimatedComplexity: 'standard', + acceptanceCriteria: [ + `Issue #${issueNumber} requirements are met`, + 'All existing tests pass', + 'New functionality is tested' + ] + }, + taskId: specId + }; + + // Send completion + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'complete', + issueNumber, + progress: 100, + message: 'Investigation complete!' + } as GitHubInvestigationStatus + ); + + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, + projectId, + investigationResult + ); + + } catch (error) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + error instanceof Error ? error.message : 'Failed to investigate issue' + ); + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_IMPORT_ISSUES, + async (_, projectId: string, issueNumbers: number[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + let imported = 0; + let failed = 0; + const errors: string[] = []; + const tasks: Task[] = []; + + // Set up specs directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + for (const issueNumber of issueNumbers) { + try { + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + number: number; + title: string; + body?: string; + labels: Array<{ name: string }>; + html_url: string; + }; + + const labels = issue.labels.map(l => l.name).join(', '); + const description = `# ${issue.title} + +**GitHub Issue:** [#${issue.number}](${issue.html_url}) +${labels ? `**Labels:** ${labels}` : ''} + +## Description + +${issue.body || 'No description provided.'} +`; + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: description, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'github', + githubIssueNumber: issue.number, + githubUrl: issue.html_url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, description, specDir, metadata); + imported++; + } catch (err) { + failed++; + errors.push(`Failed to import #${issueNumber}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + return { + success: true, + data: { + success: failed === 0, + imported, + failed, + errors: errors.length > 0 ? errors : undefined, + tasks + } + }; + } + ); + + /** + * Create a GitHub release using the gh CLI + */ + ipcMain.handle( + IPC_CHANNELS.GITHUB_CREATE_RELEASE, + async ( + _, + projectId: string, + version: string, + releaseNotes: string, + options?: { draft?: boolean; prerelease?: boolean } + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Check if gh CLI is available + // Use 'where' on Windows, 'which' on Unix + try { + const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh'; + execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' }); + } catch { + return { + success: false, + error: 'GitHub CLI (gh) not found. Please install it: https://cli.github.com/' + }; + } + + // Check if user is authenticated + try { + execSync('gh auth status', { cwd: project.path, encoding: 'utf-8', stdio: 'pipe' }); + } catch { + return { + success: false, + error: 'Not authenticated with GitHub. Run "gh auth login" in terminal first.' + }; + } + + // Prepare tag name (ensure v prefix) + const tag = version.startsWith('v') ? version : `v${version}`; + + // Build gh release command + const args = ['release', 'create', tag, '--title', tag, '--notes', releaseNotes]; + if (options?.draft) args.push('--draft'); + if (options?.prerelease) args.push('--prerelease'); + + // Create the release + const output = execSync(`gh ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')}`, { + cwd: project.path, + encoding: 'utf-8', + stdio: 'pipe' + }).trim(); + + // Output is typically the release URL + const releaseUrl = output || `https://github.com/releases/tag/${tag}`; + + return { + success: true, + data: { url: releaseUrl } + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to create release'; + // Try to extract more useful error message from stderr + if (error && typeof error === 'object' && 'stderr' in error) { + return { success: false, error: String(error.stderr) || errorMsg }; + } + return { success: false, error: errorMsg }; + } + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/ideation-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/ideation-handlers.ts new file mode 100644 index 00000000..0f6589aa --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/ideation-handlers.ts @@ -0,0 +1,605 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants'; +import type { IPCResult, IdeationSession, IdeationConfig, IdeationGenerationStatus, IdeationStatus, Task, ImplementationPlan, TaskMetadata, TaskCategory, TaskImpact } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { projectStore } from '../project-store'; +import { fileWatcher } from '../file-watcher'; +import { AgentManager } from '../agent'; + + +/** + * Register all ideation-related IPC handlers + */ +export function registerIdeationHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Ideation Operations + // ============================================ + + /** + * Transform an idea from snake_case (Python backend) to camelCase (TypeScript frontend) + */ + const transformIdeaFromSnakeCase = (idea: Record) => { + const base = { + id: idea.id as string, + type: idea.type as string, + title: idea.title as string, + description: idea.description as string, + rationale: idea.rationale as string, + status: idea.status as string || 'draft', + createdAt: idea.created_at ? new Date(idea.created_at as string) : new Date() + }; + + if (idea.type === 'code_improvements') { + return { + ...base, + buildsUpon: idea.builds_upon || idea.buildsUpon || [], + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + existingPatterns: idea.existing_patterns || idea.existingPatterns || [], + implementationApproach: idea.implementation_approach || idea.implementationApproach || '' + }; + } else if (idea.type === 'ui_ux_improvements') { + return { + ...base, + category: idea.category || 'usability', + affectedComponents: idea.affected_components || idea.affectedComponents || [], + screenshots: idea.screenshots || [], + currentState: idea.current_state || idea.currentState || '', + proposedChange: idea.proposed_change || idea.proposedChange || '', + userBenefit: idea.user_benefit || idea.userBenefit || '' + }; + } else if (idea.type === 'documentation_gaps') { + return { + ...base, + category: idea.category || 'readme', + targetAudience: idea.target_audience || idea.targetAudience || 'developers', + affectedAreas: idea.affected_areas || idea.affectedAreas || [], + currentDocumentation: idea.current_documentation || idea.currentDocumentation || '', + proposedContent: idea.proposed_content || idea.proposedContent || '', + priority: idea.priority || 'medium', + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small' + }; + } else if (idea.type === 'security_hardening') { + return { + ...base, + category: idea.category || 'configuration', + severity: idea.severity || 'medium', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + vulnerability: idea.vulnerability || '', + currentRisk: idea.current_risk || idea.currentRisk || '', + remediation: idea.remediation || '', + references: idea.references || [], + compliance: idea.compliance || [] + }; + } else if (idea.type === 'performance_optimizations') { + return { + ...base, + category: idea.category || 'runtime', + impact: idea.impact || 'medium', + affectedAreas: idea.affected_areas || idea.affectedAreas || [], + currentMetric: idea.current_metric || idea.currentMetric || '', + expectedImprovement: idea.expected_improvement || idea.expectedImprovement || '', + implementation: idea.implementation || '', + tradeoffs: idea.tradeoffs || '', + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium' + }; + } else if (idea.type === 'code_quality') { + return { + ...base, + category: idea.category || 'code_smells', + severity: idea.severity || 'minor', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + currentState: idea.current_state || idea.currentState || '', + proposedChange: idea.proposed_change || idea.proposedChange || '', + codeExample: idea.code_example || idea.codeExample || '', + bestPractice: idea.best_practice || idea.bestPractice || '', + metrics: idea.metrics || {}, + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium', + breakingChange: idea.breaking_change ?? idea.breakingChange ?? false, + prerequisites: idea.prerequisites || [] + }; + } + + return base; + }; + + ipcMain.handle( + IPC_CHANNELS.IDEATION_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const rawIdeation = JSON.parse(content); + + // Transform snake_case to camelCase for frontend + const session: IdeationSession = { + id: rawIdeation.id || `ideation-${Date.now()}`, + projectId, + config: { + enabledTypes: rawIdeation.config?.enabled_types || rawIdeation.config?.enabledTypes || [], + includeRoadmapContext: rawIdeation.config?.include_roadmap_context ?? rawIdeation.config?.includeRoadmapContext ?? true, + includeKanbanContext: rawIdeation.config?.include_kanban_context ?? rawIdeation.config?.includeKanbanContext ?? true, + maxIdeasPerType: rawIdeation.config?.max_ideas_per_type || rawIdeation.config?.maxIdeasPerType || 5 + }, + ideas: (rawIdeation.ideas || []).map((idea: Record) => + transformIdeaFromSnakeCase(idea) + ), + projectContext: { + existingFeatures: rawIdeation.project_context?.existing_features || rawIdeation.projectContext?.existingFeatures || [], + techStack: rawIdeation.project_context?.tech_stack || rawIdeation.projectContext?.techStack || [], + targetAudience: rawIdeation.project_context?.target_audience || rawIdeation.projectContext?.targetAudience, + plannedFeatures: rawIdeation.project_context?.planned_features || rawIdeation.projectContext?.plannedFeatures || [] + }, + generatedAt: rawIdeation.generated_at ? new Date(rawIdeation.generated_at) : new Date(), + updatedAt: rawIdeation.updated_at ? new Date(rawIdeation.updated_at) : new Date() + }; + + return { success: true, data: session }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read ideation' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.IDEATION_GENERATE, + (_, projectId: string, config: IdeationConfig) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start ideation generation via agent manager + agentManager.startIdeationGeneration(projectId, project.path, config, false); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Analyzing project structure...' + } as IdeationGenerationStatus + ); + } + ); + + ipcMain.on( + IPC_CHANNELS.IDEATION_REFRESH, + (_, projectId: string, config: IdeationConfig) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start ideation regeneration with refresh flag + agentManager.startIdeationGeneration(projectId, project.path, config, true); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Refreshing ideation...' + } as IdeationGenerationStatus + ); + } + ); + + // Stop ideation generation + ipcMain.handle( + IPC_CHANNELS.IDEATION_STOP, + async (_, projectId: string): Promise => { + const mainWindow = getMainWindow(); + const wasStopped = agentManager.stopIdeation(projectId); + + if (wasStopped && mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId); + } + + return { success: wasStopped }; + } + ); + + // Dismiss all ideas + ipcMain.handle( + IPC_CHANNELS.IDEATION_DISMISS_ALL, + async (_, projectId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Dismiss all ideas that are not already dismissed or converted + let dismissedCount = 0; + ideation.ideas?.forEach((idea: { status: string }) => { + if (idea.status !== 'dismissed' && idea.status !== 'converted') { + idea.status = 'dismissed'; + dismissedCount++; + } + }); + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true, data: { dismissedCount } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to dismiss all ideas' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_UPDATE_IDEA, + async ( + _, + projectId: string, + ideaId: string, + status: IdeationStatus + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find and update the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + idea.status = status; + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update idea' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_DISMISS, + async (_, projectId: string, ideaId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find and dismiss the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + idea.status = 'dismissed'; + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to dismiss idea' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, + async (_, projectId: string, ideaId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + // Generate spec ID by finding next available number + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Ensure specs directory exists + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next spec number + let nextNum = 1; + try { + const existingSpecs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => { + const match = d.name.match(/^(\d+)-/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingSpecs.length > 0) { + nextNum = Math.max(...existingSpecs) + 1; + } + } catch { + // Use default 1 + } + + // Create spec directory name from idea title + const slugifiedTitle = idea.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(nextNum).padStart(3, '0')}-${slugifiedTitle}`; + const specDir = path.join(specsDir, specId); + + // Create the spec directory + mkdirSync(specDir, { recursive: true }); + + // Build task description based on idea type + let taskDescription = `# ${idea.title}\n\n`; + taskDescription += `${idea.description}\n\n`; + taskDescription += `## Rationale\n${idea.rationale}\n\n`; + + // Note: high_value_features removed - strategic features belong to Roadmap + // low_hanging_fruit renamed to code_improvements + if (idea.type === 'code_improvements') { + if (idea.builds_upon?.length) { + taskDescription += `## Builds Upon\n${idea.builds_upon.map((b: string) => `- ${b}`).join('\n')}\n\n`; + } + if (idea.implementation_approach) { + taskDescription += `## Implementation Approach\n${idea.implementation_approach}\n\n`; + } + if (idea.affected_files?.length) { + taskDescription += `## Affected Files\n${idea.affected_files.map((f: string) => `- ${f}`).join('\n')}\n\n`; + } + if (idea.existing_patterns?.length) { + taskDescription += `## Patterns to Follow\n${idea.existing_patterns.map((p: string) => `- ${p}`).join('\n')}\n\n`; + } + } else if (idea.type === 'ui_ux_improvements') { + taskDescription += `## Category\n${idea.category}\n\n`; + taskDescription += `## Current State\n${idea.current_state}\n\n`; + taskDescription += `## Proposed Change\n${idea.proposed_change}\n\n`; + taskDescription += `## User Benefit\n${idea.user_benefit}\n\n`; + if (idea.affected_components?.length) { + taskDescription += `## Affected Components\n${idea.affected_components.map((c: string) => `- ${c}`).join('\n')}\n\n`; + } + } + + // Create initial implementation_plan.json so task shows in kanban immediately + const initialPlan: ImplementationPlan = { + feature: idea.title, + description: idea.description, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + status: 'backlog', + planStatus: 'pending', + phases: [], + workflow_type: 'development', + services_involved: [], + final_acceptance: [], + spec_file: 'spec.md' + }; + writeFileSync( + path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), + JSON.stringify(initialPlan, null, 2) + ); + + // Create initial spec.md with the task description + const specContent = `# ${idea.title} + +## Overview + +${idea.description} + +## Rationale + +${idea.rationale} + +--- +*This spec was created from ideation and is pending detailed specification.* +`; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent); + + // Update idea with converted status + idea.status = 'converted'; + idea.linked_task_id = specId; + ideation.updated_at = new Date().toISOString(); + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + // Build metadata from idea type + const metadata: TaskMetadata = { + sourceType: 'ideation', + ideationType: idea.type, + ideaId: idea.id, + rationale: idea.rationale + }; + + // Map idea type to task category + // Note: high_value_features removed, low_hanging_fruit renamed to code_improvements + const ideaTypeToCategory: Record = { + 'code_improvements': 'feature', + 'ui_ux_improvements': 'ui_ux', + 'documentation_gaps': 'documentation', + 'security_hardening': 'security', + 'performance_optimizations': 'performance', + 'code_quality': 'refactoring' + }; + metadata.category = ideaTypeToCategory[idea.type] || 'feature'; + + // Extract type-specific metadata + // Note: high_value_features removed - strategic features belong to Roadmap + // low_hanging_fruit renamed to code_improvements + if (idea.type === 'code_improvements') { + metadata.estimatedEffort = idea.estimated_effort; + metadata.complexity = idea.estimated_effort; // trivial/small/medium/large/complex + metadata.affectedFiles = idea.affected_files; + } else if (idea.type === 'ui_ux_improvements') { + metadata.uiuxCategory = idea.category; + metadata.affectedFiles = idea.affected_components; + metadata.problemSolved = idea.current_state; + } else if (idea.type === 'documentation_gaps') { + metadata.estimatedEffort = idea.estimated_effort; + metadata.priority = idea.priority; + metadata.targetAudience = idea.target_audience; + metadata.affectedFiles = idea.affected_areas; + } else if (idea.type === 'security_hardening') { + metadata.securitySeverity = idea.severity; + metadata.impact = idea.severity as TaskImpact; // Map severity to impact + metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'high' ? 'high' : 'medium'; + metadata.affectedFiles = idea.affected_files; + } else if (idea.type === 'performance_optimizations') { + metadata.performanceCategory = idea.category; + metadata.impact = idea.impact as TaskImpact; + metadata.estimatedEffort = idea.estimated_effort; + metadata.affectedFiles = idea.affected_areas; + } else if (idea.type === 'code_quality') { + metadata.codeQualitySeverity = idea.severity; + metadata.estimatedEffort = idea.estimated_effort; + metadata.affectedFiles = idea.affected_files; + metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'major' ? 'high' : 'medium'; + } + + // Save metadata to a separate file for persistence + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + + // Task is created in Planning (backlog) - user must manually start it + // Previously auto-started spec creation here, but user should control when to start + + // Create task object to return + const task: Task = { + id: specId, + specId: specId, + projectId, + title: idea.title, + description: taskDescription, + status: 'backlog', + subtasks: [], + logs: [], + metadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert idea to task' + }; + } + } + ); + + // ============================================ +} diff --git a/auto-claude-ui/src/main/ipc-handlers/index.ts b/auto-claude-ui/src/main/ipc-handlers/index.ts new file mode 100644 index 00000000..5dc4ca8b --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/index.ts @@ -0,0 +1,109 @@ +/** + * IPC Handlers Module Index + * + * This module exports a single setup function that registers all IPC handlers + * organized by domain into separate handler modules. + */ + +import type { BrowserWindow } from 'electron'; +import { AgentManager } from '../agent'; +import { TerminalManager } from '../terminal-manager'; +import { PythonEnvManager } from '../python-env-manager'; + +// Import all handler registration functions +import { registerProjectHandlers } from './project-handlers'; +import { registerTaskHandlers } from './task-handlers'; +import { registerTerminalHandlers } from './terminal-handlers'; +import { registerAgenteventsHandlers } from './agent-events-handlers'; +import { registerSettingsHandlers } from './settings-handlers'; +import { registerFileHandlers } from './file-handlers'; +import { registerRoadmapHandlers } from './roadmap-handlers'; +import { registerContextHandlers } from './context-handlers'; +import { registerEnvHandlers } from './env-handlers'; +import { registerLinearHandlers } from './linear-handlers'; +import { registerGithubHandlers } from './github-handlers'; +import { registerAutobuildSourceHandlers } from './autobuild-source-handlers'; +import { registerIdeationHandlers } from './ideation-handlers'; +import { registerChangelogHandlers } from './changelog-handlers'; +import { registerInsightsHandlers } from './insights-handlers'; + +/** + * Setup all IPC handlers across all domains + * + * @param agentManager - The agent manager instance + * @param terminalManager - The terminal manager instance + * @param getMainWindow - Function to get the main BrowserWindow + * @param pythonEnvManager - The Python environment manager instance + */ +export function setupIpcHandlers( + agentManager: AgentManager, + terminalManager: TerminalManager, + getMainWindow: () => BrowserWindow | null, + pythonEnvManager: PythonEnvManager +): void { + // Project handlers (including Python environment setup) + registerProjectHandlers(pythonEnvManager, agentManager, getMainWindow); + + // Task handlers + registerTaskHandlers(agentManager, pythonEnvManager, getMainWindow); + + // Terminal and Claude profile handlers + registerTerminalHandlers(terminalManager, getMainWindow); + + // Agent event handlers (event forwarding from agent manager to renderer) + registerAgenteventsHandlers(agentManager, getMainWindow); + + // Settings and dialog handlers + registerSettingsHandlers(agentManager, getMainWindow); + + // File explorer handlers + registerFileHandlers(); + + // Roadmap handlers + registerRoadmapHandlers(agentManager, getMainWindow); + + // Context and memory handlers + registerContextHandlers(getMainWindow); + + // Environment configuration handlers + registerEnvHandlers(getMainWindow); + + // Linear integration handlers + registerLinearHandlers(agentManager, getMainWindow); + + // GitHub integration handlers + registerGithubHandlers(agentManager, getMainWindow); + + // Auto-build source update handlers + registerAutobuildSourceHandlers(getMainWindow); + + // Ideation handlers + registerIdeationHandlers(agentManager, getMainWindow); + + // Changelog handlers + registerChangelogHandlers(getMainWindow); + + // Insights handlers + registerInsightsHandlers(getMainWindow); + + console.log('[IPC] All handler modules registered successfully'); +} + +// Re-export all individual registration functions for potential custom usage +export { + registerProjectHandlers, + registerTaskHandlers, + registerTerminalHandlers, + registerAgenteventsHandlers, + registerSettingsHandlers, + registerFileHandlers, + registerRoadmapHandlers, + registerContextHandlers, + registerEnvHandlers, + registerLinearHandlers, + registerGithubHandlers, + registerAutobuildSourceHandlers, + registerIdeationHandlers, + registerChangelogHandlers, + registerInsightsHandlers +}; diff --git a/auto-claude-ui/src/main/ipc-handlers/insights-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/insights-handlers.ts new file mode 100644 index 00000000..8db91bc3 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/insights-handlers.ts @@ -0,0 +1,244 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import path from 'path'; +import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'fs'; +import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; +import type { IPCResult, InsightsSession, InsightsSessionSummary, Task, TaskMetadata } from '../../shared/types'; +import { projectStore } from '../project-store'; +import { insightsService } from '../insights-service'; + +/** + * Register all insights-related IPC handlers + */ +export function registerInsightsHandlers( + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Insights Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_GET_SESSION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.loadSession(projectId, project.path); + return { success: true, data: session }; + } + ); + + ipcMain.on( + IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, + async (_, projectId: string, message: string) => { + const project = projectStore.getProject(projectId); + if (!project) { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, 'Project not found'); + } + return; + } + + // Note: Python environment initialization should be handled by insightsService + // or added here with proper dependency injection if needed + insightsService.sendMessage(projectId, project.path, message); + } + ); + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, + async (_, projectId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + insightsService.clearSession(projectId, project.path); + return { success: true }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_CREATE_TASK, + async ( + _, + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Auto Claude not initialized for this project' }; + } + + try { + // Generate a unique spec ID based on existing specs + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Find next available spec number + let specNumber = 1; + if (existsSync(specsDir)) { + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Build metadata with source type + const taskMetadata: TaskMetadata = { + sourceType: 'insights', + ...metadata + }; + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + + // Save task metadata + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + + // Create the task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title, + description, + status: 'backlog', + subtasks: [], + logs: [], + metadata: taskMetadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create task' + }; + } + } + ); + + // List all sessions for a project + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const sessions = insightsService.listSessions(project.path); + return { success: true, data: sessions }; + } + ); + + // Create a new session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_NEW_SESSION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.createNewSession(projectId, project.path); + return { success: true, data: session }; + } + ); + + // Switch to a different session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_SWITCH_SESSION, + async (_, projectId: string, sessionId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.switchSession(projectId, project.path, sessionId); + return { success: true, data: session }; + } + ); + + // Delete a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_DELETE_SESSION, + async (_, projectId: string, sessionId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const success = insightsService.deleteSession(projectId, project.path, sessionId); + if (success) { + return { success: true }; + } + return { success: false, error: 'Failed to delete session' }; + } + ); + + // Rename a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_RENAME_SESSION, + async (_, projectId: string, sessionId: string, newTitle: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const success = insightsService.renameSession(project.path, sessionId, newTitle); + if (success) { + return { success: true }; + } + return { success: false, error: 'Failed to rename session' }; + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/linear-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/linear-handlers.ts new file mode 100644 index 00000000..add15160 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/linear-handlers.ts @@ -0,0 +1,525 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants'; +import type { IPCResult, LinearIssue, LinearTeam, LinearProject, LinearImportResult, LinearSyncStatus, Project, Task, TaskMetadata } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs'; +import { spawn } from 'child_process'; +import { projectStore } from '../project-store'; +import { parseEnvFile } from './utils'; + + +import { AgentManager } from '../agent'; + +/** + * Register all linear-related IPC handlers + */ +export function registerLinearHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Linear Integration Operations + // ============================================ + + /** + * Helper to get Linear API key from project env + */ + const getLinearApiKey = (project: Project): string | null => { + if (!project.autoBuildPath) return null; + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + if (!existsSync(envPath)) return null; + + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + return vars['LINEAR_API_KEY'] || null; + } catch { + return null; + } + }; + + /** + * Make a request to the Linear API + */ + const linearGraphQL = async ( + apiKey: string, + query: string, + variables?: Record + ): Promise => { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': apiKey + }, + body: JSON.stringify({ query, variables }) + }); + + if (!response.ok) { + throw new Error(`Linear API error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + if (result.errors) { + throw new Error(result.errors[0]?.message || 'Linear API error'); + } + + return result.data; + }; + + ipcMain.handle( + IPC_CHANNELS.LINEAR_CHECK_CONNECTION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { + success: true, + data: { + connected: false, + error: 'No Linear API key configured' + } + }; + } + + try { + const query = ` + query { + viewer { + id + name + } + teams { + nodes { + id + name + key + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + viewer: { id: string; name: string }; + teams: { nodes: Array<{ id: string; name: string; key: string }> }; + }; + + // Get issue count for the first team + let issueCount = 0; + let teamName: string | undefined; + + if (data.teams.nodes.length > 0) { + teamName = data.teams.nodes[0].name; + const countQuery = ` + query($teamId: String!) { + team(id: $teamId) { + issues { + totalCount: nodes { id } + } + } + } + `; + // Get approximate count + const issuesQuery = ` + query($teamId: String!) { + issues(filter: { team: { id: { eq: $teamId } } }, first: 0) { + pageInfo { + hasNextPage + } + } + } + `; + + // Simple count estimation - get first 250 issues + const countData = await linearGraphQL(apiKey, ` + query($teamId: String!) { + issues(filter: { team: { id: { eq: $teamId } } }, first: 250) { + nodes { id } + } + } + `, { teamId: data.teams.nodes[0].id }) as { + issues: { nodes: Array<{ id: string }> }; + }; + issueCount = countData.issues.nodes.length; + } + + return { + success: true, + data: { + connected: true, + teamName, + issueCount, + lastSyncedAt: new Date().toISOString() + } + }; + } catch (error) { + return { + success: true, + data: { + connected: false, + error: error instanceof Error ? error.message : 'Failed to connect to Linear' + } + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_TEAMS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + const query = ` + query { + teams { + nodes { + id + name + key + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + teams: { nodes: LinearTeam[] }; + }; + + return { success: true, data: data.teams.nodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch teams' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_PROJECTS, + async (_, projectId: string, teamId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + const query = ` + query($teamId: String!) { + team(id: $teamId) { + projects { + nodes { + id + name + state + } + } + } + } + `; + + const data = await linearGraphQL(apiKey, query, { teamId }) as { + team: { projects: { nodes: LinearProject[] } }; + }; + + return { success: true, data: data.team.projects.nodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch projects' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_ISSUES, + async (_, projectId: string, teamId?: string, linearProjectId?: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + // Build filter based on provided parameters + const filters: string[] = []; + if (teamId) { + filters.push(`team: { id: { eq: "${teamId}" } }`); + } + if (linearProjectId) { + filters.push(`project: { id: { eq: "${linearProjectId}" } }`); + } + + const filterClause = filters.length > 0 ? `filter: { ${filters.join(', ')} }` : ''; + + const query = ` + query { + issues(${filterClause}, first: 250, orderBy: updatedAt) { + nodes { + id + identifier + title + description + state { + id + name + type + } + priority + priorityLabel + labels { + nodes { + id + name + color + } + } + assignee { + id + name + email + } + project { + id + name + } + createdAt + updatedAt + url + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + issues: { + nodes: Array<{ + id: string; + identifier: string; + title: string; + description?: string; + state: { id: string; name: string; type: string }; + priority: number; + priorityLabel: string; + labels: { nodes: Array<{ id: string; name: string; color: string }> }; + assignee?: { id: string; name: string; email: string }; + project?: { id: string; name: string }; + createdAt: string; + updatedAt: string; + url: string; + }>; + }; + }; + + // Transform to our LinearIssue format + const issues: LinearIssue[] = data.issues.nodes.map(issue => ({ + ...issue, + labels: issue.labels.nodes + })); + + return { success: true, data: issues }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issues' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_IMPORT_ISSUES, + async (_, projectId: string, issueIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + // First, fetch the full details of selected issues + const query = ` + query($ids: [String!]!) { + issues(filter: { id: { in: $ids } }) { + nodes { + id + identifier + title + description + state { + id + name + type + } + priority + priorityLabel + labels { + nodes { + id + name + color + } + } + url + } + } + } + `; + + const data = await linearGraphQL(apiKey, query, { ids: issueIds }) as { + issues: { + nodes: Array<{ + id: string; + identifier: string; + title: string; + description?: string; + state: { id: string; name: string; type: string }; + priority: number; + priorityLabel: string; + labels: { nodes: Array<{ id: string; name: string; color: string }> }; + url: string; + }>; + }; + }; + + let imported = 0; + let failed = 0; + const errors: string[] = []; + + // Set up specs directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Create tasks for each imported issue + for (const issue of data.issues.nodes) { + try { + // Build description from Linear issue + const labels = issue.labels.nodes.map(l => l.name).join(', '); + const description = `# ${issue.title} + +**Linear Issue:** [${issue.identifier}](${issue.url}) +**Priority:** ${issue.priorityLabel} +**Status:** ${issue.state.name} +${labels ? `**Labels:** ${labels}` : ''} + +## Description + +${issue.description || 'No description provided.'} +`; + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: description, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'linear', + linearIssueId: issue.id, + linearIdentifier: issue.identifier, + linearUrl: issue.url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, description, specDir, metadata); + + imported++; + } catch (err) { + failed++; + errors.push(`Failed to import ${issue.identifier}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + return { + success: true, + data: { + success: failed === 0, + imported, + failed, + errors: errors.length > 0 ? errors : undefined + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to import issues' + }; + } + } + ); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/project-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/project-handlers.ts new file mode 100644 index 00000000..22806980 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/project-handlers.ts @@ -0,0 +1,333 @@ +import { ipcMain, app } from 'electron'; +import { existsSync, readFileSync } from 'fs'; +import path from 'path'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + Project, + ProjectSettings, + IPCResult, + InitializationResult, + AutoBuildVersionInfo +} from '../../shared/types'; +import { projectStore } from '../project-store'; +import { + initializeProject, + isInitialized, + hasLocalSource +} from '../project-initializer'; +import { PythonEnvManager, type PythonEnvStatus } from '../python-env-manager'; +import { AgentManager } from '../agent'; +import { changelogService } from '../changelog-service'; +import { insightsService } from '../insights-service'; +import { titleGenerator } from '../title-generator'; +import type { BrowserWindow } from 'electron'; + +const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + +/** + * Auto-detect the auto-claude source path relative to the app location + * In dev: auto-claude-ui/../auto-claude + * In prod: Could be bundled or configured + */ +const detectAutoBuildSourcePath = (): string | null => { + // Try relative to app directory (works in dev and if repo structure is maintained) + // __dirname in main process points to out/main in dev + const possiblePaths = [ + // Dev mode: from out/main -> ../../../auto-claude (sibling to auto-claude-ui) + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + // Alternative: from app root (useful in some packaged scenarios) + path.resolve(app.getAppPath(), '..', 'auto-claude'), + // If running from repo root + path.resolve(process.cwd(), 'auto-claude'), + // Try one more level up (in case of different build output structure) + path.resolve(__dirname, '..', '..', 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; +}; + +/** + * Get the configured auto-claude source path from settings, or auto-detect + */ +const getAutoBuildSourcePath = (): string | null => { + // First check if manually configured + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + if (settings.autoBuildPath && existsSync(settings.autoBuildPath)) { + return settings.autoBuildPath; + } + } catch { + // Fall through to auto-detect + } + } + + // Auto-detect from app location + return detectAutoBuildSourcePath(); +}; + +/** + * Configure all Python-dependent services with the managed Python path + */ +const configureServicesWithPython = ( + pythonPath: string, + autoBuildPath: string, + agentManager: AgentManager +): void => { + console.log('[IPC] Configuring services with Python:', pythonPath); + agentManager.configure(pythonPath, autoBuildPath); + changelogService.configure(pythonPath, autoBuildPath); + insightsService.configure(pythonPath, autoBuildPath); + titleGenerator.configure(pythonPath, autoBuildPath); +}; + +/** + * Initialize the Python environment and configure services + */ +const initializePythonEnvironment = async ( + pythonEnvManager: PythonEnvManager, + agentManager: AgentManager +): Promise => { + const autoBuildSource = getAutoBuildSourcePath(); + if (!autoBuildSource) { + console.log('[IPC] Auto-build source not found, skipping Python env init'); + return { + ready: false, + pythonPath: null, + venvExists: false, + depsInstalled: false, + error: 'Auto-build source not found' + }; + } + + console.log('[IPC] Initializing Python environment...'); + const status = await pythonEnvManager.initialize(autoBuildSource); + + if (status.ready && status.pythonPath) { + configureServicesWithPython(status.pythonPath, autoBuildSource, agentManager); + } + + return status; +}; + +/** + * Register all project-related IPC handlers + */ +export function registerProjectHandlers( + pythonEnvManager: PythonEnvManager, + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Project Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.PROJECT_ADD, + async (_, projectPath: string): Promise> => { + try { + // Validate path exists + if (!existsSync(projectPath)) { + return { success: false, error: 'Directory does not exist' }; + } + + const project = projectStore.addProject(projectPath); + return { success: true, data: project }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROJECT_REMOVE, + async (_, projectId: string): Promise => { + const success = projectStore.removeProject(projectId); + return { success }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROJECT_LIST, + async (): Promise> => { + // Validate that .auto-claude folders still exist for all projects + // If a folder was deleted, reset autoBuildPath so UI prompts for reinitialization + const resetIds = projectStore.validateProjects(); + if (resetIds.length > 0) { + console.log('[IPC] PROJECT_LIST: Detected missing .auto-claude folders for', resetIds.length, 'project(s)'); + } + + const projects = projectStore.getProjects(); + console.log('[IPC] PROJECT_LIST returning', projects.length, 'projects'); + return { success: true, data: projects }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROJECT_UPDATE_SETTINGS, + async ( + _, + projectId: string, + settings: Partial + ): Promise => { + const project = projectStore.updateProjectSettings(projectId, settings); + if (project) { + return { success: true }; + } + return { success: false, error: 'Project not found' }; + } + ); + + // ============================================ + // Project Initialization Operations + // ============================================ + + // Set up Python environment status events + pythonEnvManager.on('status', (message: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('python-env:status', message); + } + }); + + pythonEnvManager.on('error', (error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('python-env:error', error); + } + }); + + pythonEnvManager.on('ready', (pythonPath: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('python-env:ready', pythonPath); + } + }); + + // Initialize Python environment on startup (non-blocking) + initializePythonEnvironment(pythonEnvManager, agentManager).then((status) => { + console.log('[IPC] Python environment initialized:', status); + }); + + // IPC handler to get Python environment status + ipcMain.handle( + 'python-env:get-status', + async (): Promise> => { + const status = await pythonEnvManager.getStatus(); + return { success: true, data: status }; + } + ); + + // IPC handler to reinitialize Python environment + ipcMain.handle( + 'python-env:reinitialize', + async (): Promise> => { + const status = await initializePythonEnvironment(pythonEnvManager, agentManager); + return { success: status.ready, data: status, error: status.error }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.PROJECT_INITIALIZE, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const result = initializeProject(project.path); + + if (result.success) { + // Update project's autoBuildPath + projectStore.updateAutoBuildPath(projectId, '.auto-claude'); + } + + return { success: result.success, data: result, error: result.error }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // PROJECT_UPDATE_AUTOBUILD is deprecated - .auto-claude only contains data, no code to update + // Kept for API compatibility, returns success immediately + ipcMain.handle( + IPC_CHANNELS.PROJECT_UPDATE_AUTOBUILD, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Nothing to update - .auto-claude only contains data directories + // The framework runs from the source repo + return { success: true, data: { success: true } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // PROJECT_CHECK_VERSION now just checks if project is initialized + // Version tracking for .auto-claude is removed since it only contains data + ipcMain.handle( + IPC_CHANNELS.PROJECT_CHECK_VERSION, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + return { + success: true, + data: { + isInitialized: isInitialized(project.path), + updateAvailable: false // No updates for .auto-claude - it's just data + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + // Check if project has local auto-claude source (is dev project) + ipcMain.handle( + 'project:has-local-source', + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + return { success: true, data: hasLocalSource(project.path) }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/roadmap-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/roadmap-handlers.ts new file mode 100644 index 00000000..c82b6f3f --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/roadmap-handlers.ts @@ -0,0 +1,387 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants'; +import type { IPCResult, Roadmap, RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, Task, TaskMetadata } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; +import { projectStore } from '../project-store'; +import { fileWatcher } from '../file-watcher'; +import { AgentManager } from '../agent'; + + +/** + * Register all roadmap-related IPC handlers + */ +export function registerRoadmapHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Roadmap Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const rawRoadmap = JSON.parse(content); + + // Transform snake_case to camelCase for frontend + const roadmap: Roadmap = { + id: rawRoadmap.id || `roadmap-${Date.now()}`, + projectId, + projectName: rawRoadmap.project_name || project.name, + version: rawRoadmap.version || '1.0', + vision: rawRoadmap.vision || '', + targetAudience: { + primary: rawRoadmap.target_audience?.primary || '', + secondary: rawRoadmap.target_audience?.secondary || [] + }, + phases: (rawRoadmap.phases || []).map((phase: Record) => ({ + id: phase.id, + name: phase.name, + description: phase.description, + order: phase.order, + status: phase.status || 'planned', + features: phase.features || [], + milestones: (phase.milestones as Array> || []).map((m) => ({ + id: m.id, + title: m.title, + description: m.description, + features: m.features || [], + status: m.status || 'planned', + targetDate: m.target_date ? new Date(m.target_date as string) : undefined + })) + })), + features: (rawRoadmap.features || []).map((feature: Record) => ({ + id: feature.id, + title: feature.title, + description: feature.description, + rationale: feature.rationale || '', + priority: feature.priority || 'should', + complexity: feature.complexity || 'medium', + impact: feature.impact || 'medium', + phaseId: feature.phase_id, + dependencies: feature.dependencies || [], + status: feature.status || 'idea', + acceptanceCriteria: feature.acceptance_criteria || [], + userStories: feature.user_stories || [], + linkedSpecId: feature.linked_spec_id + })), + status: rawRoadmap.status || 'draft', + createdAt: rawRoadmap.metadata?.created_at ? new Date(rawRoadmap.metadata.created_at) : new Date(), + updatedAt: rawRoadmap.metadata?.updated_at ? new Date(rawRoadmap.metadata.updated_at) : new Date() + }; + + return { success: true, data: roadmap }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read roadmap' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_GENERATE, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap generation via agent manager + agentManager.startRoadmapGeneration(projectId, project.path, false); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Analyzing project structure...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_REFRESH, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap regeneration with refresh flag + agentManager.startRoadmapGeneration(projectId, project.path, true); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Refreshing roadmap...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, + async ( + _, + projectId: string, + featureId: string, + status: RoadmapFeatureStatus + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find and update the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + feature.status = status; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update feature' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, + async ( + _, + projectId: string, + featureId: string + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + // Build task description from feature + const taskDescription = `# ${feature.title} + +${feature.description} + +## Rationale +${feature.rationale || 'N/A'} + +## User Stories +${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'} + +## Acceptance Criteria +${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'} +`; + + // Generate proper spec directory (like task creation) + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Ensure specs directory exists + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next available spec number + let specNumber = 1; + const existingDirs = existsSync(specsDir) + ? readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + : []; + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = feature.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: feature.title, + description: taskDescription, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: taskDescription, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'roadmap', + featureId: feature.id, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + + // Update feature with linked spec + feature.status = 'planned'; + feature.linked_spec_id = specId; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + // Create task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: feature.title, + description: taskDescription, + status: 'backlog', + subtasks: [], + logs: [], + metadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert feature to spec' + }; + } + } + ); + + // ============================================ + // Roadmap Agent Events → Renderer + // ============================================ + + agentManager.on('roadmap-progress', (projectId: string, status: RoadmapGenerationStatus) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_PROGRESS, projectId, status); + } + }); + + agentManager.on('roadmap-complete', (projectId: string, roadmap: Roadmap) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_COMPLETE, projectId, roadmap); + } + }); + + agentManager.on('roadmap-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_ERROR, projectId, error); + } + }); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/context-roadmap-section.txt b/auto-claude-ui/src/main/ipc-handlers/sections/context-roadmap-section.txt new file mode 100644 index 00000000..ec24a7a5 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/context-roadmap-section.txt @@ -0,0 +1,877 @@ + // ============================================ + // Roadmap Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const rawRoadmap = JSON.parse(content); + + // Transform snake_case to camelCase for frontend + const roadmap: Roadmap = { + id: rawRoadmap.id || `roadmap-${Date.now()}`, + projectId, + projectName: rawRoadmap.project_name || project.name, + version: rawRoadmap.version || '1.0', + vision: rawRoadmap.vision || '', + targetAudience: { + primary: rawRoadmap.target_audience?.primary || '', + secondary: rawRoadmap.target_audience?.secondary || [] + }, + phases: (rawRoadmap.phases || []).map((phase: Record) => ({ + id: phase.id, + name: phase.name, + description: phase.description, + order: phase.order, + status: phase.status || 'planned', + features: phase.features || [], + milestones: (phase.milestones as Array> || []).map((m) => ({ + id: m.id, + title: m.title, + description: m.description, + features: m.features || [], + status: m.status || 'planned', + targetDate: m.target_date ? new Date(m.target_date as string) : undefined + })) + })), + features: (rawRoadmap.features || []).map((feature: Record) => ({ + id: feature.id, + title: feature.title, + description: feature.description, + rationale: feature.rationale || '', + priority: feature.priority || 'should', + complexity: feature.complexity || 'medium', + impact: feature.impact || 'medium', + phaseId: feature.phase_id, + dependencies: feature.dependencies || [], + status: feature.status || 'idea', + acceptanceCriteria: feature.acceptance_criteria || [], + userStories: feature.user_stories || [], + linkedSpecId: feature.linked_spec_id + })), + status: rawRoadmap.status || 'draft', + createdAt: rawRoadmap.metadata?.created_at ? new Date(rawRoadmap.metadata.created_at) : new Date(), + updatedAt: rawRoadmap.metadata?.updated_at ? new Date(rawRoadmap.metadata.updated_at) : new Date() + }; + + return { success: true, data: roadmap }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read roadmap' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_GENERATE, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap generation via agent manager + agentManager.startRoadmapGeneration(projectId, project.path, false); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Analyzing project structure...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_REFRESH, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap regeneration with refresh flag + agentManager.startRoadmapGeneration(projectId, project.path, true); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Refreshing roadmap...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, + async ( + _, + projectId: string, + featureId: string, + status: RoadmapFeatureStatus + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find and update the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + feature.status = status; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update feature' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, + async ( + _, + projectId: string, + featureId: string + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + // Build task description from feature + const taskDescription = `# ${feature.title} + +${feature.description} + +## Rationale +${feature.rationale || 'N/A'} + +## User Stories +${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'} + +## Acceptance Criteria +${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'} +`; + + // Generate proper spec directory (like task creation) + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Ensure specs directory exists + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next available spec number + let specNumber = 1; + const existingDirs = existsSync(specsDir) + ? readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + : []; + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = feature.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: feature.title, + description: taskDescription, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: taskDescription, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'roadmap', + featureId: feature.id, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + + // Update feature with linked spec + feature.status = 'planned'; + feature.linked_spec_id = specId; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + // Create task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: feature.title, + description: taskDescription, + status: 'backlog', + subtasks: [], + logs: [], + metadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert feature to spec' + }; + } + } + ); + + // ============================================ + // Roadmap Agent Events → Renderer + // ============================================ + + agentManager.on('roadmap-progress', (projectId: string, status: RoadmapGenerationStatus) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_PROGRESS, projectId, status); + } + }); + + agentManager.on('roadmap-complete', (projectId: string, roadmap: Roadmap) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_COMPLETE, projectId, roadmap); + } + }); + + agentManager.on('roadmap-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_ERROR, projectId, error); + } + }); + + // ============================================ + // Context Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Load project index + let projectIndex: ProjectIndex | null = null; + const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + if (existsSync(indexPath)) { + const content = readFileSync(indexPath, 'utf-8'); + projectIndex = JSON.parse(content); + } + + // Load graphiti state from most recent spec or project root + let memoryState: GraphitiMemoryState | null = null; + let memoryStatus: GraphitiMemoryStatus = { + enabled: false, + available: false, + reason: 'Graphiti not configured' + }; + + // Check for graphiti state in specs + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const specDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of specDirs) { + const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE); + if (existsSync(statePath)) { + const stateContent = readFileSync(statePath, 'utf-8'); + memoryState = JSON.parse(stateContent); + + // If we found a state, update memory status + if (memoryState?.initialized) { + memoryStatus = { + enabled: true, + available: true, + database: memoryState.database || 'auto_build_memory', + host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost', + port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10) + }; + } + break; + } + } + } + + // Check environment for Graphiti config if not found in specs + if (!memoryState) { + // Load project .env file and global settings to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (graphitiEnabled && hasOpenAI) { + memoryStatus = { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + }; + } else if (graphitiEnabled && !hasOpenAI) { + memoryStatus = { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)' + }; + } + } + + // Load recent memories from file-based memory (session insights) + const recentMemories: MemoryEpisode[] = []; + if (existsSync(specsDir)) { + const recentSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse() + .slice(0, 10); // Last 10 specs + + for (const specDir of recentSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + // Load session insights from session_insights subdirectory + const sessionInsightsDir = path.join(memoryDir, 'session_insights'); + if (existsSync(sessionInsightsDir)) { + const sessionFiles = readdirSync(sessionInsightsDir) + .filter((f: string) => f.startsWith('session_') && f.endsWith('.json')) + .sort() + .reverse(); + + for (const sessionFile of sessionFiles.slice(0, 3)) { + try { + const sessionPath = path.join(sessionInsightsDir, sessionFile); + const sessionContent = readFileSync(sessionPath, 'utf-8'); + const sessionData = JSON.parse(sessionContent); + + // Session files have: session_number, timestamp, subtasks_completed, + // discoveries, what_worked, what_failed, recommendations_for_next_session + if (sessionData.session_number !== undefined) { + recentMemories.push({ + id: `${specDir}-${sessionFile}`, + type: 'session_insight', + timestamp: sessionData.timestamp || new Date().toISOString(), + content: JSON.stringify({ + discoveries: sessionData.discoveries, + what_worked: sessionData.what_worked, + what_failed: sessionData.what_failed, + recommendations: sessionData.recommendations_for_next_session, + subtasks_completed: sessionData.subtasks_completed + }, null, 2), + session_number: sessionData.session_number + }); + } + } catch { + // Skip invalid files + } + } + } + + // Also load codebase_map.json as a memory item + const codebaseMapPath = path.join(memoryDir, 'codebase_map.json'); + if (existsSync(codebaseMapPath)) { + try { + const mapContent = readFileSync(codebaseMapPath, 'utf-8'); + const mapData = JSON.parse(mapContent); + if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) { + recentMemories.push({ + id: `${specDir}-codebase_map`, + type: 'codebase_map', + timestamp: mapData.last_updated || new Date().toISOString(), + content: JSON.stringify(mapData.discovered_files, null, 2), + session_number: undefined + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { + success: true, + data: { + projectIndex, + memoryStatus, + memoryState, + recentMemories: recentMemories.slice(0, 20), + isLoading: false + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load project context' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_REFRESH_INDEX, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Run the analyzer script to regenerate project_index.json + const autoBuildSource = getAutoBuildSourcePath(); + + if (!autoBuildSource) { + return { + success: false, + error: 'Auto-build source path not configured' + }; + } + + const analyzerPath = path.join(autoBuildSource, 'analyzer.py'); + const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + + // Run analyzer + await new Promise((resolve, reject) => { + const proc = spawn('python', [ + analyzerPath, + '--project-dir', project.path, + '--output', indexOutputPath + ], { + cwd: project.path, + env: { ...process.env } + }); + + proc.on('close', (code: number) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Analyzer exited with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + + // Read the new index + if (existsSync(indexOutputPath)) { + const content = readFileSync(indexOutputPath, 'utf-8'); + const projectIndex = JSON.parse(content); + return { success: true, data: projectIndex }; + } + + return { success: false, error: 'Failed to generate project index' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to refresh project index' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_MEMORY_STATUS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Load project .env file to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (!graphitiEnabled) { + return { + success: true, + data: { + enabled: false, + available: false, + reason: 'GRAPHITI_ENABLED not set to true' + } + }; + } + + if (!hasOpenAI) { + return { + success: true, + data: { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for embeddings)' + } + }; + } + + return { + success: true, + data: { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + } + }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, + async (_, projectId: string, query: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // For now, do simple text search in file-based memories + // Graphiti search would require running Python subprocess + const results: ContextSearchResult[] = []; + const queryLower = query.toLowerCase(); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const allSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }); + + for (const specDir of allSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + + if (memContent.toLowerCase().includes(queryLower)) { + const memData = JSON.parse(memContent); + results.push({ + content: JSON.stringify(memData.insights || memData, null, 2), + score: 1.0, + type: 'session_insight' + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { success: true, data: results.slice(0, 20) }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET_MEMORIES, + async (_, projectId: string, limit: number = 20): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const memories: MemoryEpisode[] = []; + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + if (existsSync(specsDir)) { + const sortedSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of sortedSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')) + .sort() + .reverse(); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + const memData = JSON.parse(memContent); + + memories.push({ + id: `${specDir}-${memFile}`, + type: memData.type || 'session_insight', + timestamp: memData.timestamp || new Date().toISOString(), + content: JSON.stringify(memData.insights || memData, null, 2), + session_number: memData.session_number + }); + + if (memories.length >= limit) { + break; + } + } catch { + // Skip invalid files + } + } + } + + if (memories.length >= limit) { + break; + } + } + } + + return { success: true, data: memories }; + } + ); + diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/context_extracted.txt b/auto-claude-ui/src/main/ipc-handlers/sections/context_extracted.txt new file mode 100644 index 00000000..a7e747af --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/context_extracted.txt @@ -0,0 +1,508 @@ + // ============================================ + // Context Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Load project index + let projectIndex: ProjectIndex | null = null; + const indexPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + if (existsSync(indexPath)) { + const content = readFileSync(indexPath, 'utf-8'); + projectIndex = JSON.parse(content); + } + + // Load graphiti state from most recent spec or project root + let memoryState: GraphitiMemoryState | null = null; + let memoryStatus: GraphitiMemoryStatus = { + enabled: false, + available: false, + reason: 'Graphiti not configured' + }; + + // Check for graphiti state in specs + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const specDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of specDirs) { + const statePath = path.join(specsDir, specDir, AUTO_BUILD_PATHS.GRAPHITI_STATE); + if (existsSync(statePath)) { + const stateContent = readFileSync(statePath, 'utf-8'); + memoryState = JSON.parse(stateContent); + + // If we found a state, update memory status + if (memoryState?.initialized) { + memoryStatus = { + enabled: true, + available: true, + database: memoryState.database || 'auto_build_memory', + host: process.env.GRAPHITI_FALKORDB_HOST || 'localhost', + port: parseInt(process.env.GRAPHITI_FALKORDB_PORT || '6380', 10) + }; + } + break; + } + } + } + + // Check environment for Graphiti config if not found in specs + if (!memoryState) { + // Load project .env file and global settings to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (graphitiEnabled && hasOpenAI) { + memoryStatus = { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + }; + } else if (graphitiEnabled && !hasOpenAI) { + memoryStatus = { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for Graphiti embeddings)' + }; + } + } + + // Load recent memories from file-based memory (session insights) + const recentMemories: MemoryEpisode[] = []; + if (existsSync(specsDir)) { + const recentSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse() + .slice(0, 10); // Last 10 specs + + for (const specDir of recentSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + // Load session insights from session_insights subdirectory + const sessionInsightsDir = path.join(memoryDir, 'session_insights'); + if (existsSync(sessionInsightsDir)) { + const sessionFiles = readdirSync(sessionInsightsDir) + .filter((f: string) => f.startsWith('session_') && f.endsWith('.json')) + .sort() + .reverse(); + + for (const sessionFile of sessionFiles.slice(0, 3)) { + try { + const sessionPath = path.join(sessionInsightsDir, sessionFile); + const sessionContent = readFileSync(sessionPath, 'utf-8'); + const sessionData = JSON.parse(sessionContent); + + // Session files have: session_number, timestamp, subtasks_completed, + // discoveries, what_worked, what_failed, recommendations_for_next_session + if (sessionData.session_number !== undefined) { + recentMemories.push({ + id: `${specDir}-${sessionFile}`, + type: 'session_insight', + timestamp: sessionData.timestamp || new Date().toISOString(), + content: JSON.stringify({ + discoveries: sessionData.discoveries, + what_worked: sessionData.what_worked, + what_failed: sessionData.what_failed, + recommendations: sessionData.recommendations_for_next_session, + subtasks_completed: sessionData.subtasks_completed + }, null, 2), + session_number: sessionData.session_number + }); + } + } catch { + // Skip invalid files + } + } + } + + // Also load codebase_map.json as a memory item + const codebaseMapPath = path.join(memoryDir, 'codebase_map.json'); + if (existsSync(codebaseMapPath)) { + try { + const mapContent = readFileSync(codebaseMapPath, 'utf-8'); + const mapData = JSON.parse(mapContent); + if (mapData.discovered_files && Object.keys(mapData.discovered_files).length > 0) { + recentMemories.push({ + id: `${specDir}-codebase_map`, + type: 'codebase_map', + timestamp: mapData.last_updated || new Date().toISOString(), + content: JSON.stringify(mapData.discovered_files, null, 2), + session_number: undefined + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { + success: true, + data: { + projectIndex, + memoryStatus, + memoryState, + recentMemories: recentMemories.slice(0, 20), + isLoading: false + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load project context' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_REFRESH_INDEX, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Run the analyzer script to regenerate project_index.json + const autoBuildSource = getAutoBuildSourcePath(); + + if (!autoBuildSource) { + return { + success: false, + error: 'Auto-build source path not configured' + }; + } + + const analyzerPath = path.join(autoBuildSource, 'analyzer.py'); + const indexOutputPath = path.join(project.path, AUTO_BUILD_PATHS.PROJECT_INDEX); + + // Run analyzer + await new Promise((resolve, reject) => { + const proc = spawn('python', [ + analyzerPath, + '--project-dir', project.path, + '--output', indexOutputPath + ], { + cwd: project.path, + env: { ...process.env } + }); + + proc.on('close', (code: number) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Analyzer exited with code ${code}`)); + } + }); + + proc.on('error', reject); + }); + + // Read the new index + if (existsSync(indexOutputPath)) { + const content = readFileSync(indexOutputPath, 'utf-8'); + const projectIndex = JSON.parse(content); + return { success: true, data: projectIndex }; + } + + return { success: false, error: 'Failed to generate project index' }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to refresh project index' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_MEMORY_STATUS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Load project .env file to check for Graphiti config + let projectEnvVars: Record = {}; + if (project.autoBuildPath) { + const projectEnvPath = path.join(project.path, project.autoBuildPath, '.env'); + if (existsSync(projectEnvPath)) { + try { + const envContent = readFileSync(projectEnvPath, 'utf-8'); + // Parse .env file inline - handle both Unix and Windows line endings + for (const line of envContent.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + projectEnvVars[key] = value; + } + } + } catch { + // Continue with empty vars + } + } + } + + // Load global settings for OpenAI API key fallback + let globalOpenAIKey: string | undefined; + if (existsSync(settingsPath)) { + try { + const settingsContent = readFileSync(settingsPath, 'utf-8'); + const globalSettings = JSON.parse(settingsContent); + globalOpenAIKey = globalSettings.globalOpenAIApiKey; + } catch { + // Continue without global settings + } + } + + // Check for Graphiti config: project .env > process.env + const graphitiEnabled = + projectEnvVars['GRAPHITI_ENABLED']?.toLowerCase() === 'true' || + process.env.GRAPHITI_ENABLED?.toLowerCase() === 'true'; + + // Check for OpenAI key: project .env > global settings > process.env + const hasOpenAI = + !!projectEnvVars['OPENAI_API_KEY'] || + !!globalOpenAIKey || + !!process.env.OPENAI_API_KEY; + + // Get Graphiti connection details from project .env or process.env + const graphitiHost = projectEnvVars['GRAPHITI_FALKORDB_HOST'] || process.env.GRAPHITI_FALKORDB_HOST || 'localhost'; + const graphitiPort = parseInt(projectEnvVars['GRAPHITI_FALKORDB_PORT'] || process.env.GRAPHITI_FALKORDB_PORT || '6380', 10); + const graphitiDatabase = projectEnvVars['GRAPHITI_DATABASE'] || process.env.GRAPHITI_DATABASE || 'auto_build_memory'; + + if (!graphitiEnabled) { + return { + success: true, + data: { + enabled: false, + available: false, + reason: 'GRAPHITI_ENABLED not set to true' + } + }; + } + + if (!hasOpenAI) { + return { + success: true, + data: { + enabled: true, + available: false, + reason: 'OPENAI_API_KEY not set (required for embeddings)' + } + }; + } + + return { + success: true, + data: { + enabled: true, + available: true, + host: graphitiHost, + port: graphitiPort, + database: graphitiDatabase + } + }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, + async (_, projectId: string, query: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // For now, do simple text search in file-based memories + // Graphiti search would require running Python subprocess + const results: ContextSearchResult[] = []; + const queryLower = query.toLowerCase(); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (existsSync(specsDir)) { + const allSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }); + + for (const specDir of allSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + + if (memContent.toLowerCase().includes(queryLower)) { + const memData = JSON.parse(memContent); + results.push({ + content: JSON.stringify(memData.insights || memData, null, 2), + score: 1.0, + type: 'session_insight' + }); + } + } catch { + // Skip invalid files + } + } + } + } + } + + return { success: true, data: results.slice(0, 20) }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CONTEXT_GET_MEMORIES, + async (_, projectId: string, limit: number = 20): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const memories: MemoryEpisode[] = []; + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + if (existsSync(specsDir)) { + const sortedSpecDirs = readdirSync(specsDir) + .filter((f: string) => { + const specPath = path.join(specsDir, f); + return statSync(specPath).isDirectory(); + }) + .sort() + .reverse(); + + for (const specDir of sortedSpecDirs) { + const memoryDir = path.join(specsDir, specDir, 'memory'); + if (existsSync(memoryDir)) { + const memoryFiles = readdirSync(memoryDir) + .filter((f: string) => f.endsWith('.json')) + .sort() + .reverse(); + + for (const memFile of memoryFiles) { + try { + const memPath = path.join(memoryDir, memFile); + const memContent = readFileSync(memPath, 'utf-8'); + const memData = JSON.parse(memContent); + + memories.push({ + id: `${specDir}-${memFile}`, + type: memData.type || 'session_insight', + timestamp: memData.timestamp || new Date().toISOString(), + content: JSON.stringify(memData.insights || memData, null, 2), + session_number: memData.session_number + }); + + if (memories.length >= limit) { + break; + } + } catch { + // Skip invalid files + } + } + } + + if (memories.length >= limit) { + break; + } + } + } + + return { success: true, data: memories }; + } + ); diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/ideation-insights-section.txt b/auto-claude-ui/src/main/ipc-handlers/sections/ideation-insights-section.txt new file mode 100644 index 00000000..a4c39eeb --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/ideation-insights-section.txt @@ -0,0 +1,1175 @@ + // ============================================ + // Ideation Operations + // ============================================ + + /** + * Transform an idea from snake_case (Python backend) to camelCase (TypeScript frontend) + */ + const transformIdeaFromSnakeCase = (idea: Record) => { + const base = { + id: idea.id as string, + type: idea.type as string, + title: idea.title as string, + description: idea.description as string, + rationale: idea.rationale as string, + status: idea.status as string || 'draft', + createdAt: idea.created_at ? new Date(idea.created_at as string) : new Date() + }; + + if (idea.type === 'code_improvements') { + return { + ...base, + buildsUpon: idea.builds_upon || idea.buildsUpon || [], + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + existingPatterns: idea.existing_patterns || idea.existingPatterns || [], + implementationApproach: idea.implementation_approach || idea.implementationApproach || '' + }; + } else if (idea.type === 'ui_ux_improvements') { + return { + ...base, + category: idea.category || 'usability', + affectedComponents: idea.affected_components || idea.affectedComponents || [], + screenshots: idea.screenshots || [], + currentState: idea.current_state || idea.currentState || '', + proposedChange: idea.proposed_change || idea.proposedChange || '', + userBenefit: idea.user_benefit || idea.userBenefit || '' + }; + } else if (idea.type === 'documentation_gaps') { + return { + ...base, + category: idea.category || 'readme', + targetAudience: idea.target_audience || idea.targetAudience || 'developers', + affectedAreas: idea.affected_areas || idea.affectedAreas || [], + currentDocumentation: idea.current_documentation || idea.currentDocumentation || '', + proposedContent: idea.proposed_content || idea.proposedContent || '', + priority: idea.priority || 'medium', + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'small' + }; + } else if (idea.type === 'security_hardening') { + return { + ...base, + category: idea.category || 'configuration', + severity: idea.severity || 'medium', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + vulnerability: idea.vulnerability || '', + currentRisk: idea.current_risk || idea.currentRisk || '', + remediation: idea.remediation || '', + references: idea.references || [], + compliance: idea.compliance || [] + }; + } else if (idea.type === 'performance_optimizations') { + return { + ...base, + category: idea.category || 'runtime', + impact: idea.impact || 'medium', + affectedAreas: idea.affected_areas || idea.affectedAreas || [], + currentMetric: idea.current_metric || idea.currentMetric || '', + expectedImprovement: idea.expected_improvement || idea.expectedImprovement || '', + implementation: idea.implementation || '', + tradeoffs: idea.tradeoffs || '', + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium' + }; + } else if (idea.type === 'code_quality') { + return { + ...base, + category: idea.category || 'code_smells', + severity: idea.severity || 'minor', + affectedFiles: idea.affected_files || idea.affectedFiles || [], + currentState: idea.current_state || idea.currentState || '', + proposedChange: idea.proposed_change || idea.proposedChange || '', + codeExample: idea.code_example || idea.codeExample || '', + bestPractice: idea.best_practice || idea.bestPractice || '', + metrics: idea.metrics || {}, + estimatedEffort: idea.estimated_effort || idea.estimatedEffort || 'medium', + breakingChange: idea.breaking_change ?? idea.breakingChange ?? false, + prerequisites: idea.prerequisites || [] + }; + } + + return base; + }; + + ipcMain.handle( + IPC_CHANNELS.IDEATION_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const rawIdeation = JSON.parse(content); + + // Transform snake_case to camelCase for frontend + const session: IdeationSession = { + id: rawIdeation.id || `ideation-${Date.now()}`, + projectId, + config: { + enabledTypes: rawIdeation.config?.enabled_types || rawIdeation.config?.enabledTypes || [], + includeRoadmapContext: rawIdeation.config?.include_roadmap_context ?? rawIdeation.config?.includeRoadmapContext ?? true, + includeKanbanContext: rawIdeation.config?.include_kanban_context ?? rawIdeation.config?.includeKanbanContext ?? true, + maxIdeasPerType: rawIdeation.config?.max_ideas_per_type || rawIdeation.config?.maxIdeasPerType || 5 + }, + ideas: (rawIdeation.ideas || []).map((idea: Record) => + transformIdeaFromSnakeCase(idea) + ), + projectContext: { + existingFeatures: rawIdeation.project_context?.existing_features || rawIdeation.projectContext?.existingFeatures || [], + techStack: rawIdeation.project_context?.tech_stack || rawIdeation.projectContext?.techStack || [], + targetAudience: rawIdeation.project_context?.target_audience || rawIdeation.projectContext?.targetAudience, + plannedFeatures: rawIdeation.project_context?.planned_features || rawIdeation.projectContext?.plannedFeatures || [] + }, + generatedAt: rawIdeation.generated_at ? new Date(rawIdeation.generated_at) : new Date(), + updatedAt: rawIdeation.updated_at ? new Date(rawIdeation.updated_at) : new Date() + }; + + return { success: true, data: session }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read ideation' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.IDEATION_GENERATE, + (_, projectId: string, config: IdeationConfig) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start ideation generation via agent manager + agentManager.startIdeationGeneration(projectId, project.path, config, false); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Analyzing project structure...' + } as IdeationGenerationStatus + ); + } + ); + + ipcMain.on( + IPC_CHANNELS.IDEATION_REFRESH, + (_, projectId: string, config: IdeationConfig) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start ideation regeneration with refresh flag + agentManager.startIdeationGeneration(projectId, project.path, config, true); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Refreshing ideation...' + } as IdeationGenerationStatus + ); + } + ); + + // Stop ideation generation + ipcMain.handle( + IPC_CHANNELS.IDEATION_STOP, + async (_, projectId: string): Promise => { + const mainWindow = getMainWindow(); + const wasStopped = agentManager.stopIdeation(projectId); + + if (wasStopped && mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId); + } + + return { success: wasStopped }; + } + ); + + // Dismiss all ideas + ipcMain.handle( + IPC_CHANNELS.IDEATION_DISMISS_ALL, + async (_, projectId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Dismiss all ideas that are not already dismissed or converted + let dismissedCount = 0; + ideation.ideas?.forEach((idea: { status: string }) => { + if (idea.status !== 'dismissed' && idea.status !== 'converted') { + idea.status = 'dismissed'; + dismissedCount++; + } + }); + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true, data: { dismissedCount } }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to dismiss all ideas' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_UPDATE_IDEA, + async ( + _, + projectId: string, + ideaId: string, + status: IdeationStatus + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find and update the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + idea.status = status; + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update idea' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_DISMISS, + async (_, projectId: string, ideaId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find and dismiss the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + idea.status = 'dismissed'; + ideation.updated_at = new Date().toISOString(); + + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to dismiss idea' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, + async (_, projectId: string, ideaId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const ideationPath = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + AUTO_BUILD_PATHS.IDEATION_FILE + ); + + if (!existsSync(ideationPath)) { + return { success: false, error: 'Ideation not found' }; + } + + try { + const content = readFileSync(ideationPath, 'utf-8'); + const ideation = JSON.parse(content); + + // Find the idea + const idea = ideation.ideas?.find((i: { id: string }) => i.id === ideaId); + if (!idea) { + return { success: false, error: 'Idea not found' }; + } + + // Generate spec ID by finding next available number + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Ensure specs directory exists + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next spec number + let nextNum = 1; + try { + const existingSpecs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => { + const match = d.name.match(/^(\d+)-/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingSpecs.length > 0) { + nextNum = Math.max(...existingSpecs) + 1; + } + } catch { + // Use default 1 + } + + // Create spec directory name from idea title + const slugifiedTitle = idea.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(nextNum).padStart(3, '0')}-${slugifiedTitle}`; + const specDir = path.join(specsDir, specId); + + // Create the spec directory + mkdirSync(specDir, { recursive: true }); + + // Build task description based on idea type + let taskDescription = `# ${idea.title}\n\n`; + taskDescription += `${idea.description}\n\n`; + taskDescription += `## Rationale\n${idea.rationale}\n\n`; + + // Note: high_value_features removed - strategic features belong to Roadmap + // low_hanging_fruit renamed to code_improvements + if (idea.type === 'code_improvements') { + if (idea.builds_upon?.length) { + taskDescription += `## Builds Upon\n${idea.builds_upon.map((b: string) => `- ${b}`).join('\n')}\n\n`; + } + if (idea.implementation_approach) { + taskDescription += `## Implementation Approach\n${idea.implementation_approach}\n\n`; + } + if (idea.affected_files?.length) { + taskDescription += `## Affected Files\n${idea.affected_files.map((f: string) => `- ${f}`).join('\n')}\n\n`; + } + if (idea.existing_patterns?.length) { + taskDescription += `## Patterns to Follow\n${idea.existing_patterns.map((p: string) => `- ${p}`).join('\n')}\n\n`; + } + } else if (idea.type === 'ui_ux_improvements') { + taskDescription += `## Category\n${idea.category}\n\n`; + taskDescription += `## Current State\n${idea.current_state}\n\n`; + taskDescription += `## Proposed Change\n${idea.proposed_change}\n\n`; + taskDescription += `## User Benefit\n${idea.user_benefit}\n\n`; + if (idea.affected_components?.length) { + taskDescription += `## Affected Components\n${idea.affected_components.map((c: string) => `- ${c}`).join('\n')}\n\n`; + } + } + + // Create initial implementation_plan.json so task shows in kanban immediately + const initialPlan: ImplementationPlan = { + feature: idea.title, + description: idea.description, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + status: 'backlog', + planStatus: 'pending', + phases: [], + workflow_type: 'development', + services_involved: [], + final_acceptance: [], + spec_file: 'spec.md' + }; + writeFileSync( + path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), + JSON.stringify(initialPlan, null, 2) + ); + + // Create initial spec.md with the task description + const specContent = `# ${idea.title} + +## Overview + +${idea.description} + +## Rationale + +${idea.rationale} + +--- +*This spec was created from ideation and is pending detailed specification.* +`; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent); + + // Update idea with converted status + idea.status = 'converted'; + idea.linked_task_id = specId; + ideation.updated_at = new Date().toISOString(); + writeFileSync(ideationPath, JSON.stringify(ideation, null, 2)); + + // Build metadata from idea type + const metadata: TaskMetadata = { + sourceType: 'ideation', + ideationType: idea.type, + ideaId: idea.id, + rationale: idea.rationale + }; + + // Map idea type to task category + // Note: high_value_features removed, low_hanging_fruit renamed to code_improvements + const ideaTypeToCategory: Record = { + 'code_improvements': 'feature', + 'ui_ux_improvements': 'ui_ux', + 'documentation_gaps': 'documentation', + 'security_hardening': 'security', + 'performance_optimizations': 'performance', + 'code_quality': 'refactoring' + }; + metadata.category = ideaTypeToCategory[idea.type] || 'feature'; + + // Extract type-specific metadata + // Note: high_value_features removed - strategic features belong to Roadmap + // low_hanging_fruit renamed to code_improvements + if (idea.type === 'code_improvements') { + metadata.estimatedEffort = idea.estimated_effort; + metadata.complexity = idea.estimated_effort; // trivial/small/medium/large/complex + metadata.affectedFiles = idea.affected_files; + } else if (idea.type === 'ui_ux_improvements') { + metadata.uiuxCategory = idea.category; + metadata.affectedFiles = idea.affected_components; + metadata.problemSolved = idea.current_state; + } else if (idea.type === 'documentation_gaps') { + metadata.estimatedEffort = idea.estimated_effort; + metadata.priority = idea.priority; + metadata.targetAudience = idea.target_audience; + metadata.affectedFiles = idea.affected_areas; + } else if (idea.type === 'security_hardening') { + metadata.securitySeverity = idea.severity; + metadata.impact = idea.severity as TaskImpact; // Map severity to impact + metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'high' ? 'high' : 'medium'; + metadata.affectedFiles = idea.affected_files; + } else if (idea.type === 'performance_optimizations') { + metadata.performanceCategory = idea.category; + metadata.impact = idea.impact as TaskImpact; + metadata.estimatedEffort = idea.estimated_effort; + metadata.affectedFiles = idea.affected_areas; + } else if (idea.type === 'code_quality') { + metadata.codeQualitySeverity = idea.severity; + metadata.estimatedEffort = idea.estimated_effort; + metadata.affectedFiles = idea.affected_files; + metadata.priority = idea.severity === 'critical' ? 'urgent' : idea.severity === 'major' ? 'high' : 'medium'; + } + + // Save metadata to a separate file for persistence + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + + // Task is created in Planning (backlog) - user must manually start it + // Previously auto-started spec creation here, but user should control when to start + + // Create task object to return + const task: Task = { + id: specId, + specId: specId, + projectId, + title: idea.title, + description: taskDescription, + status: 'backlog', + subtasks: [], + logs: [], + metadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert idea to task' + }; + } + } + ); + + // ============================================ + // Ideation Agent Events → Renderer + // ============================================ + + agentManager.on('ideation-progress', (projectId: string, status: IdeationGenerationStatus) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_PROGRESS, projectId, status); + } + }); + + agentManager.on('ideation-log', (projectId: string, log: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_LOG, projectId, log); + } + }); + + agentManager.on('ideation-complete', (projectId: string, session: IdeationSession) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_COMPLETE, projectId, session); + } + }); + + agentManager.on('ideation-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_ERROR, projectId, error); + } + }); + + agentManager.on('ideation-stopped', (projectId: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_STOPPED, projectId); + } + }); + + // Handle streaming ideation type completion - load ideas for this type immediately + agentManager.on('ideation-type-complete', (projectId: string, ideationType: string, ideasCount: number) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + // Read the type-specific ideas file and send to renderer + const project = projectStore.getProject(projectId); + if (project) { + const typeFile = path.join( + project.path, + AUTO_BUILD_PATHS.IDEATION_DIR, + `${ideationType}_ideas.json` + ); + if (existsSync(typeFile)) { + try { + const content = readFileSync(typeFile, 'utf-8'); + const data = JSON.parse(content); + const rawIdeas = data[ideationType] || []; + // Transform ideas from snake_case to camelCase + const ideas = rawIdeas.map((idea: Record) => transformIdeaFromSnakeCase(idea)); + mainWindow.webContents.send( + IPC_CHANNELS.IDEATION_TYPE_COMPLETE, + projectId, + ideationType, + ideas + ); + } catch (err) { + console.error(`[Ideation] Failed to read ${ideationType} ideas:`, err); + } + } + } + } + }); + + agentManager.on('ideation-type-failed', (projectId: string, ideationType: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.IDEATION_TYPE_FAILED, projectId, ideationType); + } + }); + + // ============================================ + // Changelog Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, + async (_, projectId: string, rendererTasks?: import('../shared/types').Task[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Use renderer tasks if provided (they have the correct UI status), + // otherwise fall back to reading from filesystem + const tasks = rendererTasks || projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const doneTasks = changelogService.getCompletedTasks(project.path, tasks, specsBaseDir); + + return { success: true, data: doneTasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const tasks = projectStore.getTasks(projectId); + + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + return { success: true, data: specs }; + } + ); + + ipcMain.on( + IPC_CHANNELS.CHANGELOG_GENERATE, + async (_, request: import('../shared/types').ChangelogGenerationRequest) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(request.projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, + request.projectId, + 'Project not found' + ); + return; + } + + // Load specs for selected tasks (only in tasks mode) + let specs: import('../shared/types').TaskSpecContent[] = []; + if (request.sourceMode === 'tasks' && request.taskIds && request.taskIds.length > 0) { + const tasks = projectStore.getTasks(request.projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + specs = await changelogService.loadTaskSpecs(project.path, request.taskIds, tasks, specsBaseDir); + } + + // Start generation + changelogService.generateChangelog(request.projectId, project.path, request, specs); + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SAVE, + async (_, request: import('../shared/types').ChangelogSaveRequest): Promise> => { + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const result = changelogService.saveChangelog(project.path, request); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save changelog' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_READ_EXISTING, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const result = changelogService.readExistingChangelog(project.path); + return { success: true, data: result }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, + async (_, projectId: string, taskIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Get current version from existing changelog + const existing = changelogService.readExistingChangelog(project.path); + const currentVersion = existing.lastVersion; + + // Load specs for selected tasks to analyze change types + const tasks = projectStore.getTasks(projectId); + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specs = await changelogService.loadTaskSpecs(project.path, taskIds, tasks, specsBaseDir); + + // Analyze specs and suggest version + const suggestedVersion = changelogService.suggestVersion(specs, currentVersion); + + // Determine reason for the suggestion + let reason = 'patch'; + if (currentVersion) { + const [oldMajor, oldMinor] = currentVersion.split('.').map(Number); + const [newMajor, newMinor] = suggestedVersion.split('.').map(Number); + if (newMajor > oldMajor) { + reason = 'breaking'; + } else if (newMinor > oldMinor) { + reason = 'feature'; + } + } + + return { + success: true, + data: { version: suggestedVersion, reason } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to suggest version' + }; + } + } + ); + + // ============================================ + // Changelog Git Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_BRANCHES, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const branches = changelogService.getBranches(project.path); + return { success: true, data: branches }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get branches' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_TAGS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + const tags = changelogService.getTags(project.path); + return { success: true, data: tags }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get tags' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, + async ( + _, + projectId: string, + options: import('../shared/types').GitHistoryOptions | import('../shared/types').BranchDiffOptions, + mode: 'git-history' | 'branch-diff' + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + let commits: import('../shared/types').GitCommit[]; + + if (mode === 'git-history') { + commits = changelogService.getCommits( + project.path, + options as import('../shared/types').GitHistoryOptions + ); + } else { + commits = changelogService.getBranchDiffCommits( + project.path, + options as import('../shared/types').BranchDiffOptions + ); + } + + return { success: true, data: commits }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get commits preview' + }; + } + } + ); + + // ============================================ + // Changelog Agent Events → Renderer + // ============================================ + + changelogService.on('generation-progress', (projectId: string, progress: import('../shared/types').ChangelogGenerationProgress) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, projectId, progress); + } + }); + + changelogService.on('generation-complete', (projectId: string, result: import('../shared/types').ChangelogGenerationResult) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, projectId, result); + } + }); + + changelogService.on('generation-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, projectId, error); + } + }); + + changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../shared/types').SDKRateLimitInfo) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); + } + }); + + // ============================================ + // Insights Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_GET_SESSION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.loadSession(projectId, project.path); + return { success: true, data: session }; + } + ); + + ipcMain.on( + IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, + async (_, projectId: string, message: string) => { + const project = projectStore.getProject(projectId); + if (!project) { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.INSIGHTS_ERROR, projectId, 'Project not found'); + } + return; + } + + // Ensure Python environment is ready before sending message + if (!pythonEnvManager.isEnvReady()) { + const autoBuildSource = getAutoBuildSourcePath(); + if (autoBuildSource) { + const status = await pythonEnvManager.initialize(autoBuildSource); + if (status.ready && status.pythonPath) { + configureServicesWithPython(status.pythonPath, autoBuildSource); + } else { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.INSIGHTS_ERROR, + projectId, + status.error || 'Python environment not ready' + ); + } + return; + } + } + } + + insightsService.sendMessage(projectId, project.path, message); + } + ); + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, + async (_, projectId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + insightsService.clearSession(projectId, project.path); + return { success: true }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_CREATE_TASK, + async ( + _, + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Auto Claude not initialized for this project' }; + } + + try { + // Generate a unique spec ID based on existing specs + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Find next available spec number + let specNumber = 1; + if (existsSync(specsDir)) { + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Build metadata with source type + const taskMetadata: TaskMetadata = { + sourceType: 'insights', + ...metadata + }; + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + + // Save task metadata + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + + // Create the task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title, + description, + status: 'backlog', + subtasks: [], + logs: [], + metadata: taskMetadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create task' + }; + } + } + ); + + // List all sessions for a project + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const sessions = insightsService.listSessions(project.path); + return { success: true, data: sessions }; + } + ); + + // Create a new session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_NEW_SESSION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.createNewSession(projectId, project.path); + return { success: true, data: session }; + } + ); + + // Switch to a different session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_SWITCH_SESSION, + async (_, projectId: string, sessionId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const session = insightsService.switchSession(projectId, project.path, sessionId); + return { success: true, data: session }; + } + ); + + // Delete a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_DELETE_SESSION, + async (_, projectId: string, sessionId: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const success = insightsService.deleteSession(projectId, project.path, sessionId); + if (success) { + return { success: true }; + } + return { success: false, error: 'Failed to delete session' }; + } + ); + + // Rename a session + ipcMain.handle( + IPC_CHANNELS.INSIGHTS_RENAME_SESSION, + async (_, projectId: string, sessionId: string, newTitle: string): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const success = insightsService.renameSession(project.path, sessionId, newTitle); + if (success) { + return { success: true }; + } + return { success: false, error: 'Failed to rename session' }; + } + ); + diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/integration-section.txt b/auto-claude-ui/src/main/ipc-handlers/sections/integration-section.txt new file mode 100644 index 00000000..642514e2 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/integration-section.txt @@ -0,0 +1,1653 @@ + // ============================================ + // Environment Configuration Operations + // ============================================ + + /** + * Parse .env file into key-value object + */ + const parseEnvFile = (content: string): Record => { + const result: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue; + + const equalsIndex = trimmed.indexOf('='); + if (equalsIndex > 0) { + const key = trimmed.substring(0, equalsIndex).trim(); + let value = trimmed.substring(equalsIndex + 1).trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + result[key] = value; + } + } + return result; + }; + + /** + * Generate .env file content from config + */ + const generateEnvContent = ( + config: Partial, + existingContent?: string + ): string => { + // Parse existing content to preserve comments and structure + const existingVars = existingContent ? parseEnvFile(existingContent) : {}; + + // Update with new values + if (config.claudeOAuthToken !== undefined) { + existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken; + } + if (config.autoBuildModel !== undefined) { + existingVars['AUTO_BUILD_MODEL'] = config.autoBuildModel; + } + if (config.linearApiKey !== undefined) { + existingVars['LINEAR_API_KEY'] = config.linearApiKey; + } + if (config.linearTeamId !== undefined) { + existingVars['LINEAR_TEAM_ID'] = config.linearTeamId; + } + if (config.linearProjectId !== undefined) { + existingVars['LINEAR_PROJECT_ID'] = config.linearProjectId; + } + if (config.linearRealtimeSync !== undefined) { + existingVars['LINEAR_REALTIME_SYNC'] = config.linearRealtimeSync ? 'true' : 'false'; + } + // GitHub Integration + if (config.githubToken !== undefined) { + existingVars['GITHUB_TOKEN'] = config.githubToken; + } + if (config.githubRepo !== undefined) { + existingVars['GITHUB_REPO'] = config.githubRepo; + } + if (config.githubAutoSync !== undefined) { + existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false'; + } + if (config.graphitiEnabled !== undefined) { + existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false'; + } + if (config.openaiApiKey !== undefined) { + existingVars['OPENAI_API_KEY'] = config.openaiApiKey; + } + if (config.graphitiFalkorDbHost !== undefined) { + existingVars['GRAPHITI_FALKORDB_HOST'] = config.graphitiFalkorDbHost; + } + if (config.graphitiFalkorDbPort !== undefined) { + existingVars['GRAPHITI_FALKORDB_PORT'] = String(config.graphitiFalkorDbPort); + } + if (config.graphitiFalkorDbPassword !== undefined) { + existingVars['GRAPHITI_FALKORDB_PASSWORD'] = config.graphitiFalkorDbPassword; + } + if (config.graphitiDatabase !== undefined) { + existingVars['GRAPHITI_DATABASE'] = config.graphitiDatabase; + } + if (config.enableFancyUi !== undefined) { + existingVars['ENABLE_FANCY_UI'] = config.enableFancyUi ? 'true' : 'false'; + } + + // Generate content with sections + let content = `# Auto Claude Framework Environment Variables +# Managed by Auto Claude UI + +# Claude Code OAuth Token (REQUIRED) +CLAUDE_CODE_OAUTH_TOKEN=${existingVars['CLAUDE_CODE_OAUTH_TOKEN'] || ''} + +# Model override (OPTIONAL) +${existingVars['AUTO_BUILD_MODEL'] ? `AUTO_BUILD_MODEL=${existingVars['AUTO_BUILD_MODEL']}` : '# AUTO_BUILD_MODEL=claude-opus-4-5-20251101'} + +# ============================================================================= +# LINEAR INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['LINEAR_API_KEY'] ? `LINEAR_API_KEY=${existingVars['LINEAR_API_KEY']}` : '# LINEAR_API_KEY='} +${existingVars['LINEAR_TEAM_ID'] ? `LINEAR_TEAM_ID=${existingVars['LINEAR_TEAM_ID']}` : '# LINEAR_TEAM_ID='} +${existingVars['LINEAR_PROJECT_ID'] ? `LINEAR_PROJECT_ID=${existingVars['LINEAR_PROJECT_ID']}` : '# LINEAR_PROJECT_ID='} +${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${existingVars['LINEAR_REALTIME_SYNC']}` : '# LINEAR_REALTIME_SYNC=false'} + +# ============================================================================= +# GITHUB INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='} +${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'} +${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'} + +# ============================================================================= +# UI SETTINGS (OPTIONAL) +# ============================================================================= +${existingVars['ENABLE_FANCY_UI'] !== undefined ? `ENABLE_FANCY_UI=${existingVars['ENABLE_FANCY_UI']}` : '# ENABLE_FANCY_UI=true'} + +# ============================================================================= +# GRAPHITI MEMORY INTEGRATION (OPTIONAL) +# ============================================================================= +${existingVars['GRAPHITI_ENABLED'] ? `GRAPHITI_ENABLED=${existingVars['GRAPHITI_ENABLED']}` : '# GRAPHITI_ENABLED=false'} +${existingVars['OPENAI_API_KEY'] ? `OPENAI_API_KEY=${existingVars['OPENAI_API_KEY']}` : '# OPENAI_API_KEY='} +${existingVars['GRAPHITI_FALKORDB_HOST'] ? `GRAPHITI_FALKORDB_HOST=${existingVars['GRAPHITI_FALKORDB_HOST']}` : '# GRAPHITI_FALKORDB_HOST=localhost'} +${existingVars['GRAPHITI_FALKORDB_PORT'] ? `GRAPHITI_FALKORDB_PORT=${existingVars['GRAPHITI_FALKORDB_PORT']}` : '# GRAPHITI_FALKORDB_PORT=6380'} +${existingVars['GRAPHITI_FALKORDB_PASSWORD'] ? `GRAPHITI_FALKORDB_PASSWORD=${existingVars['GRAPHITI_FALKORDB_PASSWORD']}` : '# GRAPHITI_FALKORDB_PASSWORD='} +${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHITI_DATABASE']}` : '# GRAPHITI_DATABASE=auto_build_memory'} +`; + + return content; + }; + + ipcMain.handle( + IPC_CHANNELS.ENV_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Project not initialized' }; + } + + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + + // Load global settings for fallbacks + let globalSettings: AppSettings = { ...DEFAULT_APP_SETTINGS }; + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + globalSettings = { ...globalSettings, ...JSON.parse(content) }; + } catch { + // Use defaults + } + } + + // Default config + const config: ProjectEnvConfig = { + claudeAuthStatus: 'not_configured', + linearEnabled: false, + githubEnabled: false, + graphitiEnabled: false, + enableFancyUi: true, + claudeTokenIsGlobal: false, + openaiKeyIsGlobal: false + }; + + // Parse project-specific .env if it exists + let vars: Record = {}; + if (existsSync(envPath)) { + try { + const content = readFileSync(envPath, 'utf-8'); + vars = parseEnvFile(content); + } catch { + // Continue with empty vars + } + } + + // Claude OAuth Token: project-specific takes precedence, then global + if (vars['CLAUDE_CODE_OAUTH_TOKEN']) { + config.claudeOAuthToken = vars['CLAUDE_CODE_OAUTH_TOKEN']; + config.claudeAuthStatus = 'token_set'; + config.claudeTokenIsGlobal = false; + } else if (globalSettings.globalClaudeOAuthToken) { + config.claudeOAuthToken = globalSettings.globalClaudeOAuthToken; + config.claudeAuthStatus = 'token_set'; + config.claudeTokenIsGlobal = true; + } + + if (vars['AUTO_BUILD_MODEL']) { + config.autoBuildModel = vars['AUTO_BUILD_MODEL']; + } + + if (vars['LINEAR_API_KEY']) { + config.linearEnabled = true; + config.linearApiKey = vars['LINEAR_API_KEY']; + } + if (vars['LINEAR_TEAM_ID']) { + config.linearTeamId = vars['LINEAR_TEAM_ID']; + } + if (vars['LINEAR_PROJECT_ID']) { + config.linearProjectId = vars['LINEAR_PROJECT_ID']; + } + if (vars['LINEAR_REALTIME_SYNC']?.toLowerCase() === 'true') { + config.linearRealtimeSync = true; + } + + // GitHub config + if (vars['GITHUB_TOKEN']) { + config.githubEnabled = true; + config.githubToken = vars['GITHUB_TOKEN']; + } + if (vars['GITHUB_REPO']) { + config.githubRepo = vars['GITHUB_REPO']; + } + if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') { + config.githubAutoSync = true; + } + + if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') { + config.graphitiEnabled = true; + } + + // OpenAI API Key: project-specific takes precedence, then global + if (vars['OPENAI_API_KEY']) { + config.openaiApiKey = vars['OPENAI_API_KEY']; + config.openaiKeyIsGlobal = false; + } else if (globalSettings.globalOpenAIApiKey) { + config.openaiApiKey = globalSettings.globalOpenAIApiKey; + config.openaiKeyIsGlobal = true; + } + + if (vars['GRAPHITI_FALKORDB_HOST']) { + config.graphitiFalkorDbHost = vars['GRAPHITI_FALKORDB_HOST']; + } + if (vars['GRAPHITI_FALKORDB_PORT']) { + config.graphitiFalkorDbPort = parseInt(vars['GRAPHITI_FALKORDB_PORT'], 10); + } + if (vars['GRAPHITI_FALKORDB_PASSWORD']) { + config.graphitiFalkorDbPassword = vars['GRAPHITI_FALKORDB_PASSWORD']; + } + if (vars['GRAPHITI_DATABASE']) { + config.graphitiDatabase = vars['GRAPHITI_DATABASE']; + } + + if (vars['ENABLE_FANCY_UI']?.toLowerCase() === 'false') { + config.enableFancyUi = false; + } + + return { success: true, data: config }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_UPDATE, + async (_, projectId: string, config: Partial): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + if (!project.autoBuildPath) { + return { success: false, error: 'Project not initialized' }; + } + + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + + try { + // Read existing content if file exists + let existingContent: string | undefined; + if (existsSync(envPath)) { + existingContent = readFileSync(envPath, 'utf-8'); + } + + // Generate new content + const newContent = generateEnvContent(config, existingContent); + + // Write to file + writeFileSync(envPath, newContent); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update .env file' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Check if Claude CLI is available and authenticated + const result = await new Promise((resolve) => { + const proc = spawn('claude', ['--version'], { + cwd: project.path, + env: { ...process.env }, + shell: true + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + // Claude CLI is available, check if authenticated + // Run a simple command that requires auth + const authCheck = spawn('claude', ['api', '--help'], { + cwd: project.path, + env: { ...process.env }, + shell: true + }); + + authCheck.on('close', (authCode: number | null) => { + resolve({ + success: true, + authenticated: authCode === 0 + }); + }); + + authCheck.on('error', () => { + resolve({ + success: true, + authenticated: false, + error: 'Could not verify authentication' + }); + }); + } else { + resolve({ + success: false, + authenticated: false, + error: 'Claude CLI not found. Please install it first.' + }); + } + }); + + proc.on('error', () => { + resolve({ + success: false, + authenticated: false, + error: 'Claude CLI not found. Please install it first.' + }); + }); + }); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check Claude auth' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Run claude setup-token which will open browser for OAuth + const result = await new Promise((resolve) => { + const proc = spawn('claude', ['setup-token'], { + cwd: project.path, + env: { ...process.env }, + shell: true, + stdio: 'inherit' // This allows the terminal to handle the interactive auth + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve({ + success: true, + authenticated: true + }); + } else { + resolve({ + success: false, + authenticated: false, + error: 'Setup cancelled or failed' + }); + } + }); + + proc.on('error', (err: Error) => { + resolve({ + success: false, + authenticated: false, + error: err.message + }); + }); + }); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to invoke Claude setup' + }; + } + } + ); + + // ============================================ + // Linear Integration Operations + // ============================================ + + /** + * Helper to get Linear API key from project env + */ + const getLinearApiKey = (project: Project): string | null => { + if (!project.autoBuildPath) return null; + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + if (!existsSync(envPath)) return null; + + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + return vars['LINEAR_API_KEY'] || null; + } catch { + return null; + } + }; + + /** + * Make a request to the Linear API + */ + const linearGraphQL = async ( + apiKey: string, + query: string, + variables?: Record + ): Promise => { + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': apiKey + }, + body: JSON.stringify({ query, variables }) + }); + + if (!response.ok) { + throw new Error(`Linear API error: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + if (result.errors) { + throw new Error(result.errors[0]?.message || 'Linear API error'); + } + + return result.data; + }; + + ipcMain.handle( + IPC_CHANNELS.LINEAR_CHECK_CONNECTION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { + success: true, + data: { + connected: false, + error: 'No Linear API key configured' + } + }; + } + + try { + const query = ` + query { + viewer { + id + name + } + teams { + nodes { + id + name + key + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + viewer: { id: string; name: string }; + teams: { nodes: Array<{ id: string; name: string; key: string }> }; + }; + + // Get issue count for the first team + let issueCount = 0; + let teamName: string | undefined; + + if (data.teams.nodes.length > 0) { + teamName = data.teams.nodes[0].name; + const countQuery = ` + query($teamId: String!) { + team(id: $teamId) { + issues { + totalCount: nodes { id } + } + } + } + `; + // Get approximate count + const issuesQuery = ` + query($teamId: String!) { + issues(filter: { team: { id: { eq: $teamId } } }, first: 0) { + pageInfo { + hasNextPage + } + } + } + `; + + // Simple count estimation - get first 250 issues + const countData = await linearGraphQL(apiKey, ` + query($teamId: String!) { + issues(filter: { team: { id: { eq: $teamId } } }, first: 250) { + nodes { id } + } + } + `, { teamId: data.teams.nodes[0].id }) as { + issues: { nodes: Array<{ id: string }> }; + }; + issueCount = countData.issues.nodes.length; + } + + return { + success: true, + data: { + connected: true, + teamName, + issueCount, + lastSyncedAt: new Date().toISOString() + } + }; + } catch (error) { + return { + success: true, + data: { + connected: false, + error: error instanceof Error ? error.message : 'Failed to connect to Linear' + } + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_TEAMS, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + const query = ` + query { + teams { + nodes { + id + name + key + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + teams: { nodes: LinearTeam[] }; + }; + + return { success: true, data: data.teams.nodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch teams' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_PROJECTS, + async (_, projectId: string, teamId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + const query = ` + query($teamId: String!) { + team(id: $teamId) { + projects { + nodes { + id + name + state + } + } + } + } + `; + + const data = await linearGraphQL(apiKey, query, { teamId }) as { + team: { projects: { nodes: LinearProject[] } }; + }; + + return { success: true, data: data.team.projects.nodes }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch projects' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_GET_ISSUES, + async (_, projectId: string, teamId?: string, linearProjectId?: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + // Build filter based on provided parameters + const filters: string[] = []; + if (teamId) { + filters.push(`team: { id: { eq: "${teamId}" } }`); + } + if (linearProjectId) { + filters.push(`project: { id: { eq: "${linearProjectId}" } }`); + } + + const filterClause = filters.length > 0 ? `filter: { ${filters.join(', ')} }` : ''; + + const query = ` + query { + issues(${filterClause}, first: 250, orderBy: updatedAt) { + nodes { + id + identifier + title + description + state { + id + name + type + } + priority + priorityLabel + labels { + nodes { + id + name + color + } + } + assignee { + id + name + email + } + project { + id + name + } + createdAt + updatedAt + url + } + } + } + `; + + const data = await linearGraphQL(apiKey, query) as { + issues: { + nodes: Array<{ + id: string; + identifier: string; + title: string; + description?: string; + state: { id: string; name: string; type: string }; + priority: number; + priorityLabel: string; + labels: { nodes: Array<{ id: string; name: string; color: string }> }; + assignee?: { id: string; name: string; email: string }; + project?: { id: string; name: string }; + createdAt: string; + updatedAt: string; + url: string; + }>; + }; + }; + + // Transform to our LinearIssue format + const issues: LinearIssue[] = data.issues.nodes.map(issue => ({ + ...issue, + labels: issue.labels.nodes + })); + + return { success: true, data: issues }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issues' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.LINEAR_IMPORT_ISSUES, + async (_, projectId: string, issueIds: string[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const apiKey = getLinearApiKey(project); + if (!apiKey) { + return { success: false, error: 'No Linear API key configured' }; + } + + try { + // First, fetch the full details of selected issues + const query = ` + query($ids: [String!]!) { + issues(filter: { id: { in: $ids } }) { + nodes { + id + identifier + title + description + state { + id + name + type + } + priority + priorityLabel + labels { + nodes { + id + name + color + } + } + url + } + } + } + `; + + const data = await linearGraphQL(apiKey, query, { ids: issueIds }) as { + issues: { + nodes: Array<{ + id: string; + identifier: string; + title: string; + description?: string; + state: { id: string; name: string; type: string }; + priority: number; + priorityLabel: string; + labels: { nodes: Array<{ id: string; name: string; color: string }> }; + url: string; + }>; + }; + }; + + let imported = 0; + let failed = 0; + const errors: string[] = []; + + // Set up specs directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Create tasks for each imported issue + for (const issue of data.issues.nodes) { + try { + // Build description from Linear issue + const labels = issue.labels.nodes.map(l => l.name).join(', '); + const description = `# ${issue.title} + +**Linear Issue:** [${issue.identifier}](${issue.url}) +**Priority:** ${issue.priorityLabel} +**Status:** ${issue.state.name} +${labels ? `**Labels:** ${labels}` : ''} + +## Description + +${issue.description || 'No description provided.'} +`; + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: description, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'linear', + linearIssueId: issue.id, + linearIdentifier: issue.identifier, + linearUrl: issue.url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, description, specDir, metadata); + + imported++; + } catch (err) { + failed++; + errors.push(`Failed to import ${issue.identifier}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + return { + success: true, + data: { + success: failed === 0, + imported, + failed, + errors: errors.length > 0 ? errors : undefined + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to import issues' + }; + } + } + ); + + // ============================================ + // GitHub Integration Operations + // ============================================ + + /** + * Helper to get GitHub config from project env + */ + const getGitHubConfig = (project: Project): { token: string; repo: string } | null => { + if (!project.autoBuildPath) return null; + const envPath = path.join(project.path, project.autoBuildPath, '.env'); + if (!existsSync(envPath)) return null; + + try { + const content = readFileSync(envPath, 'utf-8'); + const vars = parseEnvFile(content); + const token = vars['GITHUB_TOKEN']; + const repo = vars['GITHUB_REPO']; + + if (!token || !repo) return null; + return { token, repo }; + } catch { + return null; + } + }; + + /** + * Make a request to the GitHub API + */ + const githubFetch = async ( + token: string, + endpoint: string, + options: RequestInit = {} + ): Promise => { + const url = endpoint.startsWith('http') + ? endpoint + : `https://api.github.com${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Authorization': `Bearer ${token}`, + 'User-Agent': 'Auto-Claude-UI', + ...options.headers + } + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorBody}`); + } + + return response.json(); + }; + + ipcMain.handle( + IPC_CHANNELS.GITHUB_CHECK_CONNECTION, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { + success: true, + data: { + connected: false, + error: 'No GitHub token or repository configured' + } + }; + } + + try { + // Fetch repo info + const repoData = await githubFetch( + config.token, + `/repos/${config.repo}` + ) as { full_name: string; description?: string }; + + // Count open issues + const issuesData = await githubFetch( + config.token, + `/repos/${config.repo}/issues?state=open&per_page=1` + ) as unknown[]; + + const openCount = Array.isArray(issuesData) ? issuesData.length : 0; + + return { + success: true, + data: { + connected: true, + repoFullName: repoData.full_name, + repoDescription: repoData.description, + issueCount: openCount, + lastSyncedAt: new Date().toISOString() + } + }; + } catch (error) { + return { + success: true, + data: { + connected: false, + error: error instanceof Error ? error.message : 'Failed to connect to GitHub' + } + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_REPOSITORIES, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token configured' }; + } + + try { + const repos = await githubFetch( + config.token, + '/user/repos?per_page=100&sort=updated' + ) as Array<{ + id: number; + name: string; + full_name: string; + description?: string; + html_url: string; + default_branch: string; + private: boolean; + owner: { login: string; avatar_url?: string }; + }>; + + const result: GitHubRepository[] = repos.map(repo => ({ + id: repo.id, + name: repo.name, + fullName: repo.full_name, + description: repo.description, + url: repo.html_url, + defaultBranch: repo.default_branch, + private: repo.private, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url + } + })); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch repositories' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_ISSUES, + async (_, projectId: string, state: 'open' | 'closed' | 'all' = 'open'): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + try { + const issues = await githubFetch( + config.token, + `/repos/${config.repo}/issues?state=${state}&per_page=100&sort=updated` + ) as Array<{ + id: number; + number: number; + title: string; + body?: string; + state: 'open' | 'closed'; + labels: Array<{ id: number; name: string; color: string; description?: string }>; + assignees: Array<{ login: string; avatar_url?: string }>; + user: { login: string; avatar_url?: string }; + milestone?: { id: number; title: string; state: 'open' | 'closed' }; + created_at: string; + updated_at: string; + closed_at?: string; + comments: number; + url: string; + html_url: string; + pull_request?: unknown; + }>; + + // Filter out pull requests + const issuesOnly = issues.filter(issue => !issue.pull_request); + + const result: GitHubIssue[] = issuesOnly.map(issue => ({ + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + labels: issue.labels, + assignees: issue.assignees.map(a => ({ + login: a.login, + avatarUrl: a.avatar_url + })), + author: { + login: issue.user.login, + avatarUrl: issue.user.avatar_url + }, + milestone: issue.milestone, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + closedAt: issue.closed_at, + commentsCount: issue.comments, + url: issue.url, + htmlUrl: issue.html_url, + repoFullName: config.repo + })); + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issues' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_GET_ISSUE, + async (_, projectId: string, issueNumber: number): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + try { + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + id: number; + number: number; + title: string; + body?: string; + state: 'open' | 'closed'; + labels: Array<{ id: number; name: string; color: string; description?: string }>; + assignees: Array<{ login: string; avatar_url?: string }>; + user: { login: string; avatar_url?: string }; + milestone?: { id: number; title: string; state: 'open' | 'closed' }; + created_at: string; + updated_at: string; + closed_at?: string; + comments: number; + url: string; + html_url: string; + }; + + const result: GitHubIssue = { + id: issue.id, + number: issue.number, + title: issue.title, + body: issue.body, + state: issue.state, + labels: issue.labels, + assignees: issue.assignees.map(a => ({ + login: a.login, + avatarUrl: a.avatar_url + })), + author: { + login: issue.user.login, + avatarUrl: issue.user.avatar_url + }, + milestone: issue.milestone, + createdAt: issue.created_at, + updatedAt: issue.updated_at, + closedAt: issue.closed_at, + commentsCount: issue.comments, + url: issue.url, + htmlUrl: issue.html_url, + repoFullName: config.repo + }; + + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch issue' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, + async (_, projectId: string, issueNumber: number) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + 'Project not found' + ); + return; + } + + const config = getGitHubConfig(project); + if (!config) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + 'No GitHub token or repository configured' + ); + return; + } + + try { + // Send progress update: fetching issue + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'fetching', + issueNumber, + progress: 10, + message: 'Fetching issue details...' + } as GitHubInvestigationStatus + ); + + // Fetch the issue + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + number: number; + title: string; + body?: string; + labels: Array<{ name: string }>; + html_url: string; + }; + + // Fetch issue comments for more context + const comments = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}/comments` + ) as Array<{ body: string; user: { login: string } }>; + + // Build context for the AI investigation + const issueContext = ` +# GitHub Issue #${issue.number}: ${issue.title} + +${issue.body || 'No description provided.'} + +${comments.length > 0 ? `## Comments (${comments.length}): +${comments.map(c => `**${c.user.login}:** ${c.body}`).join('\n\n')}` : ''} + +**Labels:** ${issue.labels.map(l => l.name).join(', ') || 'None'} +**URL:** ${issue.html_url} +`; + + // Send progress update: analyzing + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'analyzing', + issueNumber, + progress: 30, + message: 'AI is analyzing the issue...' + } as GitHubInvestigationStatus + ); + + // Build task description + const taskDescription = `Investigate GitHub Issue #${issue.number}: ${issue.title} + +${issueContext} + +Please analyze this issue and provide: +1. A brief summary of what the issue is about +2. A proposed solution approach +3. The files that would likely need to be modified +4. Estimated complexity (simple/standard/complex) +5. Acceptance criteria for resolving this issue`; + + // Create proper spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: taskDescription, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: taskDescription, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'github', + githubIssueNumber: issue.number, + githubUrl: issue.html_url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + + // Send progress update: creating task + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'creating_task', + issueNumber, + progress: 70, + message: 'Creating task from investigation...' + } as GitHubInvestigationStatus + ); + + const investigationResult: GitHubInvestigationResult = { + success: true, + issueNumber, + analysis: { + summary: `Investigation of issue #${issueNumber}: ${issue.title}`, + proposedSolution: 'Task has been created for AI agent to implement the solution.', + affectedFiles: [], + estimatedComplexity: 'standard', + acceptanceCriteria: [ + `Issue #${issueNumber} requirements are met`, + 'All existing tests pass', + 'New functionality is tested' + ] + }, + taskId: specId + }; + + // Send completion + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, + projectId, + { + phase: 'complete', + issueNumber, + progress: 100, + message: 'Investigation complete!' + } as GitHubInvestigationStatus + ); + + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, + projectId, + investigationResult + ); + + } catch (error) { + mainWindow.webContents.send( + IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, + projectId, + error instanceof Error ? error.message : 'Failed to investigate issue' + ); + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.GITHUB_IMPORT_ISSUES, + async (_, projectId: string, issueNumbers: number[]): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const config = getGitHubConfig(project); + if (!config) { + return { success: false, error: 'No GitHub token or repository configured' }; + } + + let imported = 0; + let failed = 0; + const errors: string[] = []; + const tasks: Task[] = []; + + // Set up specs directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + for (const issueNumber of issueNumbers) { + try { + const issue = await githubFetch( + config.token, + `/repos/${config.repo}/issues/${issueNumber}` + ) as { + number: number; + title: string; + body?: string; + labels: Array<{ name: string }>; + html_url: string; + }; + + const labels = issue.labels.map(l => l.name).join(', '); + const description = `# ${issue.title} + +**GitHub Issue:** [#${issue.number}](${issue.html_url}) +${labels ? `**Labels:** ${labels}` : ''} + +## Description + +${issue.body || 'No description provided.'} +`; + + // Find next available spec number + let specNumber = 1; + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = issue.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: issue.title, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: description, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'github', + githubIssueNumber: issue.number, + githubUrl: issue.html_url, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, description, specDir, metadata); + imported++; + } catch (err) { + failed++; + errors.push(`Failed to import #${issueNumber}: ${err instanceof Error ? err.message : 'Unknown error'}`); + } + } + + return { + success: true, + data: { + success: failed === 0, + imported, + failed, + errors: errors.length > 0 ? errors : undefined, + tasks + } + }; + } + ); + + /** + * Create a GitHub release using the gh CLI + */ + ipcMain.handle( + IPC_CHANNELS.GITHUB_CREATE_RELEASE, + async ( + _, + projectId: string, + version: string, + releaseNotes: string, + options?: { draft?: boolean; prerelease?: boolean } + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + try { + // Check if gh CLI is available + // Use 'where' on Windows, 'which' on Unix + try { + const checkCmd = process.platform === 'win32' ? 'where gh' : 'which gh'; + execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' }); + } catch { + return { + success: false, + error: 'GitHub CLI (gh) not found. Please install it: https://cli.github.com/' + }; + } + + // Check if user is authenticated + try { + execSync('gh auth status', { cwd: project.path, encoding: 'utf-8', stdio: 'pipe' }); + } catch { + return { + success: false, + error: 'Not authenticated with GitHub. Run "gh auth login" in terminal first.' + }; + } + + // Prepare tag name (ensure v prefix) + const tag = version.startsWith('v') ? version : `v${version}`; + + // Build gh release command + const args = ['release', 'create', tag, '--title', tag, '--notes', releaseNotes]; + if (options?.draft) args.push('--draft'); + if (options?.prerelease) args.push('--prerelease'); + + // Create the release + const output = execSync(`gh ${args.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')}`, { + cwd: project.path, + encoding: 'utf-8', + stdio: 'pipe' + }).trim(); + + // Output is typically the release URL + const releaseUrl = output || `https://github.com/releases/tag/${tag}`; + + return { + success: true, + data: { url: releaseUrl } + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to create release'; + // Try to extract more useful error message from stderr + if (error && typeof error === 'object' && 'stderr' in error) { + return { success: false, error: String(error.stderr) || errorMsg }; + } + return { success: false, error: errorMsg }; + } + } + ); + diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/roadmap_extracted.txt b/auto-claude-ui/src/main/ipc-handlers/sections/roadmap_extracted.txt new file mode 100644 index 00000000..ba527ce8 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/roadmap_extracted.txt @@ -0,0 +1,367 @@ + // ============================================ + // Roadmap Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_GET, + async (_, projectId: string): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: true, data: null }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const rawRoadmap = JSON.parse(content); + + // Transform snake_case to camelCase for frontend + const roadmap: Roadmap = { + id: rawRoadmap.id || `roadmap-${Date.now()}`, + projectId, + projectName: rawRoadmap.project_name || project.name, + version: rawRoadmap.version || '1.0', + vision: rawRoadmap.vision || '', + targetAudience: { + primary: rawRoadmap.target_audience?.primary || '', + secondary: rawRoadmap.target_audience?.secondary || [] + }, + phases: (rawRoadmap.phases || []).map((phase: Record) => ({ + id: phase.id, + name: phase.name, + description: phase.description, + order: phase.order, + status: phase.status || 'planned', + features: phase.features || [], + milestones: (phase.milestones as Array> || []).map((m) => ({ + id: m.id, + title: m.title, + description: m.description, + features: m.features || [], + status: m.status || 'planned', + targetDate: m.target_date ? new Date(m.target_date as string) : undefined + })) + })), + features: (rawRoadmap.features || []).map((feature: Record) => ({ + id: feature.id, + title: feature.title, + description: feature.description, + rationale: feature.rationale || '', + priority: feature.priority || 'should', + complexity: feature.complexity || 'medium', + impact: feature.impact || 'medium', + phaseId: feature.phase_id, + dependencies: feature.dependencies || [], + status: feature.status || 'idea', + acceptanceCriteria: feature.acceptance_criteria || [], + userStories: feature.user_stories || [], + linkedSpecId: feature.linked_spec_id + })), + status: rawRoadmap.status || 'draft', + createdAt: rawRoadmap.metadata?.created_at ? new Date(rawRoadmap.metadata.created_at) : new Date(), + updatedAt: rawRoadmap.metadata?.updated_at ? new Date(rawRoadmap.metadata.updated_at) : new Date() + }; + + return { success: true, data: roadmap }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to read roadmap' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_GENERATE, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap generation via agent manager + agentManager.startRoadmapGeneration(projectId, project.path, false); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Analyzing project structure...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.on( + IPC_CHANNELS.ROADMAP_REFRESH, + (_, projectId: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + const project = projectStore.getProject(projectId); + if (!project) { + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_ERROR, + projectId, + 'Project not found' + ); + return; + } + + // Start roadmap regeneration with refresh flag + agentManager.startRoadmapGeneration(projectId, project.path, true); + + // Send initial progress + mainWindow.webContents.send( + IPC_CHANNELS.ROADMAP_PROGRESS, + projectId, + { + phase: 'analyzing', + progress: 10, + message: 'Refreshing roadmap...' + } as RoadmapGenerationStatus + ); + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, + async ( + _, + projectId: string, + featureId: string, + status: RoadmapFeatureStatus + ): Promise => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find and update the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + feature.status = status; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update feature' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, + async ( + _, + projectId: string, + featureId: string + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const roadmapPath = path.join( + project.path, + AUTO_BUILD_PATHS.ROADMAP_DIR, + AUTO_BUILD_PATHS.ROADMAP_FILE + ); + + if (!existsSync(roadmapPath)) { + return { success: false, error: 'Roadmap not found' }; + } + + try { + const content = readFileSync(roadmapPath, 'utf-8'); + const roadmap = JSON.parse(content); + + // Find the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: 'Feature not found' }; + } + + // Build task description from feature + const taskDescription = `# ${feature.title} + +${feature.description} + +## Rationale +${feature.rationale || 'N/A'} + +## User Stories +${(feature.user_stories || []).map((s: string) => `- ${s}`).join('\n') || 'N/A'} + +## Acceptance Criteria +${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join('\n') || 'N/A'} +`; + + // Generate proper spec directory (like task creation) + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Ensure specs directory exists + if (!existsSync(specsDir)) { + mkdirSync(specsDir, { recursive: true }); + } + + // Find next available spec number + let specNumber = 1; + const existingDirs = existsSync(specsDir) + ? readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + : []; + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = feature.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Create initial implementation_plan.json + const now = new Date().toISOString(); + const implementationPlan = { + feature: feature.title, + description: taskDescription, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2)); + + // Create requirements.json + const requirements = { + task_description: taskDescription, + workflow_type: 'feature' + }; + writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2)); + + // Build metadata + const metadata: TaskMetadata = { + sourceType: 'roadmap', + featureId: feature.id, + category: 'feature' + }; + writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2)); + + // Start spec creation with the existing spec directory + agentManager.startSpecCreation(specId, project.path, taskDescription, specDir, metadata); + + // Update feature with linked spec + feature.status = 'planned'; + feature.linked_spec_id = specId; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); + writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2)); + + // Create task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: feature.title, + description: taskDescription, + status: 'backlog', + subtasks: [], + logs: [], + metadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to convert feature to spec' + }; + } + } + ); + + // ============================================ + // Roadmap Agent Events → Renderer + // ============================================ + + agentManager.on('roadmap-progress', (projectId: string, status: RoadmapGenerationStatus) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_PROGRESS, projectId, status); + } + }); + + agentManager.on('roadmap-complete', (projectId: string, roadmap: Roadmap) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_COMPLETE, projectId, roadmap); + } + }); + + agentManager.on('roadmap-error', (projectId: string, error: string) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.ROADMAP_ERROR, projectId, error); + } + }); diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/task-section.txt b/auto-claude-ui/src/main/ipc-handlers/sections/task-section.txt new file mode 100644 index 00000000..96b81e00 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/task-section.txt @@ -0,0 +1,1497 @@ + // ============================================ + // Task Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TASK_LIST, + async (_, projectId: string): Promise> => { + console.log('[IPC] TASK_LIST called with projectId:', projectId); + const tasks = projectStore.getTasks(projectId); + console.log('[IPC] TASK_LIST returning', tasks.length, 'tasks'); + return { success: true, data: tasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_CREATE, + async ( + _, + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Auto-generate title if empty using Claude AI + let finalTitle = title; + if (!title || !title.trim()) { + console.log('[TASK_CREATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(description); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_CREATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_CREATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_CREATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Generate a unique spec ID based on existing specs + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Find next available spec number + let specNumber = 1; + if (existsSync(specsDir)) { + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + // Extract numbers from spec directory names (e.g., "001-feature" -> 1) + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = finalTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Build metadata with source type + const taskMetadata: TaskMetadata = { + sourceType: 'manual', + ...metadata + }; + + // Process and save attached images + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + const attachmentsDir = path.join(specDir, 'attachments'); + mkdirSync(attachmentsDir, { recursive: true }); + + const savedImages: typeof taskMetadata.attachedImages = []; + + for (const image of taskMetadata.attachedImages) { + if (image.data) { + try { + // Decode base64 and save to file + const buffer = Buffer.from(image.data, 'base64'); + const imagePath = path.join(attachmentsDir, image.filename); + writeFileSync(imagePath, buffer); + + // Store relative path instead of base64 data + savedImages.push({ + id: image.id, + filename: image.filename, + mimeType: image.mimeType, + size: image.size, + path: `attachments/${image.filename}` + // Don't include data or thumbnail to save space + }); + } catch (err) { + console.error(`Failed to save image ${image.filename}:`, err); + } + } + } + + // Update metadata with saved image paths (without base64 data) + taskMetadata.attachedImages = savedImages; + } + + // Create initial implementation_plan.json (task is created but not started) + const now = new Date().toISOString(); + const implementationPlan = { + feature: finalTitle, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + + // Save task metadata if provided + if (taskMetadata) { + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + } + + // Create requirements.json with attached images + const requirements: Record = { + task_description: description, + workflow_type: taskMetadata.category || 'feature' + }; + + // Add attached images to requirements if present + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + requirements.attached_images = taskMetadata.attachedImages.map(img => ({ + filename: img.filename, + path: img.path, + description: '' // User can add descriptions later + })); + } + + const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); + + // Create the task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: finalTitle, + description, + status: 'backlog', + subtasks: [], + logs: [], + metadata: taskMetadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_DELETE, + async (_, taskId: string): Promise => { + const { rm } = await import('fs/promises'); + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task or project not found' }; + } + + // Check if task is currently running + const isRunning = agentManager.isRunning(taskId); + if (isRunning) { + return { success: false, error: 'Cannot delete a running task. Stop the task first.' }; + } + + // Delete the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, task.specId); + + try { + if (existsSync(specDir)) { + await rm(specDir, { recursive: true, force: true }); + console.log(`[TASK_DELETE] Deleted spec directory: ${specDir}`); + } + return { success: true }; + } catch (error) { + console.error('[TASK_DELETE] Error deleting spec directory:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete task files' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE, + async ( + _, + taskId: string, + updates: { title?: string; description?: string } + ): Promise> => { + try { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join(project.path, autoBuildDir, 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + // Auto-generate title if empty + let finalTitle = updates.title; + if (updates.title !== undefined && !updates.title.trim()) { + // Get description to use for title generation + const descriptionToUse = updates.description ?? task.description; + console.log('[TASK_UPDATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(descriptionToUse); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_UPDATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_UPDATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_UPDATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + if (existsSync(planPath)) { + try { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + if (finalTitle !== undefined) { + plan.feature = finalTitle; + } + if (updates.description !== undefined) { + plan.description = updates.description; + } + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } catch { + // Plan file might not be valid JSON, continue anyway + } + } + + // Update spec.md if it exists + const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + if (existsSync(specPath)) { + try { + let specContent = readFileSync(specPath, 'utf-8'); + + // Update title (first # heading) + if (finalTitle !== undefined) { + specContent = specContent.replace( + /^#\s+.*$/m, + `# ${finalTitle}` + ); + } + + // Update description (## Overview section content) + if (updates.description !== undefined) { + // Replace content between ## Overview and the next ## section + specContent = specContent.replace( + /(## Overview\n)([\s\S]*?)((?=\n## )|$)/, + `$1${updates.description}\n\n$3` + ); + } + + writeFileSync(specPath, specContent); + } catch { + // Spec file update failed, continue anyway + } + } + + // Build the updated task object + const updatedTask: Task = { + ...task, + title: finalTitle ?? task.title, + description: updates.description ?? task.description, + updatedAt: new Date() + }; + + return { success: true, data: updatedTask }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TASK_START, + (_, taskId: string, options?: TaskStartOptions) => { + console.log('[TASK_START] Received request for taskId:', taskId); + const mainWindow = getMainWindow(); + if (!mainWindow) { + console.log('[TASK_START] No main window found'); + return; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + console.log('[TASK_START] Task or project not found for taskId:', taskId); + mainWindow.webContents.send( + IPC_CHANNELS.TASK_ERROR, + taskId, + 'Task or project not found' + ); + return; + } + + console.log('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length); + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists (indicates spec creation was already done or in progress) + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + + // Check if this task needs spec creation first (no spec file = not yet created) + // OR if it has a spec but no implementation plan subtasks (spec created, needs planning/building) + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir); + + // Start spec creation process - pass the existing spec directory + // so spec_runner uses it instead of creating a new one + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + // Read the spec.md to get the task description + let taskDescription = task.description || task.title; + try { + taskDescription = readFileSync(specFilePath, 'utf-8'); + } catch { + // Use default description + } + + console.log('[TASK_START] Starting task execution (no subtasks) for:', task.specId); + // Start task execution which will create the implementation plan + // Note: No parallel mode for planning phase - parallel only makes sense with multiple subtasks + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, // Sequential for planning phase + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + // Only enable parallel if there are multiple subtasks AND user has parallel enabled + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = options?.parallel ?? project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? (options?.workers ?? project.settings.maxWorkers) : 1; + + console.log('[TASK_START] Starting task execution (has subtasks) for:', task.specId); + console.log('[TASK_START] Parallel decision:', { + hasMultipleSubtasks, + pendingSubtasks, + parallelEnabled, + useParallel, + workers + }); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify status change + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + ); + + ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => { + agentManager.killTask(taskId); + fileWatcher.unwatch(taskId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'backlog' + ); + } + }); + + ipcMain.handle( + IPC_CHANNELS.TASK_REVIEW, + async ( + _, + taskId: string, + approved: boolean, + feedback?: string + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Check if dev mode is enabled for this project + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + if (approved) { + // Write approval to QA report + const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT); + writeFileSync( + qaReportPath, + `# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n` + ); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'done' + ); + } + } else { + // Write feedback for QA fixer + const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md'); + writeFileSync( + fixRequestPath, + `# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n` + ); + + // Restart QA process with dev mode + agentManager.startQAProcess(taskId, project.path, task.specId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE_STATUS, + async ( + _, + taskId: string, + status: TaskStatus + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + // Update implementation_plan.json if it exists + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + // Store the exact UI status - project-store.ts will map it back + plan.status = status; + // Also store mapped version for Python compatibility + plan.planStatus = status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } else { + // If no implementation plan exists yet, create a basic one + const plan = { + feature: task.title, + description: task.description || '', + created_at: task.createdAt.toISOString(), + updated_at: new Date().toISOString(), + status: status, // Store exact UI status for persistence + planStatus: status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending', + phases: [] + }; + + // Ensure spec directory exists + if (!existsSync(specDir)) { + mkdirSync(specDir, { recursive: true }); + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Auto-start task when status changes to 'in_progress' and no process is running + if (status === 'in_progress' && !agentManager.isRunning(taskId)) { + const mainWindow = getMainWindow(); + console.log('[TASK_UPDATE_STATUS] Auto-starting task:', taskId); + + // Start file watcher for this task + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_UPDATE_STATUS] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId); + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + console.log('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + console.log('[TASK_UPDATE_STATUS] Starting task execution (has subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify renderer about status change + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } catch (error) { + console.error('Failed to update task status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update task status' + }; + } + } + ); + + // Handler to check if a task is actually running (has active process) + ipcMain.handle( + IPC_CHANNELS.TASK_CHECK_RUNNING, + async (_, taskId: string): Promise> => { + const isRunning = agentManager.isRunning(taskId); + return { success: true, data: isRunning }; + } + ); + + // Handler to recover a stuck task (status says in_progress but no process running) + ipcMain.handle( + IPC_CHANNELS.TASK_RECOVER_STUCK, + async ( + _, + taskId: string, + options?: { targetStatus?: TaskStatus; autoRestart?: boolean } + ): Promise> => { + const targetStatus = options?.targetStatus; + const autoRestart = options?.autoRestart ?? false; + // Check if task is actually running + const isActuallyRunning = agentManager.isRunning(taskId); + + if (isActuallyRunning) { + return { + success: false, + error: 'Task is still running. Stop it first before recovering.', + data: { + taskId, + recovered: false, + newStatus: 'in_progress' as TaskStatus, + message: 'Task is still running' + } + }; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join( + project.path, + autoBuildDir, + 'specs', + task.specId + ); + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + // Read the plan to analyze subtask progress + let plan: Record | null = null; + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + plan = JSON.parse(planContent); + } + + // Determine the target status intelligently based on subtask progress + // If targetStatus is explicitly provided, use it; otherwise calculate from subtasks + let newStatus: TaskStatus = targetStatus || 'backlog'; + + if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) { + // Analyze subtask statuses to determine appropriate recovery status + const allSubtasks: Array<{ status: string }> = []; + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + allSubtasks.push(...phase.subtasks); + } + } + + if (allSubtasks.length > 0) { + const completedCount = allSubtasks.filter(s => s.status === 'completed').length; + const allCompleted = completedCount === allSubtasks.length; + + if (allCompleted) { + // All subtasks completed - should go to review (ai_review or human_review based on source) + // For recovery, human_review is safer as it requires manual verification + newStatus = 'human_review'; + } else if (completedCount > 0) { + // Some subtasks completed, some still pending - task is in progress + newStatus = 'in_progress'; + } + // else: no subtasks completed, stay with 'backlog' + } + } + + if (plan) { + // Update status + plan.status = newStatus; + plan.planStatus = newStatus === 'done' ? 'completed' + : newStatus === 'in_progress' ? 'in_progress' + : newStatus === 'ai_review' ? 'review' + : newStatus === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + // Add recovery note + plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`; + + // Reset in_progress and failed subtask statuses to 'pending' so they can be retried + // Keep completed subtasks as-is so run.py can resume from where it left off + if (plan.phases && Array.isArray(plan.phases)) { + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + for (const subtask of phase.subtasks) { + // Reset in_progress subtasks to pending (they were interrupted) + // Keep completed subtasks as-is so run.py can resume + if (subtask.status === 'in_progress') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + // Also reset failed subtasks so they can be retried + if (subtask.status === 'failed') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + } + } + } + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Stop file watcher if it was watching this task + fileWatcher.unwatch(taskId); + + // Auto-restart the task if requested + let autoRestarted = false; + if (autoRestart && project) { + try { + // Set status to in_progress for the restart + newStatus = 'in_progress'; + + // Update plan status for restart + if (plan) { + plan.status = 'in_progress'; + plan.planStatus = 'in_progress'; + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Start the task execution + + // Check if we should use parallel mode + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, specDirForWatcher); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + + autoRestarted = true; + console.log(`[Recovery] Auto-restarted task ${taskId}`); + } catch (restartError) { + console.error('Failed to auto-restart task after recovery:', restartError); + // Recovery succeeded but restart failed - still report success + } + } + + // Notify renderer of status change + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + newStatus + ); + } + + return { + success: true, + data: { + taskId, + recovered: true, + newStatus, + message: autoRestarted + ? 'Task recovered and restarted successfully' + : `Task recovered successfully and moved to ${newStatus}`, + autoRestarted + } + }; + } catch (error) { + console.error('Failed to recover stuck task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to recover task' + }; + } + } + ); + + // ============================================ + // Workspace Management Operations (for human review) + // ============================================ + + /** + * Helper function to find task and project by taskId + */ + const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => { + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + return { task, project }; + }; + + /** + * Get the worktree status for a task + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_STATUS, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { exists: false } + }; + } + + // Get branch info from git + try { + // Get current branch in worktree + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get base branch (usually main or master) + let baseBranch = 'main'; + try { + // Try to get the default branch + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --stat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse the summary line (e.g., "3 files changed, 50 insertions(+), 10 deletions(-)") + const summaryMatch = diffStat.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); + if (summaryMatch) { + filesChanged = parseInt(summaryMatch[1], 10) || 0; + additions = parseInt(summaryMatch[2], 10) || 0; + deletions = parseInt(summaryMatch[3], 10) || 0; + } + } catch { + // Ignore diff errors + } + + return { + success: true, + data: { + exists: true, + worktreePath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + } + }; + } catch (gitError) { + console.error('Git error getting worktree status:', gitError); + return { + success: true, + data: { exists: true, worktreePath } + }; + } + } catch (error) { + console.error('Failed to get worktree status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree status' + }; + } + } + ); + + /** + * Get the diff for a task's worktree + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DIFF, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { success: false, error: 'No worktree found for this task' }; + } + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get the diff with file stats + const files: import('../shared/types').WorktreeDiffFile[] = []; + + try { + // Get numstat for additions/deletions per file + const numstat = execSync(`git diff --numstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get name-status for file status + const nameStatus = execSync(`git diff --name-status ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse name-status to get file statuses + const statusMap: Record = {}; + nameStatus.split('\n').filter(Boolean).forEach((line: string) => { + const [status, ...pathParts] = line.split('\t'); + const filePath = pathParts.join('\t'); // Handle files with tabs in name + switch (status[0]) { + case 'A': statusMap[filePath] = 'added'; break; + case 'M': statusMap[filePath] = 'modified'; break; + case 'D': statusMap[filePath] = 'deleted'; break; + case 'R': statusMap[pathParts[1] || filePath] = 'renamed'; break; + default: statusMap[filePath] = 'modified'; + } + }); + + // Parse numstat for additions/deletions + numstat.split('\n').filter(Boolean).forEach((line: string) => { + const [adds, dels, filePath] = line.split('\t'); + files.push({ + path: filePath, + status: statusMap[filePath] || 'modified', + additions: parseInt(adds, 10) || 0, + deletions: parseInt(dels, 10) || 0 + }); + }); + } catch (diffError) { + console.error('Error getting diff:', diffError); + } + + // Generate summary + const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); + const summary = `${files.length} files changed, ${totalAdditions} insertions(+), ${totalDeletions} deletions(-)`; + + return { + success: true, + data: { files, summary } + }; + } catch (error) { + console.error('Failed to get worktree diff:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree diff' + }; + } + } + ); + + /** + * Merge the worktree changes into the main branch + * @param taskId - The task ID to merge + * @param options - Merge options { noCommit?: boolean } + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_MERGE, + async (_, taskId: string, options?: { noCommit?: boolean }): Promise> => { + try { + // Ensure Python environment is ready + if (!pythonEnvManager.isEnvReady()) { + const autoBuildSource = getEffectiveSourcePath(); + if (autoBuildSource) { + const status = await pythonEnvManager.initialize(autoBuildSource); + if (!status.ready) { + return { success: false, error: `Python environment not ready: ${status.error || 'Unknown error'}` }; + } + } else { + return { success: false, error: 'Python environment not ready and Auto Claude source not found' }; + } + } + + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Use run.py --merge to handle the merge + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { success: false, error: 'Auto Claude source not found' }; + } + + const runScript = path.join(sourcePath, 'run.py'); + const specDir = path.join(project.path, project.autoBuildPath || '.auto-claude', 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + const args = [ + runScript, + '--spec', task.specId, + '--project-dir', project.path, + '--merge' + ]; + + // Add --no-commit flag if requested (stage changes without committing) + if (options?.noCommit) { + args.push('--no-commit'); + } + + return new Promise((resolve) => { + const pythonPath = pythonEnvManager.getPythonPath() || 'python3'; + const mergeProcess = spawn(pythonPath, args, { + cwd: sourcePath, + env: { + ...process.env, + PYTHONUNBUFFERED: '1' + } + }); + + let stdout = ''; + let stderr = ''; + + mergeProcess.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + mergeProcess.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + mergeProcess.on('close', (code: number) => { + if (code === 0) { + // Persist the status change to implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + plan.status = 'done'; + plan.planStatus = 'completed'; + plan.updated_at = new Date().toISOString(); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + } catch (persistError) { + console.error('Failed to persist task status:', persistError); + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'done'); + } + + resolve({ + success: true, + data: { + success: true, + message: 'Changes merged successfully' + } + }); + } else { + // Check if there were conflicts + const hasConflicts = stdout.includes('conflict') || stderr.includes('conflict'); + + resolve({ + success: true, + data: { + success: false, + message: hasConflicts ? 'Merge conflicts detected' : `Merge failed: ${stderr || stdout}`, + conflictFiles: hasConflicts ? [] : undefined + } + }); + } + }); + + mergeProcess.on('error', (err: Error) => { + resolve({ + success: false, + error: `Failed to run merge: ${err.message}` + }); + }); + }); + } catch (error) { + console.error('Failed to merge worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to merge worktree' + }; + } + } + ); + + /** + * Discard the worktree changes + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DISCARD, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { + success: true, + message: 'No worktree to discard' + } + }; + } + + try { + // Get the branch name before removing + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Remove the worktree + execSync(`git worktree remove --force "${worktreePath}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + + // Delete the branch + try { + execSync(`git branch -D "${branch}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + } catch { + // Branch might already be deleted or not exist + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'backlog'); + } + + return { + success: true, + data: { + success: true, + message: 'Worktree discarded successfully' + } + }; + } catch (gitError) { + console.error('Git error discarding worktree:', gitError); + return { + success: false, + error: `Failed to discard worktree: ${gitError instanceof Error ? gitError.message : 'Unknown error'}` + }; + } + } catch (error) { + console.error('Failed to discard worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to discard worktree' + }; + } + } + ); + + /** + * List all spec worktrees for a project + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LIST_WORKTREES, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const worktreesDir = path.join(project.path, '.worktrees'); + const worktrees: import('../shared/types').WorktreeListItem[] = []; + + if (!existsSync(worktreesDir)) { + return { success: true, data: { worktrees } }; + } + + // Get all directories in .worktrees + const entries = readdirSync(worktreesDir); + for (const entry of entries) { + const entryPath = path.join(worktreesDir, entry); + const stat = statSync(entryPath); + + // Skip worker directories and non-directories + if (!stat.isDirectory() || entry.startsWith('worker-')) { + continue; + } + + try { + // Get branch info + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --shortstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + const filesMatch = diffStat.match(/(\d+) files? changed/); + const addMatch = diffStat.match(/(\d+) insertions?/); + const delMatch = diffStat.match(/(\d+) deletions?/); + + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10) || 0; + if (addMatch) additions = parseInt(addMatch[1], 10) || 0; + if (delMatch) deletions = parseInt(delMatch[1], 10) || 0; + } catch { + // Ignore diff errors + } + + worktrees.push({ + specName: entry, + path: entryPath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + }); + } catch (gitError) { + console.error(`Error getting info for worktree ${entry}:`, gitError); + // Skip this worktree if we can't get git info + } + } + + return { success: true, data: { worktrees } }; + } catch (error) { + console.error('Failed to list worktrees:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list worktrees' + }; + } + } + ); + diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/task_extracted.txt b/auto-claude-ui/src/main/ipc-handlers/sections/task_extracted.txt new file mode 100644 index 00000000..97004056 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/task_extracted.txt @@ -0,0 +1,1496 @@ + // ============================================ + // Task Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TASK_LIST, + async (_, projectId: string): Promise> => { + console.log('[IPC] TASK_LIST called with projectId:', projectId); + const tasks = projectStore.getTasks(projectId); + console.log('[IPC] TASK_LIST returning', tasks.length, 'tasks'); + return { success: true, data: tasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_CREATE, + async ( + _, + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Auto-generate title if empty using Claude AI + let finalTitle = title; + if (!title || !title.trim()) { + console.log('[TASK_CREATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(description); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_CREATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_CREATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_CREATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Generate a unique spec ID based on existing specs + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Find next available spec number + let specNumber = 1; + if (existsSync(specsDir)) { + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + // Extract numbers from spec directory names (e.g., "001-feature" -> 1) + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = finalTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Build metadata with source type + const taskMetadata: TaskMetadata = { + sourceType: 'manual', + ...metadata + }; + + // Process and save attached images + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + const attachmentsDir = path.join(specDir, 'attachments'); + mkdirSync(attachmentsDir, { recursive: true }); + + const savedImages: typeof taskMetadata.attachedImages = []; + + for (const image of taskMetadata.attachedImages) { + if (image.data) { + try { + // Decode base64 and save to file + const buffer = Buffer.from(image.data, 'base64'); + const imagePath = path.join(attachmentsDir, image.filename); + writeFileSync(imagePath, buffer); + + // Store relative path instead of base64 data + savedImages.push({ + id: image.id, + filename: image.filename, + mimeType: image.mimeType, + size: image.size, + path: `attachments/${image.filename}` + // Don't include data or thumbnail to save space + }); + } catch (err) { + console.error(`Failed to save image ${image.filename}:`, err); + } + } + } + + // Update metadata with saved image paths (without base64 data) + taskMetadata.attachedImages = savedImages; + } + + // Create initial implementation_plan.json (task is created but not started) + const now = new Date().toISOString(); + const implementationPlan = { + feature: finalTitle, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + + // Save task metadata if provided + if (taskMetadata) { + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + } + + // Create requirements.json with attached images + const requirements: Record = { + task_description: description, + workflow_type: taskMetadata.category || 'feature' + }; + + // Add attached images to requirements if present + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + requirements.attached_images = taskMetadata.attachedImages.map(img => ({ + filename: img.filename, + path: img.path, + description: '' // User can add descriptions later + })); + } + + const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); + + // Create the task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: finalTitle, + description, + status: 'backlog', + subtasks: [], + logs: [], + metadata: taskMetadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_DELETE, + async (_, taskId: string): Promise => { + const { rm } = await import('fs/promises'); + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task or project not found' }; + } + + // Check if task is currently running + const isRunning = agentManager.isRunning(taskId); + if (isRunning) { + return { success: false, error: 'Cannot delete a running task. Stop the task first.' }; + } + + // Delete the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, task.specId); + + try { + if (existsSync(specDir)) { + await rm(specDir, { recursive: true, force: true }); + console.log(`[TASK_DELETE] Deleted spec directory: ${specDir}`); + } + return { success: true }; + } catch (error) { + console.error('[TASK_DELETE] Error deleting spec directory:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete task files' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE, + async ( + _, + taskId: string, + updates: { title?: string; description?: string } + ): Promise> => { + try { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join(project.path, autoBuildDir, 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + // Auto-generate title if empty + let finalTitle = updates.title; + if (updates.title !== undefined && !updates.title.trim()) { + // Get description to use for title generation + const descriptionToUse = updates.description ?? task.description; + console.log('[TASK_UPDATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(descriptionToUse); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_UPDATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_UPDATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_UPDATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + if (existsSync(planPath)) { + try { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + if (finalTitle !== undefined) { + plan.feature = finalTitle; + } + if (updates.description !== undefined) { + plan.description = updates.description; + } + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } catch { + // Plan file might not be valid JSON, continue anyway + } + } + + // Update spec.md if it exists + const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + if (existsSync(specPath)) { + try { + let specContent = readFileSync(specPath, 'utf-8'); + + // Update title (first # heading) + if (finalTitle !== undefined) { + specContent = specContent.replace( + /^#\s+.*$/m, + `# ${finalTitle}` + ); + } + + // Update description (## Overview section content) + if (updates.description !== undefined) { + // Replace content between ## Overview and the next ## section + specContent = specContent.replace( + /(## Overview\n)([\s\S]*?)((?=\n## )|$)/, + `$1${updates.description}\n\n$3` + ); + } + + writeFileSync(specPath, specContent); + } catch { + // Spec file update failed, continue anyway + } + } + + // Build the updated task object + const updatedTask: Task = { + ...task, + title: finalTitle ?? task.title, + description: updates.description ?? task.description, + updatedAt: new Date() + }; + + return { success: true, data: updatedTask }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TASK_START, + (_, taskId: string, options?: TaskStartOptions) => { + console.log('[TASK_START] Received request for taskId:', taskId); + const mainWindow = getMainWindow(); + if (!mainWindow) { + console.log('[TASK_START] No main window found'); + return; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + console.log('[TASK_START] Task or project not found for taskId:', taskId); + mainWindow.webContents.send( + IPC_CHANNELS.TASK_ERROR, + taskId, + 'Task or project not found' + ); + return; + } + + console.log('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length); + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists (indicates spec creation was already done or in progress) + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + + // Check if this task needs spec creation first (no spec file = not yet created) + // OR if it has a spec but no implementation plan subtasks (spec created, needs planning/building) + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir); + + // Start spec creation process - pass the existing spec directory + // so spec_runner uses it instead of creating a new one + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + // Read the spec.md to get the task description + let taskDescription = task.description || task.title; + try { + taskDescription = readFileSync(specFilePath, 'utf-8'); + } catch { + // Use default description + } + + console.log('[TASK_START] Starting task execution (no subtasks) for:', task.specId); + // Start task execution which will create the implementation plan + // Note: No parallel mode for planning phase - parallel only makes sense with multiple subtasks + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, // Sequential for planning phase + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + // Only enable parallel if there are multiple subtasks AND user has parallel enabled + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = options?.parallel ?? project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? (options?.workers ?? project.settings.maxWorkers) : 1; + + console.log('[TASK_START] Starting task execution (has subtasks) for:', task.specId); + console.log('[TASK_START] Parallel decision:', { + hasMultipleSubtasks, + pendingSubtasks, + parallelEnabled, + useParallel, + workers + }); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify status change + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + ); + + ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => { + agentManager.killTask(taskId); + fileWatcher.unwatch(taskId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'backlog' + ); + } + }); + + ipcMain.handle( + IPC_CHANNELS.TASK_REVIEW, + async ( + _, + taskId: string, + approved: boolean, + feedback?: string + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Check if dev mode is enabled for this project + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + if (approved) { + // Write approval to QA report + const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT); + writeFileSync( + qaReportPath, + `# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n` + ); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'done' + ); + } + } else { + // Write feedback for QA fixer + const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md'); + writeFileSync( + fixRequestPath, + `# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n` + ); + + // Restart QA process with dev mode + agentManager.startQAProcess(taskId, project.path, task.specId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE_STATUS, + async ( + _, + taskId: string, + status: TaskStatus + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + // Update implementation_plan.json if it exists + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + // Store the exact UI status - project-store.ts will map it back + plan.status = status; + // Also store mapped version for Python compatibility + plan.planStatus = status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } else { + // If no implementation plan exists yet, create a basic one + const plan = { + feature: task.title, + description: task.description || '', + created_at: task.createdAt.toISOString(), + updated_at: new Date().toISOString(), + status: status, // Store exact UI status for persistence + planStatus: status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending', + phases: [] + }; + + // Ensure spec directory exists + if (!existsSync(specDir)) { + mkdirSync(specDir, { recursive: true }); + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Auto-start task when status changes to 'in_progress' and no process is running + if (status === 'in_progress' && !agentManager.isRunning(taskId)) { + const mainWindow = getMainWindow(); + console.log('[TASK_UPDATE_STATUS] Auto-starting task:', taskId); + + // Start file watcher for this task + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_UPDATE_STATUS] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId); + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + console.log('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + console.log('[TASK_UPDATE_STATUS] Starting task execution (has subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify renderer about status change + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } catch (error) { + console.error('Failed to update task status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update task status' + }; + } + } + ); + + // Handler to check if a task is actually running (has active process) + ipcMain.handle( + IPC_CHANNELS.TASK_CHECK_RUNNING, + async (_, taskId: string): Promise> => { + const isRunning = agentManager.isRunning(taskId); + return { success: true, data: isRunning }; + } + ); + + // Handler to recover a stuck task (status says in_progress but no process running) + ipcMain.handle( + IPC_CHANNELS.TASK_RECOVER_STUCK, + async ( + _, + taskId: string, + options?: { targetStatus?: TaskStatus; autoRestart?: boolean } + ): Promise> => { + const targetStatus = options?.targetStatus; + const autoRestart = options?.autoRestart ?? false; + // Check if task is actually running + const isActuallyRunning = agentManager.isRunning(taskId); + + if (isActuallyRunning) { + return { + success: false, + error: 'Task is still running. Stop it first before recovering.', + data: { + taskId, + recovered: false, + newStatus: 'in_progress' as TaskStatus, + message: 'Task is still running' + } + }; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join( + project.path, + autoBuildDir, + 'specs', + task.specId + ); + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + // Read the plan to analyze subtask progress + let plan: Record | null = null; + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + plan = JSON.parse(planContent); + } + + // Determine the target status intelligently based on subtask progress + // If targetStatus is explicitly provided, use it; otherwise calculate from subtasks + let newStatus: TaskStatus = targetStatus || 'backlog'; + + if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) { + // Analyze subtask statuses to determine appropriate recovery status + const allSubtasks: Array<{ status: string }> = []; + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + allSubtasks.push(...phase.subtasks); + } + } + + if (allSubtasks.length > 0) { + const completedCount = allSubtasks.filter(s => s.status === 'completed').length; + const allCompleted = completedCount === allSubtasks.length; + + if (allCompleted) { + // All subtasks completed - should go to review (ai_review or human_review based on source) + // For recovery, human_review is safer as it requires manual verification + newStatus = 'human_review'; + } else if (completedCount > 0) { + // Some subtasks completed, some still pending - task is in progress + newStatus = 'in_progress'; + } + // else: no subtasks completed, stay with 'backlog' + } + } + + if (plan) { + // Update status + plan.status = newStatus; + plan.planStatus = newStatus === 'done' ? 'completed' + : newStatus === 'in_progress' ? 'in_progress' + : newStatus === 'ai_review' ? 'review' + : newStatus === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + // Add recovery note + plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`; + + // Reset in_progress and failed subtask statuses to 'pending' so they can be retried + // Keep completed subtasks as-is so run.py can resume from where it left off + if (plan.phases && Array.isArray(plan.phases)) { + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + for (const subtask of phase.subtasks) { + // Reset in_progress subtasks to pending (they were interrupted) + // Keep completed subtasks as-is so run.py can resume + if (subtask.status === 'in_progress') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + // Also reset failed subtasks so they can be retried + if (subtask.status === 'failed') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + } + } + } + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Stop file watcher if it was watching this task + fileWatcher.unwatch(taskId); + + // Auto-restart the task if requested + let autoRestarted = false; + if (autoRestart && project) { + try { + // Set status to in_progress for the restart + newStatus = 'in_progress'; + + // Update plan status for restart + if (plan) { + plan.status = 'in_progress'; + plan.planStatus = 'in_progress'; + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Start the task execution + + // Check if we should use parallel mode + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, specDirForWatcher); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + + autoRestarted = true; + console.log(`[Recovery] Auto-restarted task ${taskId}`); + } catch (restartError) { + console.error('Failed to auto-restart task after recovery:', restartError); + // Recovery succeeded but restart failed - still report success + } + } + + // Notify renderer of status change + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + newStatus + ); + } + + return { + success: true, + data: { + taskId, + recovered: true, + newStatus, + message: autoRestarted + ? 'Task recovered and restarted successfully' + : `Task recovered successfully and moved to ${newStatus}`, + autoRestarted + } + }; + } catch (error) { + console.error('Failed to recover stuck task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to recover task' + }; + } + } + ); + + // ============================================ + // Workspace Management Operations (for human review) + // ============================================ + + /** + * Helper function to find task and project by taskId + */ + const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => { + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + return { task, project }; + }; + + /** + * Get the worktree status for a task + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_STATUS, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { exists: false } + }; + } + + // Get branch info from git + try { + // Get current branch in worktree + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get base branch (usually main or master) + let baseBranch = 'main'; + try { + // Try to get the default branch + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --stat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse the summary line (e.g., "3 files changed, 50 insertions(+), 10 deletions(-)") + const summaryMatch = diffStat.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); + if (summaryMatch) { + filesChanged = parseInt(summaryMatch[1], 10) || 0; + additions = parseInt(summaryMatch[2], 10) || 0; + deletions = parseInt(summaryMatch[3], 10) || 0; + } + } catch { + // Ignore diff errors + } + + return { + success: true, + data: { + exists: true, + worktreePath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + } + }; + } catch (gitError) { + console.error('Git error getting worktree status:', gitError); + return { + success: true, + data: { exists: true, worktreePath } + }; + } + } catch (error) { + console.error('Failed to get worktree status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree status' + }; + } + } + ); + + /** + * Get the diff for a task's worktree + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DIFF, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { success: false, error: 'No worktree found for this task' }; + } + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get the diff with file stats + const files: import('../shared/types').WorktreeDiffFile[] = []; + + try { + // Get numstat for additions/deletions per file + const numstat = execSync(`git diff --numstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get name-status for file status + const nameStatus = execSync(`git diff --name-status ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse name-status to get file statuses + const statusMap: Record = {}; + nameStatus.split('\n').filter(Boolean).forEach((line: string) => { + const [status, ...pathParts] = line.split('\t'); + const filePath = pathParts.join('\t'); // Handle files with tabs in name + switch (status[0]) { + case 'A': statusMap[filePath] = 'added'; break; + case 'M': statusMap[filePath] = 'modified'; break; + case 'D': statusMap[filePath] = 'deleted'; break; + case 'R': statusMap[pathParts[1] || filePath] = 'renamed'; break; + default: statusMap[filePath] = 'modified'; + } + }); + + // Parse numstat for additions/deletions + numstat.split('\n').filter(Boolean).forEach((line: string) => { + const [adds, dels, filePath] = line.split('\t'); + files.push({ + path: filePath, + status: statusMap[filePath] || 'modified', + additions: parseInt(adds, 10) || 0, + deletions: parseInt(dels, 10) || 0 + }); + }); + } catch (diffError) { + console.error('Error getting diff:', diffError); + } + + // Generate summary + const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); + const summary = `${files.length} files changed, ${totalAdditions} insertions(+), ${totalDeletions} deletions(-)`; + + return { + success: true, + data: { files, summary } + }; + } catch (error) { + console.error('Failed to get worktree diff:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree diff' + }; + } + } + ); + + /** + * Merge the worktree changes into the main branch + * @param taskId - The task ID to merge + * @param options - Merge options { noCommit?: boolean } + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_MERGE, + async (_, taskId: string, options?: { noCommit?: boolean }): Promise> => { + try { + // Ensure Python environment is ready + if (!pythonEnvManager.isEnvReady()) { + const autoBuildSource = getEffectiveSourcePath(); + if (autoBuildSource) { + const status = await pythonEnvManager.initialize(autoBuildSource); + if (!status.ready) { + return { success: false, error: `Python environment not ready: ${status.error || 'Unknown error'}` }; + } + } else { + return { success: false, error: 'Python environment not ready and Auto Claude source not found' }; + } + } + + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Use run.py --merge to handle the merge + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { success: false, error: 'Auto Claude source not found' }; + } + + const runScript = path.join(sourcePath, 'run.py'); + const specDir = path.join(project.path, project.autoBuildPath || '.auto-claude', 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + const args = [ + runScript, + '--spec', task.specId, + '--project-dir', project.path, + '--merge' + ]; + + // Add --no-commit flag if requested (stage changes without committing) + if (options?.noCommit) { + args.push('--no-commit'); + } + + return new Promise((resolve) => { + const pythonPath = pythonEnvManager.getPythonPath() || 'python3'; + const mergeProcess = spawn(pythonPath, args, { + cwd: sourcePath, + env: { + ...process.env, + PYTHONUNBUFFERED: '1' + } + }); + + let stdout = ''; + let stderr = ''; + + mergeProcess.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + mergeProcess.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + mergeProcess.on('close', (code: number) => { + if (code === 0) { + // Persist the status change to implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + plan.status = 'done'; + plan.planStatus = 'completed'; + plan.updated_at = new Date().toISOString(); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + } catch (persistError) { + console.error('Failed to persist task status:', persistError); + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'done'); + } + + resolve({ + success: true, + data: { + success: true, + message: 'Changes merged successfully' + } + }); + } else { + // Check if there were conflicts + const hasConflicts = stdout.includes('conflict') || stderr.includes('conflict'); + + resolve({ + success: true, + data: { + success: false, + message: hasConflicts ? 'Merge conflicts detected' : `Merge failed: ${stderr || stdout}`, + conflictFiles: hasConflicts ? [] : undefined + } + }); + } + }); + + mergeProcess.on('error', (err: Error) => { + resolve({ + success: false, + error: `Failed to run merge: ${err.message}` + }); + }); + }); + } catch (error) { + console.error('Failed to merge worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to merge worktree' + }; + } + } + ); + + /** + * Discard the worktree changes + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DISCARD, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { + success: true, + message: 'No worktree to discard' + } + }; + } + + try { + // Get the branch name before removing + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Remove the worktree + execSync(`git worktree remove --force "${worktreePath}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + + // Delete the branch + try { + execSync(`git branch -D "${branch}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + } catch { + // Branch might already be deleted or not exist + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'backlog'); + } + + return { + success: true, + data: { + success: true, + message: 'Worktree discarded successfully' + } + }; + } catch (gitError) { + console.error('Git error discarding worktree:', gitError); + return { + success: false, + error: `Failed to discard worktree: ${gitError instanceof Error ? gitError.message : 'Unknown error'}` + }; + } + } catch (error) { + console.error('Failed to discard worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to discard worktree' + }; + } + } + ); + + /** + * List all spec worktrees for a project + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LIST_WORKTREES, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const worktreesDir = path.join(project.path, '.worktrees'); + const worktrees: import('../shared/types').WorktreeListItem[] = []; + + if (!existsSync(worktreesDir)) { + return { success: true, data: { worktrees } }; + } + + // Get all directories in .worktrees + const entries = readdirSync(worktreesDir); + for (const entry of entries) { + const entryPath = path.join(worktreesDir, entry); + const stat = statSync(entryPath); + + // Skip worker directories and non-directories + if (!stat.isDirectory() || entry.startsWith('worker-')) { + continue; + } + + try { + // Get branch info + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --shortstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + const filesMatch = diffStat.match(/(\d+) files? changed/); + const addMatch = diffStat.match(/(\d+) insertions?/); + const delMatch = diffStat.match(/(\d+) deletions?/); + + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10) || 0; + if (addMatch) additions = parseInt(addMatch[1], 10) || 0; + if (delMatch) deletions = parseInt(delMatch[1], 10) || 0; + } catch { + // Ignore diff errors + } + + worktrees.push({ + specName: entry, + path: entryPath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + }); + } catch (gitError) { + console.error(`Error getting info for worktree ${entry}:`, gitError); + // Skip this worktree if we can't get git info + } + } + + return { success: true, data: { worktrees } }; + } catch (error) { + console.error('Failed to list worktrees:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list worktrees' + }; + } + } + ); diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/terminal-section.txt b/auto-claude-ui/src/main/ipc-handlers/sections/terminal-section.txt new file mode 100644 index 00000000..db2a953f --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/terminal-section.txt @@ -0,0 +1,487 @@ + // ============================================ + // Terminal Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CREATE, + async (_, options: TerminalCreateOptions): Promise => { + return terminalManager.create(options); + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_DESTROY, + async (_, id: string): Promise => { + return terminalManager.destroy(id); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INPUT, + (_, id: string, data: string) => { + terminalManager.write(id, data); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESIZE, + (_, id: string, cols: number, rows: number) => { + terminalManager.resize(id, cols, rows); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, + (_, id: string, cwd?: string) => { + terminalManager.invokeClaude(id, cwd); + } + ); + + // Claude profile management (multi-account support) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILES_GET, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get Claude profiles' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SAVE, + async (_, profile: ClaudeProfile): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + + // If this is a new profile without an ID, generate one + if (!profile.id) { + profile.id = profileManager.generateProfileId(profile.name); + } + + // Ensure config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + } + } + + const savedProfile = profileManager.saveProfile(profile); + return { success: true, data: savedProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_DELETE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.deleteProfile(profileId); + if (!success) { + return { success: false, error: 'Cannot delete default or last profile' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_RENAME, + async (_, profileId: string, newName: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.renameProfile(profileId, newName); + if (!success) { + return { success: false, error: 'Profile not found or invalid name' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setActiveProfile(profileId); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set active Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, + async (_, terminalId: string, profileId: string): Promise => { + try { + const result = await terminalManager.switchClaudeProfile(terminalId, profileId); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to switch Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (!profile) { + return { success: false, error: 'Profile not found' }; + } + + // Ensure the config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + console.log('[IPC] Created config directory:', profile.configDir); + } + } + + // Create a terminal and run claude setup-token there + // This is needed because claude setup-token requires TTY/raw mode + const terminalId = `claude-login-${profileId}-${Date.now()}`; + const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; + + console.log('[IPC] Initializing Claude profile:', { + profileId, + profileName: profile.name, + configDir: profile.configDir, + isDefault: profile.isDefault + }); + + // Create a new terminal for the login process + await terminalManager.create({ id: terminalId, cwd: homeDir }); + + // Wait a moment for the terminal to initialize + await new Promise(resolve => setTimeout(resolve, 500)); + + // Build the login command with the profile's config dir + // Use export to ensure the variable persists, then run setup-token + let loginCommand: string; + if (!profile.isDefault && profile.configDir) { + // Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set + loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + } else { + loginCommand = 'claude setup-token'; + } + + console.log('[IPC] Sending login command to terminal:', loginCommand); + + // Write the login command to the terminal + terminalManager.write(terminalId, `${loginCommand}\r`); + + // Notify the renderer that a login terminal was created + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('claude-profile-login-terminal', { + terminalId, + profileId, + profileName: profile.name + }); + } + + return { + success: true, + data: { + terminalId, + message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.` + } + }; + } catch (error) { + console.error('[IPC] Failed to initialize Claude profile:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to initialize Claude profile' + }; + } + } + ); + + // Set OAuth token for a profile (used when capturing from terminal or manual input) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, + async (_, profileId: string, token: string, email?: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setProfileToken(profileId, token, email); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + console.error('[IPC] Failed to set OAuth token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set OAuth token' + }; + } + } + ); + + // Get auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get auto-switch settings' + }; + } + } + ); + + // Update auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, + async (_, settings: Partial): Promise => { + try { + const profileManager = getClaudeProfileManager(); + profileManager.updateAutoSwitchSettings(settings); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update auto-switch settings' + }; + } + } + ); + + // Fetch usage by sending /usage command to terminal + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, + async (_, terminalId: string): Promise => { + try { + // Send /usage command to the terminal + terminalManager.write(terminalId, '/usage\r'); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch usage' + }; + } + } + ); + + // Get best available profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, + async (_, excludeProfileId?: string): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId); + return { success: true, data: bestProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get best profile' + }; + } + } + ); + + // Retry rate-limited operation with a different profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, + async (_, request: import('../shared/types').RetryWithProfileRequest): Promise => { + try { + const profileManager = getClaudeProfileManager(); + + // Set the new active profile + profileManager.setActiveProfile(request.profileId); + + // Get the project + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Retry based on the source + switch (request.source) { + case 'changelog': + // The changelog UI will handle retrying by re-submitting the form + // We just need to confirm the profile switch was successful + return { success: true }; + + case 'task': + // For tasks, we would need to restart the task + // This is complex and would need task state restoration + return { success: true, data: { message: 'Please restart the task manually' } }; + + case 'roadmap': + // For roadmap, the UI can trigger a refresh + return { success: true }; + + case 'ideation': + // For ideation, the UI can trigger a refresh + return { success: true }; + + default: + return { success: true }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to retry with profile' + }; + } + } + ); + + // Terminal session management (persistence/restore) + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS, + async (_, projectPath: string): Promise> => { + try { + const sessions = terminalManager.getSavedSessions(projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get terminal sessions' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_SESSION, + async (_, session: import('../shared/types').TerminalSession, cols?: number, rows?: number): Promise> => { + try { + const result = await terminalManager.restore(session, cols, rows); + return { + success: result.success, + data: { + success: result.success, + terminalId: session.id, + outputBuffer: result.outputBuffer, + error: result.error + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore terminal session' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, + async (_, projectPath: string): Promise => { + try { + terminalManager.clearSavedSessions(projectPath); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear terminal sessions' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, + (_, id: string, sessionId?: string) => { + terminalManager.resumeClaude(id, sessionId); + } + ); + + // Get available session dates for a project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, + async (_, projectPath?: string) => { + try { + const dates = terminalManager.getAvailableSessionDates(projectPath); + return { success: true, data: dates }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get session dates' + }; + } + } + ); + + // Get sessions for a specific date and project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, + async (_, date: string, projectPath: string) => { + try { + const sessions = terminalManager.getSessionsForDate(date, projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get sessions for date' + }; + } + } + ); + + // Restore all sessions from a specific date + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, + async (_, date: string, projectPath: string, cols?: number, rows?: number) => { + try { + const result = await terminalManager.restoreSessionsFromDate( + date, + projectPath, + cols || 80, + rows || 24 + ); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore sessions from date' + }; + } + } + ); diff --git a/auto-claude-ui/src/main/ipc-handlers/sections/terminal_extracted.txt b/auto-claude-ui/src/main/ipc-handlers/sections/terminal_extracted.txt new file mode 100644 index 00000000..c988423f --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/sections/terminal_extracted.txt @@ -0,0 +1,487 @@ + // ============================================ + // Terminal Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CREATE, + async (_, options: TerminalCreateOptions): Promise => { + return terminalManager.create(options); + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_DESTROY, + async (_, id: string): Promise => { + return terminalManager.destroy(id); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INPUT, + (_, id: string, data: string) => { + terminalManager.write(id, data); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESIZE, + (_, id: string, cols: number, rows: number) => { + terminalManager.resize(id, cols, rows); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, + (_, id: string, cwd?: string) => { + terminalManager.invokeClaude(id, cwd); + } + ); + + // Claude profile management (multi-account support) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILES_GET, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get Claude profiles' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SAVE, + async (_, profile: ClaudeProfile): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + + // If this is a new profile without an ID, generate one + if (!profile.id) { + profile.id = profileManager.generateProfileId(profile.name); + } + + // Ensure config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + } + } + + const savedProfile = profileManager.saveProfile(profile); + return { success: true, data: savedProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_DELETE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.deleteProfile(profileId); + if (!success) { + return { success: false, error: 'Cannot delete default or last profile' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_RENAME, + async (_, profileId: string, newName: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.renameProfile(profileId, newName); + if (!success) { + return { success: false, error: 'Profile not found or invalid name' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setActiveProfile(profileId); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set active Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, + async (_, terminalId: string, profileId: string): Promise => { + try { + const result = await terminalManager.switchClaudeProfile(terminalId, profileId); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to switch Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (!profile) { + return { success: false, error: 'Profile not found' }; + } + + // Ensure the config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + console.log('[IPC] Created config directory:', profile.configDir); + } + } + + // Create a terminal and run claude setup-token there + // This is needed because claude setup-token requires TTY/raw mode + const terminalId = `claude-login-${profileId}-${Date.now()}`; + const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; + + console.log('[IPC] Initializing Claude profile:', { + profileId, + profileName: profile.name, + configDir: profile.configDir, + isDefault: profile.isDefault + }); + + // Create a new terminal for the login process + await terminalManager.create({ id: terminalId, cwd: homeDir }); + + // Wait a moment for the terminal to initialize + await new Promise(resolve => setTimeout(resolve, 500)); + + // Build the login command with the profile's config dir + // Use export to ensure the variable persists, then run setup-token + let loginCommand: string; + if (!profile.isDefault && profile.configDir) { + // Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set + loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + } else { + loginCommand = 'claude setup-token'; + } + + console.log('[IPC] Sending login command to terminal:', loginCommand); + + // Write the login command to the terminal + terminalManager.write(terminalId, `${loginCommand}\r`); + + // Notify the renderer that a login terminal was created + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('claude-profile-login-terminal', { + terminalId, + profileId, + profileName: profile.name + }); + } + + return { + success: true, + data: { + terminalId, + message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.` + } + }; + } catch (error) { + console.error('[IPC] Failed to initialize Claude profile:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to initialize Claude profile' + }; + } + } + ); + + // Set OAuth token for a profile (used when capturing from terminal or manual input) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, + async (_, profileId: string, token: string, email?: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setProfileToken(profileId, token, email); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + console.error('[IPC] Failed to set OAuth token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set OAuth token' + }; + } + } + ); + + // Get auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get auto-switch settings' + }; + } + } + ); + + // Update auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, + async (_, settings: Partial): Promise => { + try { + const profileManager = getClaudeProfileManager(); + profileManager.updateAutoSwitchSettings(settings); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update auto-switch settings' + }; + } + } + ); + + // Fetch usage by sending /usage command to terminal + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, + async (_, terminalId: string): Promise => { + try { + // Send /usage command to the terminal + terminalManager.write(terminalId, '/usage\r'); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch usage' + }; + } + } + ); + + // Get best available profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, + async (_, excludeProfileId?: string): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId); + return { success: true, data: bestProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get best profile' + }; + } + } + ); + + // Retry rate-limited operation with a different profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, + async (_, request: import('../shared/types').RetryWithProfileRequest): Promise => { + try { + const profileManager = getClaudeProfileManager(); + + // Set the new active profile + profileManager.setActiveProfile(request.profileId); + + // Get the project + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Retry based on the source + switch (request.source) { + case 'changelog': + // The changelog UI will handle retrying by re-submitting the form + // We just need to confirm the profile switch was successful + return { success: true }; + + case 'task': + // For tasks, we would need to restart the task + // This is complex and would need task state restoration + return { success: true, data: { message: 'Please restart the task manually' } }; + + case 'roadmap': + // For roadmap, the UI can trigger a refresh + return { success: true }; + + case 'ideation': + // For ideation, the UI can trigger a refresh + return { success: true }; + + default: + return { success: true }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to retry with profile' + }; + } + } + ); + + // Terminal session management (persistence/restore) + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS, + async (_, projectPath: string): Promise> => { + try { + const sessions = terminalManager.getSavedSessions(projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get terminal sessions' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_SESSION, + async (_, session: import('../shared/types').TerminalSession, cols?: number, rows?: number): Promise> => { + try { + const result = await terminalManager.restore(session, cols, rows); + return { + success: result.success, + data: { + success: result.success, + terminalId: session.id, + outputBuffer: result.outputBuffer, + error: result.error + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore terminal session' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, + async (_, projectPath: string): Promise => { + try { + terminalManager.clearSavedSessions(projectPath); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear terminal sessions' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, + (_, id: string, sessionId?: string) => { + terminalManager.resumeClaude(id, sessionId); + } + ); + + // Get available session dates for a project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, + async (_, projectPath?: string) => { + try { + const dates = terminalManager.getAvailableSessionDates(projectPath); + return { success: true, data: dates }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get session dates' + }; + } + } + ); + + // Get sessions for a specific date and project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, + async (_, date: string, projectPath: string) => { + try { + const sessions = terminalManager.getSessionsForDate(date, projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get sessions for date' + }; + } + } + ); + + // Restore all sessions from a specific date + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, + async (_, date: string, projectPath: string, cols?: number, rows?: number) => { + try { + const result = await terminalManager.restoreSessionsFromDate( + date, + projectPath, + cols || 80, + rows || 24 + ); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore sessions from date' + }; + } + } + ); \ No newline at end of file diff --git a/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts new file mode 100644 index 00000000..f231558c --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/settings-handlers.ts @@ -0,0 +1,221 @@ +import { ipcMain, dialog, app } from 'electron'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { execSync } from 'child_process'; +import path from 'path'; +import { IPC_CHANNELS, DEFAULT_APP_SETTINGS } from '../../shared/constants'; +import type { + AppSettings, + IPCResult +} from '../../shared/types'; +import { AgentManager } from '../agent'; +import type { BrowserWindow } from 'electron'; + +const settingsPath = path.join(app.getPath('userData'), 'settings.json'); + +/** + * Auto-detect the auto-claude source path relative to the app location + */ +const detectAutoBuildSourcePath = (): string | null => { + const possiblePaths = [ + path.resolve(__dirname, '..', '..', '..', 'auto-claude'), + path.resolve(app.getAppPath(), '..', 'auto-claude'), + path.resolve(process.cwd(), 'auto-claude'), + path.resolve(__dirname, '..', '..', 'auto-claude') + ]; + + for (const p of possiblePaths) { + if (existsSync(p) && existsSync(path.join(p, 'VERSION'))) { + return p; + } + } + return null; +}; + +/** + * Register all settings-related IPC handlers + */ +export function registerSettingsHandlers( + agentManager: AgentManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Settings Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.SETTINGS_GET, + async (): Promise> => { + let settings = { ...DEFAULT_APP_SETTINGS }; + + if (existsSync(settingsPath)) { + try { + const content = readFileSync(settingsPath, 'utf-8'); + settings = { ...settings, ...JSON.parse(content) }; + } catch { + // Use defaults + } + } + + // If no manual autoBuildPath is set, try to auto-detect + if (!settings.autoBuildPath) { + const detectedPath = detectAutoBuildSourcePath(); + if (detectedPath) { + settings.autoBuildPath = detectedPath; + } + } + + return { success: true, data: settings as AppSettings }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.SETTINGS_SAVE, + async (_, settings: Partial): Promise => { + try { + let currentSettings = DEFAULT_APP_SETTINGS; + if (existsSync(settingsPath)) { + const content = readFileSync(settingsPath, 'utf-8'); + currentSettings = { ...currentSettings, ...JSON.parse(content) }; + } + + const newSettings = { ...currentSettings, ...settings }; + writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2)); + + // Apply Python path if changed + if (settings.pythonPath || settings.autoBuildPath) { + agentManager.configure(settings.pythonPath, settings.autoBuildPath); + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save settings' + }; + } + } + ); + + // ============================================ + // Dialog Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.DIALOG_SELECT_DIRECTORY, + async (): Promise => { + const mainWindow = getMainWindow(); + if (!mainWindow) return null; + + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'], + title: 'Select Project Directory' + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; + } + ); + + ipcMain.handle( + IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER, + async ( + _, + location: string, + name: string, + initGit: boolean + ): Promise> => { + try { + // Validate inputs + if (!location || !name) { + return { success: false, error: 'Location and name are required' }; + } + + // Sanitize project name (convert to kebab-case, remove invalid chars) + const sanitizedName = name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-_]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + if (!sanitizedName) { + return { success: false, error: 'Invalid project name' }; + } + + const projectPath = path.join(location, sanitizedName); + + // Check if folder already exists + if (existsSync(projectPath)) { + return { success: false, error: `Folder "${sanitizedName}" already exists at this location` }; + } + + // Create the directory + mkdirSync(projectPath, { recursive: true }); + + // Initialize git if requested + let gitInitialized = false; + if (initGit) { + try { + execSync('git init', { cwd: projectPath, stdio: 'ignore' }); + gitInitialized = true; + } catch { + // Git init failed, but folder was created - continue without git + console.warn('Failed to initialize git repository'); + } + } + + return { + success: true, + data: { + path: projectPath, + name: sanitizedName, + gitInitialized + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create project folder' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.DIALOG_GET_DEFAULT_PROJECT_LOCATION, + async (): Promise => { + try { + // Return user's home directory + common project folders + const homeDir = app.getPath('home'); + const commonPaths = [ + path.join(homeDir, 'Projects'), + path.join(homeDir, 'Developer'), + path.join(homeDir, 'Code'), + path.join(homeDir, 'Documents') + ]; + + // Return the first one that exists, or Documents as fallback + for (const p of commonPaths) { + if (existsSync(p)) { + return p; + } + } + + return path.join(homeDir, 'Documents'); + } catch { + return null; + } + } + ); + + // ============================================ + // App Info + // ============================================ + + ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise => { + return app.getVersion(); + }); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts new file mode 100644 index 00000000..8d134078 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/task-handlers.ts @@ -0,0 +1,1622 @@ +import { ipcMain, BrowserWindow } from 'electron'; +import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../shared/constants'; +import type { IPCResult, Task, TaskMetadata, TaskStartOptions, ImplementationPlan, TaskStatus, Project } from '../../shared/types'; +import path from 'path'; +import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync, statSync } from 'fs'; +import { execSync, spawn } from 'child_process'; +import { projectStore } from '../project-store'; +import { fileWatcher } from '../file-watcher'; +import { taskLogService } from '../task-log-service'; +import { titleGenerator } from '../title-generator'; +import { AgentManager } from '../agent'; +import { PythonEnvManager } from '../python-env-manager'; +import { getEffectiveSourcePath } from '../auto-claude-updater'; + + +/** + * Register all task-related IPC handlers + */ +export function registerTaskHandlers( + agentManager: AgentManager, + pythonEnvManager: PythonEnvManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Task Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TASK_LIST, + async (_, projectId: string): Promise> => { + console.log('[IPC] TASK_LIST called with projectId:', projectId); + const tasks = projectStore.getTasks(projectId); + console.log('[IPC] TASK_LIST returning', tasks.length, 'tasks'); + return { success: true, data: tasks }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_CREATE, + async ( + _, + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Auto-generate title if empty using Claude AI + let finalTitle = title; + if (!title || !title.trim()) { + console.log('[TASK_CREATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(description); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_CREATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_CREATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_CREATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = description.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Generate a unique spec ID based on existing specs + // Get specs directory path + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specsDir = path.join(project.path, specsBaseDir); + + // Find next available spec number + let specNumber = 1; + if (existsSync(specsDir)) { + const existingDirs = readdirSync(specsDir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name); + + // Extract numbers from spec directory names (e.g., "001-feature" -> 1) + const existingNumbers = existingDirs + .map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter(n => n > 0); + + if (existingNumbers.length > 0) { + specNumber = Math.max(...existingNumbers) + 1; + } + } + + // Create spec ID with zero-padded number and slugified title + const slugifiedTitle = finalTitle + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 50); + const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`; + + // Create spec directory + const specDir = path.join(specsDir, specId); + mkdirSync(specDir, { recursive: true }); + + // Build metadata with source type + const taskMetadata: TaskMetadata = { + sourceType: 'manual', + ...metadata + }; + + // Process and save attached images + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + const attachmentsDir = path.join(specDir, 'attachments'); + mkdirSync(attachmentsDir, { recursive: true }); + + const savedImages: typeof taskMetadata.attachedImages = []; + + for (const image of taskMetadata.attachedImages) { + if (image.data) { + try { + // Decode base64 and save to file + const buffer = Buffer.from(image.data, 'base64'); + const imagePath = path.join(attachmentsDir, image.filename); + writeFileSync(imagePath, buffer); + + // Store relative path instead of base64 data + savedImages.push({ + id: image.id, + filename: image.filename, + mimeType: image.mimeType, + size: image.size, + path: `attachments/${image.filename}` + // Don't include data or thumbnail to save space + }); + } catch (err) { + console.error(`Failed to save image ${image.filename}:`, err); + } + } + } + + // Update metadata with saved image paths (without base64 data) + taskMetadata.attachedImages = savedImages; + } + + // Create initial implementation_plan.json (task is created but not started) + const now = new Date().toISOString(); + const implementationPlan = { + feature: finalTitle, + description: description, + created_at: now, + updated_at: now, + status: 'pending', + phases: [] + }; + + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2)); + + // Save task metadata if provided + if (taskMetadata) { + const metadataPath = path.join(specDir, 'task_metadata.json'); + writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2)); + } + + // Create requirements.json with attached images + const requirements: Record = { + task_description: description, + workflow_type: taskMetadata.category || 'feature' + }; + + // Add attached images to requirements if present + if (taskMetadata.attachedImages && taskMetadata.attachedImages.length > 0) { + requirements.attached_images = taskMetadata.attachedImages.map(img => ({ + filename: img.filename, + path: img.path, + description: '' // User can add descriptions later + })); + } + + const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS); + writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2)); + + // Create the task object + const task: Task = { + id: specId, + specId: specId, + projectId, + title: finalTitle, + description, + status: 'backlog', + subtasks: [], + logs: [], + metadata: taskMetadata, + createdAt: new Date(), + updatedAt: new Date() + }; + + return { success: true, data: task }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_DELETE, + async (_, taskId: string): Promise => { + const { rm } = await import('fs/promises'); + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task or project not found' }; + } + + // Check if task is currently running + const isRunning = agentManager.isRunning(taskId); + if (isRunning) { + return { success: false, error: 'Cannot delete a running task. Stop the task first.' }; + } + + // Delete the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsBaseDir, task.specId); + + try { + if (existsSync(specDir)) { + await rm(specDir, { recursive: true, force: true }); + console.log(`[TASK_DELETE] Deleted spec directory: ${specDir}`); + } + return { success: true }; + } catch (error) { + console.error('[TASK_DELETE] Error deleting spec directory:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete task files' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE, + async ( + _, + taskId: string, + updates: { title?: string; description?: string } + ): Promise> => { + try { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join(project.path, autoBuildDir, 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + // Auto-generate title if empty + let finalTitle = updates.title; + if (updates.title !== undefined && !updates.title.trim()) { + // Get description to use for title generation + const descriptionToUse = updates.description ?? task.description; + console.log('[TASK_UPDATE] Title is empty, generating with Claude AI...'); + try { + const generatedTitle = await titleGenerator.generateTitle(descriptionToUse); + if (generatedTitle) { + finalTitle = generatedTitle; + console.log('[TASK_UPDATE] Generated title:', finalTitle); + } else { + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + console.log('[TASK_UPDATE] AI generation failed, using fallback:', finalTitle); + } + } catch (err) { + console.error('[TASK_UPDATE] Title generation error:', err); + // Fallback: create title from first line of description + finalTitle = descriptionToUse.split('\n')[0].substring(0, 60); + if (finalTitle.length === 60) finalTitle += '...'; + } + } + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + if (existsSync(planPath)) { + try { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + if (finalTitle !== undefined) { + plan.feature = finalTitle; + } + if (updates.description !== undefined) { + plan.description = updates.description; + } + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } catch { + // Plan file might not be valid JSON, continue anyway + } + } + + // Update spec.md if it exists + const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + if (existsSync(specPath)) { + try { + let specContent = readFileSync(specPath, 'utf-8'); + + // Update title (first # heading) + if (finalTitle !== undefined) { + specContent = specContent.replace( + /^#\s+.*$/m, + `# ${finalTitle}` + ); + } + + // Update description (## Overview section content) + if (updates.description !== undefined) { + // Replace content between ## Overview and the next ## section + specContent = specContent.replace( + /(## Overview\n)([\s\S]*?)((?=\n## )|$)/, + `$1${updates.description}\n\n$3` + ); + } + + writeFileSync(specPath, specContent); + } catch { + // Spec file update failed, continue anyway + } + } + + // Build the updated task object + const updatedTask: Task = { + ...task, + title: finalTitle ?? task.title, + description: updates.description ?? task.description, + updatedAt: new Date() + }; + + return { success: true, data: updatedTask }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TASK_START, + (_, taskId: string, options?: TaskStartOptions) => { + console.log('[TASK_START] Received request for taskId:', taskId); + const mainWindow = getMainWindow(); + if (!mainWindow) { + console.log('[TASK_START] No main window found'); + return; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + console.log('[TASK_START] Task or project not found for taskId:', taskId); + mainWindow.webContents.send( + IPC_CHANNELS.TASK_ERROR, + taskId, + 'Task or project not found' + ); + return; + } + + console.log('[TASK_START] Found task:', task.specId, 'status:', task.status, 'subtasks:', task.subtasks.length); + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists (indicates spec creation was already done or in progress) + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + + // Check if this task needs spec creation first (no spec file = not yet created) + // OR if it has a spec but no implementation plan subtasks (spec created, needs planning/building) + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_START] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_START] Starting spec creation for:', task.specId, 'in:', specDir); + + // Start spec creation process - pass the existing spec directory + // so spec_runner uses it instead of creating a new one + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + // Read the spec.md to get the task description + let taskDescription = task.description || task.title; + try { + taskDescription = readFileSync(specFilePath, 'utf-8'); + } catch { + // Use default description + } + + console.log('[TASK_START] Starting task execution (no subtasks) for:', task.specId); + // Start task execution which will create the implementation plan + // Note: No parallel mode for planning phase - parallel only makes sense with multiple subtasks + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, // Sequential for planning phase + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + // Only enable parallel if there are multiple subtasks AND user has parallel enabled + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = options?.parallel ?? project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? (options?.workers ?? project.settings.maxWorkers) : 1; + + console.log('[TASK_START] Starting task execution (has subtasks) for:', task.specId); + console.log('[TASK_START] Parallel decision:', { + hasMultipleSubtasks, + pendingSubtasks, + parallelEnabled, + useParallel, + workers + }); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify status change + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + ); + + ipcMain.on(IPC_CHANNELS.TASK_STOP, (_, taskId: string) => { + agentManager.killTask(taskId); + fileWatcher.unwatch(taskId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'backlog' + ); + } + }); + + ipcMain.handle( + IPC_CHANNELS.TASK_REVIEW, + async ( + _, + taskId: string, + approved: boolean, + feedback?: string + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Check if dev mode is enabled for this project + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + if (approved) { + // Write approval to QA report + const qaReportPath = path.join(specDir, AUTO_BUILD_PATHS.QA_REPORT); + writeFileSync( + qaReportPath, + `# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n` + ); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'done' + ); + } + } else { + // Write feedback for QA fixer + const fixRequestPath = path.join(specDir, 'QA_FIX_REQUEST.md'); + writeFileSync( + fixRequestPath, + `# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}\n\nCreated at: ${new Date().toISOString()}\n` + ); + + // Restart QA process with dev mode + agentManager.startQAProcess(taskId, project.path, task.specId); + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } + ); + + ipcMain.handle( + IPC_CHANNELS.TASK_UPDATE_STATUS, + async ( + _, + taskId: string, + status: TaskStatus + ): Promise => { + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = path.join( + project.path, + specsBaseDir, + task.specId + ); + + // Update implementation_plan.json if it exists + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + + // Store the exact UI status - project-store.ts will map it back + plan.status = status; + // Also store mapped version for Python compatibility + plan.planStatus = status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } else { + // If no implementation plan exists yet, create a basic one + const plan = { + feature: task.title, + description: task.description || '', + created_at: task.createdAt.toISOString(), + updated_at: new Date().toISOString(), + status: status, // Store exact UI status for persistence + planStatus: status === 'done' ? 'completed' + : status === 'in_progress' ? 'in_progress' + : status === 'ai_review' ? 'review' + : status === 'human_review' ? 'review' + : 'pending', + phases: [] + }; + + // Ensure spec directory exists + if (!existsSync(specDir)) { + mkdirSync(specDir, { recursive: true }); + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Auto-start task when status changes to 'in_progress' and no process is running + if (status === 'in_progress' && !agentManager.isRunning(taskId)) { + const mainWindow = getMainWindow(); + console.log('[TASK_UPDATE_STATUS] Auto-starting task:', taskId); + + // Start file watcher for this task + fileWatcher.watch(taskId, specDir); + + // Check if spec.md exists + const specFilePath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE); + const hasSpec = existsSync(specFilePath); + const needsSpecCreation = !hasSpec; + const needsImplementation = hasSpec && task.subtasks.length === 0; + + console.log('[TASK_UPDATE_STATUS] hasSpec:', hasSpec, 'needsSpecCreation:', needsSpecCreation, 'needsImplementation:', needsImplementation); + + if (needsSpecCreation) { + // No spec file - need to run spec_runner.py to create the spec + const taskDescription = task.description || task.title; + console.log('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId); + agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDir, task.metadata); + } else if (needsImplementation) { + // Spec exists but no subtasks - run run.py to create implementation plan and execute + console.log('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: false, + workers: 1 + } + ); + } else { + // Task has subtasks, start normal execution + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending' || s.status === 'in_progress').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + console.log('[TASK_UPDATE_STATUS] Starting task execution (has subtasks) for:', task.specId); + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + } + + // Notify renderer about status change + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + 'in_progress' + ); + } + } + + return { success: true }; + } catch (error) { + console.error('Failed to update task status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update task status' + }; + } + } + ); + + // Handler to check if a task is actually running (has active process) + ipcMain.handle( + IPC_CHANNELS.TASK_CHECK_RUNNING, + async (_, taskId: string): Promise> => { + const isRunning = agentManager.isRunning(taskId); + return { success: true, data: isRunning }; + } + ); + + // Handler to recover a stuck task (status says in_progress but no process running) + ipcMain.handle( + IPC_CHANNELS.TASK_RECOVER_STUCK, + async ( + _, + taskId: string, + options?: { targetStatus?: TaskStatus; autoRestart?: boolean } + ): Promise> => { + const targetStatus = options?.targetStatus; + const autoRestart = options?.autoRestart ?? false; + // Check if task is actually running + const isActuallyRunning = agentManager.isRunning(taskId); + + if (isActuallyRunning) { + return { + success: false, + error: 'Task is still running. Stop it first before recovering.', + data: { + taskId, + recovered: false, + newStatus: 'in_progress' as TaskStatus, + message: 'Task is still running' + } + }; + } + + // Find task and project + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory + const autoBuildDir = project.autoBuildPath || '.auto-claude'; + const specDir = path.join( + project.path, + autoBuildDir, + 'specs', + task.specId + ); + + // Update implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + + try { + // Read the plan to analyze subtask progress + let plan: Record | null = null; + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + plan = JSON.parse(planContent); + } + + // Determine the target status intelligently based on subtask progress + // If targetStatus is explicitly provided, use it; otherwise calculate from subtasks + let newStatus: TaskStatus = targetStatus || 'backlog'; + + if (!targetStatus && plan?.phases && Array.isArray(plan.phases)) { + // Analyze subtask statuses to determine appropriate recovery status + const allSubtasks: Array<{ status: string }> = []; + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + allSubtasks.push(...phase.subtasks); + } + } + + if (allSubtasks.length > 0) { + const completedCount = allSubtasks.filter(s => s.status === 'completed').length; + const allCompleted = completedCount === allSubtasks.length; + + if (allCompleted) { + // All subtasks completed - should go to review (ai_review or human_review based on source) + // For recovery, human_review is safer as it requires manual verification + newStatus = 'human_review'; + } else if (completedCount > 0) { + // Some subtasks completed, some still pending - task is in progress + newStatus = 'in_progress'; + } + // else: no subtasks completed, stay with 'backlog' + } + } + + if (plan) { + // Update status + plan.status = newStatus; + plan.planStatus = newStatus === 'done' ? 'completed' + : newStatus === 'in_progress' ? 'in_progress' + : newStatus === 'ai_review' ? 'review' + : newStatus === 'human_review' ? 'review' + : 'pending'; + plan.updated_at = new Date().toISOString(); + + // Add recovery note + plan.recoveryNote = `Task recovered from stuck state at ${new Date().toISOString()}`; + + // Reset in_progress and failed subtask statuses to 'pending' so they can be retried + // Keep completed subtasks as-is so run.py can resume from where it left off + if (plan.phases && Array.isArray(plan.phases)) { + for (const phase of plan.phases as Array<{ subtasks?: Array<{ status: string; actual_output?: string; started_at?: string; completed_at?: string }> }>) { + if (phase.subtasks && Array.isArray(phase.subtasks)) { + for (const subtask of phase.subtasks) { + // Reset in_progress subtasks to pending (they were interrupted) + // Keep completed subtasks as-is so run.py can resume + if (subtask.status === 'in_progress') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + // Also reset failed subtasks so they can be retried + if (subtask.status === 'failed') { + subtask.status = 'pending'; + // Clear execution data to maintain consistency + delete subtask.actual_output; + delete subtask.started_at; + delete subtask.completed_at; + } + } + } + } + } + + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Stop file watcher if it was watching this task + fileWatcher.unwatch(taskId); + + // Auto-restart the task if requested + let autoRestarted = false; + if (autoRestart && project) { + try { + // Set status to in_progress for the restart + newStatus = 'in_progress'; + + // Update plan status for restart + if (plan) { + plan.status = 'in_progress'; + plan.planStatus = 'in_progress'; + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + + // Start the task execution + + // Check if we should use parallel mode + const hasMultipleSubtasks = task.subtasks.length > 1; + const pendingSubtasks = task.subtasks.filter(s => s.status === 'pending').length; + const parallelEnabled = project.settings.parallelEnabled; + const useParallel = parallelEnabled && hasMultipleSubtasks && pendingSubtasks > 1; + const workers = useParallel ? project.settings.maxWorkers : 1; + + // Start file watcher for this task + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId); + fileWatcher.watch(taskId, specDirForWatcher); + + agentManager.startTaskExecution( + taskId, + project.path, + task.specId, + { + parallel: useParallel, + workers + } + ); + + autoRestarted = true; + console.log(`[Recovery] Auto-restarted task ${taskId}`); + } catch (restartError) { + console.error('Failed to auto-restart task after recovery:', restartError); + // Recovery succeeded but restart failed - still report success + } + } + + // Notify renderer of status change + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send( + IPC_CHANNELS.TASK_STATUS_CHANGE, + taskId, + newStatus + ); + } + + return { + success: true, + data: { + taskId, + recovered: true, + newStatus, + message: autoRestarted + ? 'Task recovered and restarted successfully' + : `Task recovered successfully and moved to ${newStatus}`, + autoRestarted + } + }; + } catch (error) { + console.error('Failed to recover stuck task:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to recover task' + }; + } + } + ); + + // ============================================ + // Workspace Management Operations (for human review) + // ============================================ + + /** + * Helper function to find task and project by taskId + */ + const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => { + const projects = projectStore.getProjects(); + let task: Task | undefined; + let project: Project | undefined; + + for (const p of projects) { + const tasks = projectStore.getTasks(p.id); + task = tasks.find((t) => t.id === taskId || t.specId === taskId); + if (task) { + project = p; + break; + } + } + + return { task, project }; + }; + + /** + * Get the worktree status for a task + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_STATUS, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { exists: false } + }; + } + + // Get branch info from git + try { + // Get current branch in worktree + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get base branch (usually main or master) + let baseBranch = 'main'; + try { + // Try to get the default branch + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --stat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse the summary line (e.g., "3 files changed, 50 insertions(+), 10 deletions(-)") + const summaryMatch = diffStat.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); + if (summaryMatch) { + filesChanged = parseInt(summaryMatch[1], 10) || 0; + additions = parseInt(summaryMatch[2], 10) || 0; + deletions = parseInt(summaryMatch[3], 10) || 0; + } + } catch { + // Ignore diff errors + } + + return { + success: true, + data: { + exists: true, + worktreePath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + } + }; + } catch (gitError) { + console.error('Git error getting worktree status:', gitError); + return { + success: true, + data: { exists: true, worktreePath } + }; + } + } catch (error) { + console.error('Failed to get worktree status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree status' + }; + } + } + ); + + /** + * Get the diff for a task's worktree + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DIFF, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { success: false, error: 'No worktree found for this task' }; + } + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get the diff with file stats + const files: import('../../shared/types').WorktreeDiffFile[] = []; + + try { + // Get numstat for additions/deletions per file + const numstat = execSync(`git diff --numstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Get name-status for file status + const nameStatus = execSync(`git diff --name-status ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Parse name-status to get file statuses + const statusMap: Record = {}; + nameStatus.split('\n').filter(Boolean).forEach((line: string) => { + const [status, ...pathParts] = line.split('\t'); + const filePath = pathParts.join('\t'); // Handle files with tabs in name + switch (status[0]) { + case 'A': statusMap[filePath] = 'added'; break; + case 'M': statusMap[filePath] = 'modified'; break; + case 'D': statusMap[filePath] = 'deleted'; break; + case 'R': statusMap[pathParts[1] || filePath] = 'renamed'; break; + default: statusMap[filePath] = 'modified'; + } + }); + + // Parse numstat for additions/deletions + numstat.split('\n').filter(Boolean).forEach((line: string) => { + const [adds, dels, filePath] = line.split('\t'); + files.push({ + path: filePath, + status: statusMap[filePath] || 'modified', + additions: parseInt(adds, 10) || 0, + deletions: parseInt(dels, 10) || 0 + }); + }); + } catch (diffError) { + console.error('Error getting diff:', diffError); + } + + // Generate summary + const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); + const summary = `${files.length} files changed, ${totalAdditions} insertions(+), ${totalDeletions} deletions(-)`; + + return { + success: true, + data: { files, summary } + }; + } catch (error) { + console.error('Failed to get worktree diff:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get worktree diff' + }; + } + } + ); + + /** + * Merge the worktree changes into the main branch + * @param taskId - The task ID to merge + * @param options - Merge options { noCommit?: boolean } + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_MERGE, + async (_, taskId: string, options?: { noCommit?: boolean }): Promise> => { + try { + // Ensure Python environment is ready + if (!pythonEnvManager.isEnvReady()) { + const autoBuildSource = getEffectiveSourcePath(); + if (autoBuildSource) { + const status = await pythonEnvManager.initialize(autoBuildSource); + if (!status.ready) { + return { success: false, error: `Python environment not ready: ${status.error || 'Unknown error'}` }; + } + } else { + return { success: false, error: 'Python environment not ready and Auto Claude source not found' }; + } + } + + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Use run.py --merge to handle the merge + const sourcePath = getEffectiveSourcePath(); + if (!sourcePath) { + return { success: false, error: 'Auto Claude source not found' }; + } + + const runScript = path.join(sourcePath, 'run.py'); + const specDir = path.join(project.path, project.autoBuildPath || '.auto-claude', 'specs', task.specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + const args = [ + runScript, + '--spec', task.specId, + '--project-dir', project.path, + '--merge' + ]; + + // Add --no-commit flag if requested (stage changes without committing) + if (options?.noCommit) { + args.push('--no-commit'); + } + + return new Promise((resolve) => { + const pythonPath = pythonEnvManager.getPythonPath() || 'python3'; + const mergeProcess = spawn(pythonPath, args, { + cwd: sourcePath, + env: { + ...process.env, + PYTHONUNBUFFERED: '1' + } + }); + + let stdout = ''; + let stderr = ''; + + mergeProcess.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + mergeProcess.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + mergeProcess.on('close', (code: number) => { + if (code === 0) { + // Persist the status change to implementation_plan.json + const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); + try { + if (existsSync(planPath)) { + const planContent = readFileSync(planPath, 'utf-8'); + const plan = JSON.parse(planContent); + plan.status = 'done'; + plan.planStatus = 'completed'; + plan.updated_at = new Date().toISOString(); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + } + } catch (persistError) { + console.error('Failed to persist task status:', persistError); + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'done'); + } + + resolve({ + success: true, + data: { + success: true, + message: 'Changes merged successfully' + } + }); + } else { + // Check if there were conflicts + const hasConflicts = stdout.includes('conflict') || stderr.includes('conflict'); + + resolve({ + success: true, + data: { + success: false, + message: hasConflicts ? 'Merge conflicts detected' : `Merge failed: ${stderr || stdout}`, + conflictFiles: hasConflicts ? [] : undefined + } + }); + } + }); + + mergeProcess.on('error', (err: Error) => { + resolve({ + success: false, + error: `Failed to run merge: ${err.message}` + }); + }); + }); + } catch (error) { + console.error('Failed to merge worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to merge worktree' + }; + } + } + ); + + /** + * Discard the worktree changes + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_WORKTREE_DISCARD, + async (_, taskId: string): Promise> => { + try { + const { task, project } = findTaskAndProject(taskId); + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Per-spec worktree path: .worktrees/{spec-name}/ + const worktreePath = path.join(project.path, '.worktrees', task.specId); + + if (!existsSync(worktreePath)) { + return { + success: true, + data: { + success: true, + message: 'No worktree to discard' + } + }; + } + + try { + // Get the branch name before removing + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf-8' + }).trim(); + + // Remove the worktree + execSync(`git worktree remove --force "${worktreePath}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + + // Delete the branch + try { + execSync(`git branch -D "${branch}"`, { + cwd: project.path, + encoding: 'utf-8' + }); + } catch { + // Branch might already be deleted or not exist + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_STATUS_CHANGE, taskId, 'backlog'); + } + + return { + success: true, + data: { + success: true, + message: 'Worktree discarded successfully' + } + }; + } catch (gitError) { + console.error('Git error discarding worktree:', gitError); + return { + success: false, + error: `Failed to discard worktree: ${gitError instanceof Error ? gitError.message : 'Unknown error'}` + }; + } + } catch (error) { + console.error('Failed to discard worktree:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to discard worktree' + }; + } + } + ); + + /** + * List all spec worktrees for a project + * Per-spec architecture: Each spec has its own worktree at .worktrees/{spec-name}/ + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LIST_WORKTREES, + async (_, projectId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const worktreesDir = path.join(project.path, '.worktrees'); + const worktrees: import('../../shared/types').WorktreeListItem[] = []; + + if (!existsSync(worktreesDir)) { + return { success: true, data: { worktrees } }; + } + + // Get all directories in .worktrees + const entries = readdirSync(worktreesDir); + for (const entry of entries) { + const entryPath = path.join(worktreesDir, entry); + const stat = statSync(entryPath); + + // Skip worker directories and non-directories + if (!stat.isDirectory() || entry.startsWith('worker-')) { + continue; + } + + try { + // Get branch info + const branch = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + // Get base branch + let baseBranch = 'main'; + try { + baseBranch = execSync('git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo main', { + cwd: project.path, + encoding: 'utf-8' + }).trim().replace('origin/', ''); + } catch { + baseBranch = 'main'; + } + + // Get commit count + let commitCount = 0; + try { + const countOutput = execSync(`git rev-list --count ${baseBranch}..HEAD 2>/dev/null || echo 0`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + commitCount = parseInt(countOutput, 10) || 0; + } catch { + commitCount = 0; + } + + // Get diff stats + let filesChanged = 0; + let additions = 0; + let deletions = 0; + + try { + const diffStat = execSync(`git diff --shortstat ${baseBranch}...HEAD 2>/dev/null || echo ""`, { + cwd: entryPath, + encoding: 'utf-8' + }).trim(); + + const filesMatch = diffStat.match(/(\d+) files? changed/); + const addMatch = diffStat.match(/(\d+) insertions?/); + const delMatch = diffStat.match(/(\d+) deletions?/); + + if (filesMatch) filesChanged = parseInt(filesMatch[1], 10) || 0; + if (addMatch) additions = parseInt(addMatch[1], 10) || 0; + if (delMatch) deletions = parseInt(delMatch[1], 10) || 0; + } catch { + // Ignore diff errors + } + + worktrees.push({ + specName: entry, + path: entryPath, + branch, + baseBranch, + commitCount, + filesChanged, + additions, + deletions + }); + } catch (gitError) { + console.error(`Error getting info for worktree ${entry}:`, gitError); + // Skip this worktree if we can't get git info + } + } + + return { success: true, data: { worktrees } }; + } catch (error) { + console.error('Failed to list worktrees:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to list worktrees' + }; + } + } + ); + + // ============================================ + // Task Logs Operations + // ============================================ + + /** + * Get task logs from spec directory + * Returns logs organized by phase (planning, coding, validation) + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LOGS_GET, + async (_, projectId: string, specId: string): Promise> => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const specsRelPath = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsRelPath, specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + const logs = taskLogService.loadLogs(specDir, project.path, specsRelPath, specId); + return { success: true, data: logs }; + } catch (error) { + console.error('Failed to get task logs:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get task logs' + }; + } + } + ); + + /** + * Start watching a spec for log changes + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LOGS_WATCH, + async (_, projectId: string, specId: string): Promise => { + try { + const project = projectStore.getProject(projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + const specsRelPath = getSpecsDir(project.autoBuildPath); + const specDir = path.join(project.path, specsRelPath, specId); + + if (!existsSync(specDir)) { + return { success: false, error: 'Spec directory not found' }; + } + + taskLogService.startWatching(specId, specDir, project.path, specsRelPath); + return { success: true }; + } catch (error) { + console.error('Failed to start watching task logs:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to start watching' + }; + } + } + ); + + /** + * Stop watching a spec for log changes + */ + ipcMain.handle( + IPC_CHANNELS.TASK_LOGS_UNWATCH, + async (_, specId: string): Promise => { + try { + taskLogService.stopWatching(specId); + return { success: true }; + } catch (error) { + console.error('Failed to stop watching task logs:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to stop watching' + }; + } + } + ); + + // Setup task log service event forwarding to renderer + taskLogService.on('logs-changed', (specId: string, logs: import('../../shared/types').TaskLogs) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_LOGS_CHANGED, specId, logs); + } + }); + + taskLogService.on('stream-chunk', (specId: string, chunk: import('../../shared/types').TaskLogStreamChunk) => { + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.TASK_LOGS_STREAM, specId, chunk); + } + }); + +} diff --git a/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts new file mode 100644 index 00000000..5b00ae7d --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/terminal-handlers.ts @@ -0,0 +1,504 @@ +import { ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings } from '../../shared/types'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import { TerminalManager } from '../terminal-manager'; +import { projectStore } from '../project-store'; + + +/** + * Register all terminal-related IPC handlers + */ +export function registerTerminalHandlers( + terminalManager: TerminalManager, + getMainWindow: () => BrowserWindow | null +): void { + // ============================================ + // Terminal Operations + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CREATE, + async (_, options: TerminalCreateOptions): Promise => { + return terminalManager.create(options); + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_DESTROY, + async (_, id: string): Promise => { + return terminalManager.destroy(id); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INPUT, + (_, id: string, data: string) => { + terminalManager.write(id, data); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESIZE, + (_, id: string, cols: number, rows: number) => { + terminalManager.resize(id, cols, rows); + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, + (_, id: string, cwd?: string) => { + terminalManager.invokeClaude(id, cwd); + } + ); + + // Claude profile management (multi-account support) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILES_GET, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get Claude profiles' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SAVE, + async (_, profile: ClaudeProfile): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + + // If this is a new profile without an ID, generate one + if (!profile.id) { + profile.id = profileManager.generateProfileId(profile.name); + } + + // Ensure config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + } + } + + const savedProfile = profileManager.saveProfile(profile); + return { success: true, data: savedProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_DELETE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.deleteProfile(profileId); + if (!success) { + return { success: false, error: 'Cannot delete default or last profile' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_RENAME, + async (_, profileId: string, newName: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.renameProfile(profileId, newName); + if (!success) { + return { success: false, error: 'Profile not found or invalid name' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setActiveProfile(profileId); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set active Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, + async (_, terminalId: string, profileId: string): Promise => { + try { + const result = await terminalManager.switchClaudeProfile(terminalId, profileId); + return result; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to switch Claude profile' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, + async (_, profileId: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (!profile) { + return { success: false, error: 'Profile not found' }; + } + + // Ensure the config directory exists for non-default profiles + if (!profile.isDefault && profile.configDir) { + const { mkdirSync, existsSync } = await import('fs'); + if (!existsSync(profile.configDir)) { + mkdirSync(profile.configDir, { recursive: true }); + console.log('[IPC] Created config directory:', profile.configDir); + } + } + + // Create a terminal and run claude setup-token there + // This is needed because claude setup-token requires TTY/raw mode + const terminalId = `claude-login-${profileId}-${Date.now()}`; + const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; + + console.log('[IPC] Initializing Claude profile:', { + profileId, + profileName: profile.name, + configDir: profile.configDir, + isDefault: profile.isDefault + }); + + // Create a new terminal for the login process + await terminalManager.create({ id: terminalId, cwd: homeDir }); + + // Wait a moment for the terminal to initialize + await new Promise(resolve => setTimeout(resolve, 500)); + + // Build the login command with the profile's config dir + // Use export to ensure the variable persists, then run setup-token + let loginCommand: string; + if (!profile.isDefault && profile.configDir) { + // Use export and run in subshell to ensure CLAUDE_CONFIG_DIR is properly set + loginCommand = `export CLAUDE_CONFIG_DIR="${profile.configDir}" && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`; + } else { + loginCommand = 'claude setup-token'; + } + + console.log('[IPC] Sending login command to terminal:', loginCommand); + + // Write the login command to the terminal + terminalManager.write(terminalId, `${loginCommand}\r`); + + // Notify the renderer that a login terminal was created + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('claude-profile-login-terminal', { + terminalId, + profileId, + profileName: profile.name + }); + } + + return { + success: true, + data: { + terminalId, + message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.` + } + }; + } catch (error) { + console.error('[IPC] Failed to initialize Claude profile:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to initialize Claude profile' + }; + } + } + ); + + // Set OAuth token for a profile (used when capturing from terminal or manual input) + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, + async (_, profileId: string, token: string, email?: string): Promise => { + try { + const profileManager = getClaudeProfileManager(); + const success = profileManager.setProfileToken(profileId, token, email); + if (!success) { + return { success: false, error: 'Profile not found' }; + } + return { success: true }; + } catch (error) { + console.error('[IPC] Failed to set OAuth token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set OAuth token' + }; + } + } + ); + + // Get auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getAutoSwitchSettings(); + return { success: true, data: settings }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get auto-switch settings' + }; + } + } + ); + + // Update auto-switch settings + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, + async (_, settings: Partial): Promise => { + try { + const profileManager = getClaudeProfileManager(); + profileManager.updateAutoSwitchSettings(settings); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update auto-switch settings' + }; + } + } + ); + + // Fetch usage by sending /usage command to terminal + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, + async (_, terminalId: string): Promise => { + try { + // Send /usage command to the terminal + terminalManager.write(terminalId, '/usage\r'); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch usage' + }; + } + } + ); + + // Get best available profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, + async (_, excludeProfileId?: string): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const bestProfile = profileManager.getBestAvailableProfile(excludeProfileId); + return { success: true, data: bestProfile }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get best profile' + }; + } + } + ); + + // Retry rate-limited operation with a different profile + ipcMain.handle( + IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, + async (_, request: import('../../shared/types').RetryWithProfileRequest): Promise => { + try { + const profileManager = getClaudeProfileManager(); + + // Set the new active profile + profileManager.setActiveProfile(request.profileId); + + // Get the project + const project = projectStore.getProject(request.projectId); + if (!project) { + return { success: false, error: 'Project not found' }; + } + + // Retry based on the source + switch (request.source) { + case 'changelog': + // The changelog UI will handle retrying by re-submitting the form + // We just need to confirm the profile switch was successful + return { success: true }; + + case 'task': + // For tasks, we would need to restart the task + // This is complex and would need task state restoration + return { success: true, data: { message: 'Please restart the task manually' } }; + + case 'roadmap': + // For roadmap, the UI can trigger a refresh + return { success: true }; + + case 'ideation': + // For ideation, the UI can trigger a refresh + return { success: true }; + + default: + return { success: true }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to retry with profile' + }; + } + } + ); + + // Terminal session management (persistence/restore) + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS, + async (_, projectPath: string): Promise> => { + try { + const sessions = terminalManager.getSavedSessions(projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get terminal sessions' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_SESSION, + async (_, session: import('../../shared/types').TerminalSession, cols?: number, rows?: number): Promise> => { + try { + const result = await terminalManager.restore(session, cols, rows); + return { + success: result.success, + data: { + success: result.success, + terminalId: session.id, + outputBuffer: result.outputBuffer, + error: result.error + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore terminal session' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, + async (_, projectPath: string): Promise => { + try { + terminalManager.clearSavedSessions(projectPath); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear terminal sessions' + }; + } + } + ); + + ipcMain.on( + IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, + (_, id: string, sessionId?: string) => { + terminalManager.resumeClaude(id, sessionId); + } + ); + + // Get available session dates for a project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, + async (_, projectPath?: string) => { + try { + const dates = terminalManager.getAvailableSessionDates(projectPath); + return { success: true, data: dates }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get session dates' + }; + } + } + ); + + // Get sessions for a specific date and project + ipcMain.handle( + IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, + async (_, date: string, projectPath: string) => { + try { + const sessions = terminalManager.getSessionsForDate(date, projectPath); + return { success: true, data: sessions }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get sessions for date' + }; + } + } + ); + + // Restore all sessions from a specific date + ipcMain.handle( + IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, + async (_, date: string, projectPath: string, cols?: number, rows?: number) => { + try { + const result = await terminalManager.restoreSessionsFromDate( + date, + projectPath, + cols || 80, + rows || 24 + ); + return { success: true, data: result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore sessions from date' + }; + } + } + ); +} diff --git a/auto-claude-ui/src/main/ipc-handlers/utils.ts b/auto-claude-ui/src/main/ipc-handlers/utils.ts new file mode 100644 index 00000000..4bb738e0 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-handlers/utils.ts @@ -0,0 +1,32 @@ +/** + * Shared utilities for IPC handlers + */ + +/** + * Parse .env file into key-value object + */ +export function parseEnvFile(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) continue; + + const equalsIndex = trimmed.indexOf('='); + if (equalsIndex > 0) { + const key = trimmed.substring(0, equalsIndex).trim(); + let value = trimmed.substring(equalsIndex + 1).trim(); + // Remove quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + } + return result; +} diff --git a/auto-claude-ui/src/main/ipc-setup.ts b/auto-claude-ui/src/main/ipc-setup.ts new file mode 100644 index 00000000..5452cbe8 --- /dev/null +++ b/auto-claude-ui/src/main/ipc-setup.ts @@ -0,0 +1,49 @@ +/** + * IPC Handlers Setup + * + * This file now serves as a simple entry point that delegates to modularized handlers. + * All IPC handlers have been organized into domain-specific modules in ./ipc-handlers/ + */ + +import type { BrowserWindow } from 'electron'; +import { AgentManager } from './agent'; +import { TerminalManager } from './terminal-manager'; +import { PythonEnvManager } from './python-env-manager'; +import { setupIpcHandlers as setupModularHandlers } from './ipc-handlers'; + +/** + * Setup all IPC handlers + * + * This function has been refactored to use modular handlers for better organization. + * The monolithic 6900+ line file has been split into domain-specific modules: + * + * - project-handlers.ts: Project CRUD and initialization + * - task-handlers.ts: Task management and execution + * - terminal-handlers.ts: Terminal operations and Claude profiles + * - agent-events-handlers.ts: Agent event forwarding + * - settings-handlers.ts: App settings and dialogs + * - file-handlers.ts: File system operations + * - roadmap-handlers.ts: Roadmap generation and management + * - context-handlers.ts: Project context and memory + * - env-handlers.ts: Environment configuration + * - linear-handlers.ts: Linear integration + * - github-handlers.ts: GitHub integration + * - autobuild-source-handlers.ts: Source updates + * - ideation-handlers.ts: Ideation generation + * - changelog-handlers.ts: Changelog operations + * - insights-handlers.ts: AI insights chat + * + * @param agentManager - The agent manager instance + * @param terminalManager - The terminal manager instance + * @param getMainWindow - Function to get the main BrowserWindow + * @param pythonEnvManager - The Python environment manager instance + */ +export function setupIpcHandlers( + agentManager: AgentManager, + terminalManager: TerminalManager, + getMainWindow: () => BrowserWindow | null, + pythonEnvManager: PythonEnvManager +): void { + // Delegate to modular handler setup + setupModularHandlers(agentManager, terminalManager, getMainWindow, pythonEnvManager); +} diff --git a/auto-claude-ui/src/main/project-store.ts b/auto-claude-ui/src/main/project-store.ts index e728bb60..67ea1903 100644 --- a/auto-claude-ui/src/main/project-store.ts +++ b/auto-claude-ui/src/main/project-store.ts @@ -2,9 +2,9 @@ import { app } from 'electron'; import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; -import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason } from '../shared/types'; +import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types'; import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir } from '../shared/constants'; -import { getAutoBuildPath } from './project-initializer'; +import { getAutoBuildPath, isInitialized } from './project-initializer'; interface StoreData { projects: Project[]; @@ -126,6 +126,47 @@ export class ProjectStore { return this.data.projects; } + /** + * Validate all projects to ensure their .auto-claude folders still exist. + * If a project has autoBuildPath set but the folder was deleted, + * reset autoBuildPath to empty string so the UI prompts for reinitialization. + * + * @returns Array of project IDs that were reset due to missing .auto-claude folder + */ + validateProjects(): string[] { + const resetProjectIds: string[] = []; + let hasChanges = false; + + for (const project of this.data.projects) { + // Skip projects that aren't initialized (autoBuildPath is empty) + if (!project.autoBuildPath) { + continue; + } + + // Check if the project path still exists + if (!existsSync(project.path)) { + console.log(`[ProjectStore] Project path no longer exists: ${project.path}`); + continue; // Don't reset - let user handle this case + } + + // Check if .auto-claude folder still exists + if (!isInitialized(project.path)) { + console.log(`[ProjectStore] .auto-claude folder missing for project "${project.name}" at ${project.path}`); + project.autoBuildPath = ''; + project.updatedAt = new Date(); + resetProjectIds.push(project.id); + hasChanges = true; + } + } + + if (hasChanges) { + this.save(); + console.log(`[ProjectStore] Reset ${resetProjectIds.length} project(s) due to missing .auto-claude folder`); + } + + return resetProjectIds; + } + /** * Get a project by ID */ @@ -153,23 +194,35 @@ export class ProjectStore { * Get tasks for a project by scanning specs directory */ getTasks(projectId: string): Task[] { + console.log('[ProjectStore] getTasks called with projectId:', projectId); const project = this.getProject(projectId); - if (!project) return []; + if (!project) { + console.log('[ProjectStore] Project not found for id:', projectId); + return []; + } + console.log('[ProjectStore] Found project:', project.name, 'autoBuildPath:', project.autoBuildPath); // Get specs directory path const specsBaseDir = getSpecsDir(project.autoBuildPath); const specsDir = path.join(project.path, specsBaseDir); + console.log('[ProjectStore] specsDir:', specsDir, 'exists:', existsSync(specsDir)); if (!existsSync(specsDir)) return []; const tasks: Task[] = []; + let specDirs: ReturnType = []; try { - const specDirs = readdirSync(specsDir, { withFileTypes: true }); + specDirs = readdirSync(specsDir, { withFileTypes: true }); + } catch (error) { + console.error('[ProjectStore] Error reading specs directory:', error); + return []; + } - for (const dir of specDirs) { - if (!dir.isDirectory()) continue; - if (dir.name === '.gitkeep') continue; + for (const dir of specDirs) { + if (!dir.isDirectory()) continue; + if (dir.name === '.gitkeep') continue; + try { const specPath = path.join(specsDir, dir.name); const planPath = path.join(specPath, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN); const specFilePath = path.join(specPath, AUTO_BUILD_PATHS.SPEC_FILE); @@ -220,16 +273,17 @@ export class ProjectStore { // Determine task status and review reason from plan const { status, reviewReason } = this.determineTaskStatusAndReason(plan, specPath, metadata); - // Extract subtasks from plan - const subtasks = plan?.phases.flatMap((phase) => - phase.subtasks.map((subtask) => ({ + // Extract subtasks from plan (handle both 'subtasks' and 'chunks' naming) + const subtasks = plan?.phases?.flatMap((phase) => { + const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || []; + return items.map((subtask) => ({ id: subtask.id, title: subtask.description, description: subtask.description, status: subtask.status, files: [] - })) - ) || []; + })); + }) || []; tasks.push({ id: dir.name, // Use spec directory name as ID @@ -245,11 +299,13 @@ export class ProjectStore { createdAt: new Date(plan?.created_at || Date.now()), updatedAt: new Date(plan?.updated_at || Date.now()) }); + } catch (error) { + // Log error but continue processing other specs + console.error(`[ProjectStore] Error loading spec ${dir.name}:`, error); } - } catch { - // Return empty array on error } + console.log('[ProjectStore] Returning', tasks.length, 'tasks out of', specDirs.filter(d => d.isDirectory() && d.name !== '.gitkeep').length, 'spec directories'); return tasks; } @@ -269,7 +325,8 @@ export class ProjectStore { specPath: string, metadata?: TaskMetadata ): { status: TaskStatus; reviewReason?: ReviewReason } { - const allSubtasks = plan?.phases?.flatMap((p) => p.subtasks) || []; + // Handle both 'subtasks' and 'chunks' naming conventions, filter out undefined + const allSubtasks = plan?.phases?.flatMap((p) => p.subtasks || (p as { chunks?: PlanSubtask[] }).chunks || []).filter(Boolean) || []; let calculatedStatus: TaskStatus = 'backlog'; let reviewReason: ReviewReason | undefined; @@ -327,23 +384,30 @@ export class ProjectStore { if (storedStatus) { // Planning/coding status from the backend should be respected even if subtasks aren't in progress yet // This happens when a task is in planning phase (creating spec) but no subtasks have been started - const isActiveProcessStatus = plan.status === 'planning' || plan.status === 'coding'; + const isActiveProcessStatus = (plan.status as string) === 'planning' || (plan.status as string) === 'coding'; + + // Check if this is a plan review (spec approval stage before coding starts) + // planStatus: "review" indicates spec creation is complete and awaiting user approval + const isPlanReviewStage = (plan as unknown as { planStatus?: string })?.planStatus === 'review'; const isStoredStatusValid = (storedStatus === calculatedStatus) || // Matches calculated (storedStatus === 'human_review' && calculatedStatus === 'ai_review') || // Human review is more advanced than ai_review + (storedStatus === 'human_review' && isPlanReviewStage) || // Plan review stage (awaiting spec approval) (isActiveProcessStatus && storedStatus === 'in_progress'); // Planning/coding phases should show as in_progress if (isStoredStatusValid) { // Preserve reviewReason for human_review status if (storedStatus === 'human_review' && !reviewReason) { - // Infer reason from subtask states + // Infer reason from subtask states or plan review stage const hasFailedSubtasks = allSubtasks.some((s) => s.status === 'failed'); const allCompleted = allSubtasks.length > 0 && allSubtasks.every((s) => s.status === 'completed'); if (hasFailedSubtasks) { reviewReason = 'errors'; } else if (allCompleted) { reviewReason = 'completed'; + } else if (isPlanReviewStage) { + reviewReason = 'plan_review'; } } return { status: storedStatus, reviewReason: storedStatus === 'human_review' ? reviewReason : undefined }; diff --git a/auto-claude-ui/src/main/terminal-manager.ts b/auto-claude-ui/src/main/terminal-manager.ts index 5b6f88b6..9923d041 100644 --- a/auto-claude-ui/src/main/terminal-manager.ts +++ b/auto-claude-ui/src/main/terminal-manager.ts @@ -1,905 +1,14 @@ -import * as pty from 'node-pty'; -import { BrowserWindow } from 'electron'; -import { IPC_CHANNELS } from '../shared/constants'; -import type { TerminalCreateOptions } from '../shared/types'; -import * as os from 'os'; -import * as fs from 'fs'; -import * as path from 'path'; -import { getTerminalSessionStore, type TerminalSession } from './terminal-session-store'; -import { getClaudeProfileManager } from './claude-profile-manager'; - /** - * Get the Claude project slug from a project path. - * Claude uses the full path with forward slashes replaced by dashes. - * Example: /Users/john/project → -Users-john-project - * Example: C:\Users\john\project → C--Users-john-project + * Terminal Manager Facade + * Slim re-export facade for backward compatibility + * + * The actual implementation has been refactored into modular components: + * - terminal/terminal-manager.ts - Main orchestration + * - terminal/pty-manager.ts - PTY process management + * - terminal/session-handler.ts - Session persistence and restoration + * - terminal/output-parser.ts - Output parsing and pattern detection + * - terminal/types.ts - TypeScript type definitions */ -function getClaudeProjectSlug(projectPath: string): string { - // Claude replaces all path separators with dashes (both / and \) - return projectPath.replace(/[/\\]/g, '-'); -} -/** - * Find the most recent Claude session file for a project. - * Returns the session ID (filename without .jsonl extension) or null if not found. - */ -function findMostRecentClaudeSession(projectPath: string): string | null { - const slug = getClaudeProjectSlug(projectPath); - const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug); - - try { - if (!fs.existsSync(claudeProjectDir)) { - console.log('[TerminalManager] Claude project directory not found:', claudeProjectDir); - return null; - } - - const files = fs.readdirSync(claudeProjectDir) - .filter(f => f.endsWith('.jsonl')) - .map(f => ({ - name: f, - path: path.join(claudeProjectDir, f), - mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime() - })) - .sort((a, b) => b.mtime - a.mtime); // Most recent first - - if (files.length === 0) { - console.log('[TerminalManager] No Claude session files found in:', claudeProjectDir); - return null; - } - - // Return session ID (filename without .jsonl) - const sessionId = files[0].name.replace('.jsonl', ''); - console.log('[TerminalManager] Found most recent Claude session:', sessionId); - return sessionId; - } catch (error) { - console.error('[TerminalManager] Error finding Claude session:', error); - return null; - } -} - -/** - * Find a Claude session that was created/modified after a given timestamp. - * This helps us find the session that was just started. - */ -function findClaudeSessionAfter(projectPath: string, afterTimestamp: number): string | null { - const slug = getClaudeProjectSlug(projectPath); - const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug); - - try { - if (!fs.existsSync(claudeProjectDir)) { - return null; - } - - const files = fs.readdirSync(claudeProjectDir) - .filter(f => f.endsWith('.jsonl')) - .map(f => ({ - name: f, - path: path.join(claudeProjectDir, f), - mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime() - })) - .filter(f => f.mtime > afterTimestamp) - .sort((a, b) => b.mtime - a.mtime); - - if (files.length === 0) { - return null; - } - - return files[0].name.replace('.jsonl', ''); - } catch (error) { - console.error('[TerminalManager] Error finding Claude session:', error); - return null; - } -} - -interface TerminalProcess { - id: string; - pty: pty.IPty; - isClaudeMode: boolean; - projectPath?: string; - cwd: string; // Working directory for the terminal - claudeSessionId?: string; - claudeProfileId?: string; // Which Claude profile is being used (for multi-account support) - outputBuffer: string; // Track output for session persistence - title: string; -} - -// Regex patterns to capture Claude session ID from output -// Claude Code outputs something like "Session: abc123" or stores it in the init message -const CLAUDE_SESSION_PATTERNS = [ - // Direct session display (if Claude shows it) - /Session(?:\s+ID)?:\s*([a-zA-Z0-9_-]+)/i, - // From the JSONL filename pattern that Claude uses internally - /session[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i, - // From Claude Code's init output - /Resuming session:\s*([a-zA-Z0-9_-]+)/i, - // From conversation ID in output - /conversation[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i, -]; - -// Regex pattern to detect Claude Code rate limit messages -// Matches: "Limit reached · resets Dec 17 at 6am (Europe/Oslo)" -const RATE_LIMIT_PATTERN = /Limit reached\s*[·•]\s*resets\s+(.+?)$/m; - -// Regex pattern to capture OAuth token from `claude setup-token` output -// Token format: sk-ant-oat01-... (varies in length, typically 100+ chars) -const OAUTH_TOKEN_PATTERN = /(sk-ant-oat01-[A-Za-z0-9_-]+)/; - -// Pattern to detect email in Claude output (e.g., from /whoami or login success) -const EMAIL_PATTERN = /(?:Authenticated as|Logged in as|email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i; - -export class TerminalManager { - private terminals: Map = new Map(); - private getWindow: () => BrowserWindow | null; - private saveTimer: NodeJS.Timeout | null = null; - // Track the last notified rate limit reset time per terminal - // This prevents duplicate notifications when terminal repaints (e.g., on resize/view switch) - private lastNotifiedRateLimitReset: Map = new Map(); - - constructor(getWindow: () => BrowserWindow | null) { - this.getWindow = getWindow; - - // Periodically save session data (every 30 seconds) - this.saveTimer = setInterval(() => { - this.persistAllSessions(); - }, 30000); - } - - /** - * Persist all current sessions to disk - */ - private persistAllSessions(): void { - const store = getTerminalSessionStore(); - - for (const [, terminal] of this.terminals) { - if (terminal.projectPath) { - const session: TerminalSession = { - id: terminal.id, - title: terminal.title, - cwd: terminal.cwd, // Use the terminal's working directory - projectPath: terminal.projectPath, - isClaudeMode: terminal.isClaudeMode, - claudeSessionId: terminal.claudeSessionId, - outputBuffer: terminal.outputBuffer, - createdAt: new Date().toISOString(), - lastActiveAt: new Date().toISOString() - }; - store.saveSession(session); - } - } - } - - /** - * Try to extract Claude session ID from output - */ - private extractClaudeSessionId(data: string): string | null { - for (const pattern of CLAUDE_SESSION_PATTERNS) { - const match = data.match(pattern); - if (match && match[1]) { - return match[1]; - } - } - return null; - } - - /** - * Create a new terminal process - */ - async create(options: TerminalCreateOptions & { projectPath?: string }): Promise<{ success: boolean; error?: string }> { - const { id, cwd, cols = 80, rows = 24, projectPath } = options; - - console.log('[TerminalManager] Creating terminal:', { id, cwd, cols, rows, projectPath }); - - // Check if terminal already exists - return success instead of error - // This handles React StrictMode double-render gracefully - if (this.terminals.has(id)) { - console.log('[TerminalManager] Terminal already exists, returning success:', id); - return { success: true }; - } - - try { - // Determine shell based on platform - const shell = process.platform === 'win32' - ? process.env.COMSPEC || 'cmd.exe' - : process.env.SHELL || '/bin/zsh'; - - // Get shell args - const shellArgs = process.platform === 'win32' ? [] : ['-l']; - - console.log('[TerminalManager] Spawning shell:', shell, shellArgs); - - // Get active Claude profile's environment (OAuth token if available) - const profileManager = getClaudeProfileManager(); - const profileEnv = profileManager.getActiveProfileEnv(); - - if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) { - console.log('[TerminalManager] Injecting OAuth token from active profile into terminal'); - } - - // Spawn the pty process with profile environment - const ptyProcess = pty.spawn(shell, shellArgs, { - name: 'xterm-256color', - cols, - rows, - cwd: cwd || os.homedir(), - env: { - ...process.env, - ...profileEnv, // Include active profile's OAuth token - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - }, - }); - - console.log('[TerminalManager] PTY process spawned, pid:', ptyProcess.pid); - - // Store the terminal with its working directory - const terminalCwd = cwd || os.homedir(); - const terminal: TerminalProcess = { - id, - pty: ptyProcess, - isClaudeMode: false, - projectPath, - cwd: terminalCwd, - outputBuffer: '', - title: `Terminal ${this.terminals.size + 1}` - }; - this.terminals.set(id, terminal); - - // Handle data from terminal - ptyProcess.onData((data) => { - // Append to output buffer (limit to 100KB) - terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000); - - // Try to extract Claude session ID if in Claude mode - if (terminal.isClaudeMode && !terminal.claudeSessionId) { - const sessionId = this.extractClaudeSessionId(data); - if (sessionId) { - terminal.claudeSessionId = sessionId; - console.log('[TerminalManager] Captured Claude session ID:', sessionId); - - // Save to persistent store - if (terminal.projectPath) { - const store = getTerminalSessionStore(); - store.updateClaudeSessionId(terminal.projectPath, id, sessionId); - } - - // Notify renderer - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, id, sessionId); - } - } - } - - // Check for rate limit messages - if (terminal.isClaudeMode) { - const rateLimitMatch = data.match(RATE_LIMIT_PATTERN); - if (rateLimitMatch) { - const resetTime = rateLimitMatch[1].trim(); - const lastNotifiedReset = this.lastNotifiedRateLimitReset.get(id); - - // Only notify if this is a different reset time than we last notified about - // This prevents duplicate notifications when terminal repaints (resize, view switch) - if (resetTime !== lastNotifiedReset) { - this.lastNotifiedRateLimitReset.set(id, resetTime); - console.log('[TerminalManager] Rate limit detected, reset:', resetTime); - - // Record rate limit event in profile manager - const profileManager = getClaudeProfileManager(); - const currentProfileId = terminal.claudeProfileId || 'default'; - try { - const rateLimitEvent = profileManager.recordRateLimitEvent(currentProfileId, resetTime); - console.log('[TerminalManager] Recorded rate limit event:', rateLimitEvent.type); - } catch (err) { - console.error('[TerminalManager] Failed to record rate limit event:', err); - } - - // Check for auto-switch - const autoSwitchSettings = profileManager.getAutoSwitchSettings(); - const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); - - // Notify renderer with extended info - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { - terminalId: id, - resetTime, - detectedAt: new Date().toISOString(), - profileId: currentProfileId, - suggestedProfileId: bestProfile?.id, - suggestedProfileName: bestProfile?.name, - autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit - }); - } - - // Auto-switch if enabled and a better profile is available - if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) { - console.log('[TerminalManager] Auto-switching to profile:', bestProfile.name); - this.switchClaudeProfile(id, bestProfile.id).then(result => { - if (result.success) { - console.log('[TerminalManager] Auto-switch successful'); - } else { - console.error('[TerminalManager] Auto-switch failed:', result.error); - } - }); - } - } - } - } - - // Check for OAuth token in terminal output (from `claude setup-token`) - // Automatically save to the profile - user never sees the token - const tokenMatch = data.match(OAUTH_TOKEN_PATTERN); - if (tokenMatch) { - const token = tokenMatch[1]; - console.log('[TerminalManager] OAuth token detected, length:', token.length); - - // Also try to capture email if present in recent output - const emailMatch = terminal.outputBuffer.match(EMAIL_PATTERN); - const email = emailMatch ? emailMatch[1] : undefined; - - // Extract profile ID from terminal ID (format: claude-login-{profileId}-{timestamp}) - const profileIdMatch = id.match(/claude-login-(profile-\d+)-/); - - if (profileIdMatch) { - const profileId = profileIdMatch[1]; - - // Auto-save the token to the profile (encrypted) - const profileManager = getClaudeProfileManager(); - const success = profileManager.setProfileToken(profileId, token, email); - - if (success) { - console.log('[TerminalManager] OAuth token auto-saved to profile:', profileId); - - // Notify frontend that authentication completed (without exposing token) - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: id, - profileId, - email, - success: true, - detectedAt: new Date().toISOString() - }); - } - } else { - console.error('[TerminalManager] Failed to save OAuth token to profile:', profileId); - } - } else { - console.log('[TerminalManager] OAuth token detected but not in a profile login terminal'); - // Still notify frontend for manual handling - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: id, - email, - success: false, - message: 'Token detected but no profile associated with this terminal', - detectedAt: new Date().toISOString() - }); - } - } - } - - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OUTPUT, id, data); - } - }); - - // Handle terminal exit - ptyProcess.onExit(({ exitCode }) => { - console.log('[TerminalManager] Terminal exited:', id, 'code:', exitCode); - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode); - } - - // Remove session from persistent store when terminal exits - if (terminal.projectPath) { - const store = getTerminalSessionStore(); - store.removeSession(terminal.projectPath, id); - } - - // Clean up rate limit tracking - this.lastNotifiedRateLimitReset.delete(id); - - this.terminals.delete(id); - }); - - // Save initial session state - if (projectPath) { - const store = getTerminalSessionStore(); - store.saveSession({ - id, - title: terminal.title, - cwd: cwd || os.homedir(), - projectPath, - isClaudeMode: false, - outputBuffer: '', - createdAt: new Date().toISOString(), - lastActiveAt: new Date().toISOString() - }); - } - - console.log('[TerminalManager] Terminal created successfully:', id); - return { success: true }; - } catch (error) { - console.error('[TerminalManager] Error creating terminal:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to create terminal', - }; - } - } - - /** - * Restore a terminal session (create PTY and optionally resume Claude) - */ - async restore(session: TerminalSession, cols = 80, rows = 24): Promise<{ success: boolean; error?: string; outputBuffer?: string }> { - console.log('[TerminalManager] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode); - - // First create the base terminal - const result = await this.create({ - id: session.id, - cwd: session.cwd, - cols, - rows, - projectPath: session.projectPath - }); - - if (!result.success) { - return result; - } - - const terminal = this.terminals.get(session.id); - if (!terminal) { - return { success: false, error: 'Terminal not found after creation' }; - } - - // Set the title - terminal.title = session.title; - - // If it was a Claude session, try to resume - if (session.isClaudeMode) { - // Wait for shell to fully initialize (shell frameworks like oh-my-zsh need time) - await new Promise(resolve => setTimeout(resolve, 1000)); - - terminal.isClaudeMode = true; - terminal.claudeSessionId = session.claudeSessionId; - - // Build the resume command - always cd to the project directory first - // because Claude sessions are stored per-project in ~/.claude/projects/{project-slug}/ - const projectDir = session.cwd || session.projectPath; - const startTime = Date.now(); - let resumeCommand: string; - - // Use platform-appropriate clear command - const clearCmd = process.platform === 'win32' ? 'cls' : 'clear'; - - if (session.claudeSessionId) { - // Resume specific session with explicit directory - // Clear screen first to avoid mixing old output replay with new session - resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume "${session.claudeSessionId}"`; - console.log('[TerminalManager] Resuming Claude with session ID:', session.claudeSessionId, 'in', projectDir); - } else { - // No specific session ID - use --resume to show session picker - // This lets user choose which session to resume for this terminal - // (Using --continue would resume the same session in all terminals) - resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume`; - console.log('[TerminalManager] Opening Claude session picker in', projectDir); - } - - terminal.pty.write(`${resumeCommand}\r`); - - // Notify renderer about title change - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, 'Claude'); - } - - // If we don't have a session ID, try to capture it after user selects from picker - if (!session.claudeSessionId && projectDir) { - this.captureClaudeSessionId(session.id, projectDir, startTime); - } - } - - return { - success: true, - outputBuffer: session.outputBuffer // Return buffer for replay in UI - }; - } - - /** - * Destroy a terminal process - */ - async destroy(id: string): Promise<{ success: boolean; error?: string }> { - const terminal = this.terminals.get(id); - if (!terminal) { - return { success: false, error: 'Terminal not found' }; - } - - try { - // Remove from persistent store - if (terminal.projectPath) { - const store = getTerminalSessionStore(); - store.removeSession(terminal.projectPath, id); - } - - // Clean up rate limit tracking - this.lastNotifiedRateLimitReset.delete(id); - - terminal.pty.kill(); - this.terminals.delete(id); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to destroy terminal', - }; - } - } - - /** - * Send input to a terminal - */ - write(id: string, data: string): void { - const terminal = this.terminals.get(id); - if (terminal) { - terminal.pty.write(data); - } - } - - /** - * Resize a terminal - */ - resize(id: string, cols: number, rows: number): void { - const terminal = this.terminals.get(id); - if (terminal) { - terminal.pty.resize(cols, rows); - } - } - - /** - * Invoke Claude in a terminal with optional profile override. - * Note: For new terminals, the OAuth token is injected at spawn time (invisible to user). - * For profile switches, we use a temp file to avoid exposing the token. - */ - invokeClaude(id: string, cwd?: string, profileId?: string): void { - const terminal = this.terminals.get(id); - if (terminal) { - terminal.isClaudeMode = true; - terminal.claudeSessionId = undefined; - - // Record timestamp before starting Claude - const startTime = Date.now(); - const projectPath = cwd || terminal.projectPath || terminal.cwd; - - // Get the Claude profile to use - const profileManager = getClaudeProfileManager(); - const activeProfile = profileId - ? profileManager.getProfile(profileId) - : profileManager.getActiveProfile(); - - const previousProfileId = terminal.claudeProfileId; - terminal.claudeProfileId = activeProfile?.id; - - // Build the command - only inject token if switching profiles mid-session - // New terminals already have the token injected at spawn time (invisible) - const cwdCommand = cwd ? `cd "${cwd}" && ` : ''; - - // Only inject token if explicitly switching profiles (profileId provided and different) - const needsEnvOverride = profileId && profileId !== previousProfileId; - - if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { - const token = profileManager.getProfileToken(activeProfile.id); - - if (token) { - // Use a temp file to inject the token without exposing it in terminal output - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`); - fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 }); - - // Source the temp file, delete it, then run claude - token never visible - terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`); - console.log('[TerminalManager] Switching to Claude profile:', activeProfile.name, '(via secure temp file)'); - return; - } else if (activeProfile.configDir) { - // Fallback to config dir for legacy profiles without tokens - terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`); - console.log('[TerminalManager] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir); - return; - } - } - - if (activeProfile && !activeProfile.isDefault) { - console.log('[TerminalManager] Using Claude profile:', activeProfile.name, '(from terminal environment)'); - } - - // Normal case: token already in terminal environment from spawn time - terminal.pty.write(`${cwdCommand}claude\r`); - - // Mark the profile as used - if (activeProfile) { - profileManager.markProfileUsed(activeProfile.id); - } - - // Notify the renderer about title change (include profile name if not default) - const win = this.getWindow(); - if (win) { - const title = activeProfile && !activeProfile.isDefault - ? `Claude (${activeProfile.name})` - : 'Claude'; - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, title); - } - - // Update persistent store - if (terminal.projectPath) { - const store = getTerminalSessionStore(); - const session = store.getSession(terminal.projectPath, id); - if (session) { - store.saveSession({ - ...session, - isClaudeMode: true, - lastActiveAt: new Date().toISOString() - }); - } - } - - // Capture Claude session ID after a delay (give Claude time to create the session file) - if (projectPath) { - this.captureClaudeSessionId(id, projectPath, startTime); - } - } - } - - /** - * Switch a terminal to use a different Claude profile. - * This will exit the current Claude session and restart with the new profile. - */ - async switchClaudeProfile(id: string, profileId: string): Promise<{ success: boolean; error?: string }> { - const terminal = this.terminals.get(id); - if (!terminal) { - return { success: false, error: 'Terminal not found' }; - } - - const profileManager = getClaudeProfileManager(); - const profile = profileManager.getProfile(profileId); - if (!profile) { - return { success: false, error: 'Profile not found' }; - } - - console.log('[TerminalManager] Switching to Claude profile:', profile.name); - - // If Claude is currently running, exit it first - if (terminal.isClaudeMode) { - // Send Ctrl+C to interrupt current Claude session - terminal.pty.write('\x03'); - // Wait for Claude to exit - await new Promise(resolve => setTimeout(resolve, 500)); - // Send /exit command in case it didn't fully exit - terminal.pty.write('/exit\r'); - await new Promise(resolve => setTimeout(resolve, 500)); - } - - // Clear rate limit tracking for this terminal (new profile = new limit) - this.lastNotifiedRateLimitReset.delete(id); - - // Get the project path for re-invoking - const projectPath = terminal.projectPath || terminal.cwd; - - // Re-invoke Claude with the new profile - this.invokeClaude(id, projectPath, profileId); - - // Update the active profile globally - profileManager.setActiveProfile(profileId); - - return { success: true }; - } - - /** - * Attempt to capture Claude session ID by scanning the session directory. - * Polls periodically until a new session is found or timeout. - */ - private captureClaudeSessionId(terminalId: string, projectPath: string, startTime: number): void { - const terminal = this.terminals.get(terminalId); - if (!terminal) return; - - let attempts = 0; - const maxAttempts = 10; // Try for up to 10 seconds - - const checkForSession = () => { - attempts++; - - // Check if terminal still exists and is in Claude mode - const currentTerminal = this.terminals.get(terminalId); - if (!currentTerminal || !currentTerminal.isClaudeMode) { - return; // Terminal closed or Claude exited - } - - // Already have a session ID (maybe captured from output) - if (currentTerminal.claudeSessionId) { - return; - } - - // Look for a session file created after we started Claude - const sessionId = findClaudeSessionAfter(projectPath, startTime); - - if (sessionId) { - currentTerminal.claudeSessionId = sessionId; - console.log('[TerminalManager] Captured Claude session ID from directory:', sessionId); - - // Save to persistent store - if (currentTerminal.projectPath) { - const store = getTerminalSessionStore(); - store.updateClaudeSessionId(currentTerminal.projectPath, terminalId, sessionId); - } - - // Notify renderer - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId); - } - } else if (attempts < maxAttempts) { - // Try again in 1 second - setTimeout(checkForSession, 1000); - } else { - console.log('[TerminalManager] Could not capture Claude session ID after', maxAttempts, 'attempts'); - } - }; - - // First check after 2 seconds (give Claude time to start) - setTimeout(checkForSession, 2000); - } - - /** - * Resume Claude in a terminal with a specific session ID - */ - resumeClaude(id: string, sessionId?: string): void { - const terminal = this.terminals.get(id); - if (terminal) { - terminal.isClaudeMode = true; - - let command: string; - if (sessionId) { - command = `claude --resume "${sessionId}"`; - terminal.claudeSessionId = sessionId; - } else { - command = 'claude --continue'; - } - - terminal.pty.write(`${command}\r`); - - // Notify the renderer about title change - const win = this.getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, 'Claude'); - } - } - } - - /** - * Get saved sessions for a project - */ - getSavedSessions(projectPath: string): TerminalSession[] { - const store = getTerminalSessionStore(); - return store.getSessions(projectPath); - } - - /** - * Clear saved sessions for a project - */ - clearSavedSessions(projectPath: string): void { - const store = getTerminalSessionStore(); - store.clearProjectSessions(projectPath); - } - - /** - * Get available session dates for a project - */ - getAvailableSessionDates(projectPath?: string): import('./terminal-session-store').SessionDateInfo[] { - const store = getTerminalSessionStore(); - return store.getAvailableDates(projectPath); - } - - /** - * Get sessions for a specific date and project - */ - getSessionsForDate(date: string, projectPath: string): TerminalSession[] { - const store = getTerminalSessionStore(); - return store.getSessionsForDate(date, projectPath); - } - - /** - * Restore all sessions from a specific date for a project - */ - async restoreSessionsFromDate( - date: string, - projectPath: string, - cols = 80, - rows = 24 - ): Promise<{ restored: number; failed: number; sessions: Array<{ id: string; success: boolean; error?: string }> }> { - const store = getTerminalSessionStore(); - const sessions = store.getSessionsForDate(date, projectPath); - - const results: Array<{ id: string; success: boolean; error?: string }> = []; - - for (const session of sessions) { - const result = await this.restore(session, cols, rows); - results.push({ - id: session.id, - success: result.success, - error: result.error - }); - } - - return { - restored: results.filter(r => r.success).length, - failed: results.filter(r => !r.success).length, - sessions: results - }; - } - - /** - * Kill all terminal processes - */ - async killAll(): Promise { - // Save all sessions before killing - this.persistAllSessions(); - - // Clear the save timer - if (this.saveTimer) { - clearInterval(this.saveTimer); - this.saveTimer = null; - } - - const promises: Promise[] = []; - - for (const [id, terminal] of this.terminals) { - promises.push( - new Promise((resolve) => { - try { - terminal.pty.kill(); - } catch { - // Ignore errors during cleanup - } - resolve(); - }) - ); - } - - await Promise.all(promises); - this.terminals.clear(); - } - - /** - * Get all active terminal IDs - */ - getActiveTerminalIds(): string[] { - return Array.from(this.terminals.keys()); - } - - /** - * Check if a terminal is in Claude mode - */ - isClaudeMode(id: string): boolean { - const terminal = this.terminals.get(id); - return terminal?.isClaudeMode ?? false; - } - - /** - * Get Claude session ID for a terminal - */ - getClaudeSessionId(id: string): string | undefined { - const terminal = this.terminals.get(id); - return terminal?.claudeSessionId; - } - - /** - * Update terminal title - */ - setTitle(id: string, title: string): void { - const terminal = this.terminals.get(id); - if (terminal) { - terminal.title = title; - } - } -} +export { TerminalManager } from './terminal/terminal-manager'; +export type { TerminalProcess } from './terminal/types'; diff --git a/auto-claude-ui/src/main/terminal/index.ts b/auto-claude-ui/src/main/terminal/index.ts new file mode 100644 index 00000000..54895602 --- /dev/null +++ b/auto-claude-ui/src/main/terminal/index.ts @@ -0,0 +1,26 @@ +/** + * Terminal Module + * Modular terminal management system with Claude integration + */ + +// Main manager +export { TerminalManager } from './terminal-manager'; + +// Types +export type { + TerminalProcess, + RateLimitEvent, + OAuthTokenEvent, + SessionCaptureResult, + TerminalOperationResult, + WindowGetter +} from './types'; + +// Output parsing utilities +export * as OutputParser from './output-parser'; + +// PTY management utilities +export * as PtyManager from './pty-manager'; + +// Session management utilities +export * as SessionHandler from './session-handler'; diff --git a/auto-claude-ui/src/main/terminal/output-parser.ts b/auto-claude-ui/src/main/terminal/output-parser.ts new file mode 100644 index 00000000..72458ef2 --- /dev/null +++ b/auto-claude-ui/src/main/terminal/output-parser.ts @@ -0,0 +1,81 @@ +/** + * Output Parser Module + * Handles parsing and pattern detection in terminal output + */ + +/** + * Regex patterns to capture Claude session ID from output + */ +const CLAUDE_SESSION_PATTERNS = [ + /Session(?:\s+ID)?:\s*([a-zA-Z0-9_-]+)/i, + /session[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i, + /Resuming session:\s*([a-zA-Z0-9_-]+)/i, + /conversation[_-]?id["\s:=]+([a-zA-Z0-9_-]+)/i, +]; + +/** + * Regex pattern to detect Claude Code rate limit messages + * Matches: "Limit reached · resets Dec 17 at 6am (Europe/Oslo)" + */ +const RATE_LIMIT_PATTERN = /Limit reached\s*[·•]\s*resets\s+(.+?)$/m; + +/** + * Regex pattern to capture OAuth token from `claude setup-token` output + */ +const OAUTH_TOKEN_PATTERN = /(sk-ant-oat01-[A-Za-z0-9_-]+)/; + +/** + * Pattern to detect email in Claude output + */ +const EMAIL_PATTERN = /(?:Authenticated as|Logged in as|email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i; + +/** + * Extract Claude session ID from output + */ +export function extractClaudeSessionId(data: string): string | null { + for (const pattern of CLAUDE_SESSION_PATTERNS) { + const match = data.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +/** + * Extract rate limit reset time from output + */ +export function extractRateLimitReset(data: string): string | null { + const match = data.match(RATE_LIMIT_PATTERN); + return match ? match[1].trim() : null; +} + +/** + * Extract OAuth token from output + */ +export function extractOAuthToken(data: string): string | null { + const match = data.match(OAUTH_TOKEN_PATTERN); + return match ? match[1] : null; +} + +/** + * Extract email from output + */ +export function extractEmail(data: string): string | null { + const match = data.match(EMAIL_PATTERN); + return match ? match[1] : null; +} + +/** + * Check if output contains a rate limit message + */ +export function hasRateLimitMessage(data: string): boolean { + return RATE_LIMIT_PATTERN.test(data); +} + +/** + * Check if output contains an OAuth token + */ +export function hasOAuthToken(data: string): boolean { + return OAUTH_TOKEN_PATTERN.test(data); +} diff --git a/auto-claude-ui/src/main/terminal/pty-manager.ts b/auto-claude-ui/src/main/terminal/pty-manager.ts new file mode 100644 index 00000000..203eb888 --- /dev/null +++ b/auto-claude-ui/src/main/terminal/pty-manager.ts @@ -0,0 +1,113 @@ +/** + * PTY Manager Module + * Handles low-level PTY process creation and lifecycle + */ + +import * as pty from 'node-pty'; +import * as os from 'os'; +import type { TerminalProcess, WindowGetter, TerminalOperationResult } from './types'; +import { IPC_CHANNELS } from '../../shared/constants'; +import { getClaudeProfileManager } from '../claude-profile-manager'; + +/** + * Spawn a new PTY process with appropriate shell and environment + */ +export function spawnPtyProcess( + cwd: string, + cols: number, + rows: number, + profileEnv?: Record +): pty.IPty { + const shell = process.platform === 'win32' + ? process.env.COMSPEC || 'cmd.exe' + : process.env.SHELL || '/bin/zsh'; + + const shellArgs = process.platform === 'win32' ? [] : ['-l']; + + console.log('[PtyManager] Spawning shell:', shell, shellArgs); + + return pty.spawn(shell, shellArgs, { + name: 'xterm-256color', + cols, + rows, + cwd: cwd || os.homedir(), + env: { + ...process.env, + ...profileEnv, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + }, + }); +} + +/** + * Setup PTY event handlers for a terminal process + */ +export function setupPtyHandlers( + terminal: TerminalProcess, + terminals: Map, + getWindow: WindowGetter, + onDataCallback: (terminal: TerminalProcess, data: string) => void, + onExitCallback: (terminal: TerminalProcess) => void +): void { + const { id, pty: ptyProcess } = terminal; + + // Handle data from terminal + ptyProcess.onData((data) => { + // Append to output buffer (limit to 100KB) + terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000); + + // Call custom data handler + onDataCallback(terminal, data); + + // Send to renderer + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_OUTPUT, id, data); + } + }); + + // Handle terminal exit + ptyProcess.onExit(({ exitCode }) => { + console.log('[PtyManager] Terminal exited:', id, 'code:', exitCode); + + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode); + } + + // Call custom exit handler + onExitCallback(terminal); + + terminals.delete(id); + }); +} + +/** + * Write data to a PTY process + */ +export function writeToPty(terminal: TerminalProcess, data: string): void { + terminal.pty.write(data); +} + +/** + * Resize a PTY process + */ +export function resizePty(terminal: TerminalProcess, cols: number, rows: number): void { + terminal.pty.resize(cols, rows); +} + +/** + * Kill a PTY process + */ +export function killPty(terminal: TerminalProcess): void { + terminal.pty.kill(); +} + +/** + * Get the active Claude profile environment variables + */ +export function getActiveProfileEnv(): Record { + const profileManager = getClaudeProfileManager(); + return profileManager.getActiveProfileEnv(); +} diff --git a/auto-claude-ui/src/main/terminal/session-handler.ts b/auto-claude-ui/src/main/terminal/session-handler.ts new file mode 100644 index 00000000..2b155a12 --- /dev/null +++ b/auto-claude-ui/src/main/terminal/session-handler.ts @@ -0,0 +1,231 @@ +/** + * Session Handler Module + * Manages terminal session persistence, restoration, and Claude session tracking + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { TerminalProcess, WindowGetter, SessionCaptureResult } from './types'; +import { getTerminalSessionStore, type TerminalSession } from '../terminal-session-store'; +import { IPC_CHANNELS } from '../../shared/constants'; + +/** + * Get the Claude project slug from a project path. + * Claude uses the full path with forward slashes replaced by dashes. + */ +function getClaudeProjectSlug(projectPath: string): string { + return projectPath.replace(/[/\\]/g, '-'); +} + +/** + * Find the most recent Claude session file for a project + */ +export function findMostRecentClaudeSession(projectPath: string): string | null { + const slug = getClaudeProjectSlug(projectPath); + const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug); + + try { + if (!fs.existsSync(claudeProjectDir)) { + console.log('[SessionHandler] Claude project directory not found:', claudeProjectDir); + return null; + } + + const files = fs.readdirSync(claudeProjectDir) + .filter(f => f.endsWith('.jsonl')) + .map(f => ({ + name: f, + path: path.join(claudeProjectDir, f), + mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime() + })) + .sort((a, b) => b.mtime - a.mtime); + + if (files.length === 0) { + console.log('[SessionHandler] No Claude session files found in:', claudeProjectDir); + return null; + } + + const sessionId = files[0].name.replace('.jsonl', ''); + console.log('[SessionHandler] Found most recent Claude session:', sessionId); + return sessionId; + } catch (error) { + console.error('[SessionHandler] Error finding Claude session:', error); + return null; + } +} + +/** + * Find a Claude session created/modified after a given timestamp + */ +export function findClaudeSessionAfter(projectPath: string, afterTimestamp: number): string | null { + const slug = getClaudeProjectSlug(projectPath); + const claudeProjectDir = path.join(os.homedir(), '.claude', 'projects', slug); + + try { + if (!fs.existsSync(claudeProjectDir)) { + return null; + } + + const files = fs.readdirSync(claudeProjectDir) + .filter(f => f.endsWith('.jsonl')) + .map(f => ({ + name: f, + path: path.join(claudeProjectDir, f), + mtime: fs.statSync(path.join(claudeProjectDir, f)).mtime.getTime() + })) + .filter(f => f.mtime > afterTimestamp) + .sort((a, b) => b.mtime - a.mtime); + + if (files.length === 0) { + return null; + } + + return files[0].name.replace('.jsonl', ''); + } catch (error) { + console.error('[SessionHandler] Error finding Claude session:', error); + return null; + } +} + +/** + * Persist a terminal session to disk + */ +export function persistSession(terminal: TerminalProcess): void { + if (!terminal.projectPath) { + return; + } + + const store = getTerminalSessionStore(); + const session: TerminalSession = { + id: terminal.id, + title: terminal.title, + cwd: terminal.cwd, + projectPath: terminal.projectPath, + isClaudeMode: terminal.isClaudeMode, + claudeSessionId: terminal.claudeSessionId, + outputBuffer: terminal.outputBuffer, + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString() + }; + store.saveSession(session); +} + +/** + * Persist all active sessions + */ +export function persistAllSessions(terminals: Map): void { + const store = getTerminalSessionStore(); + + terminals.forEach((terminal) => { + if (terminal.projectPath) { + persistSession(terminal); + } + }); +} + +/** + * Remove a session from persistent storage + */ +export function removePersistedSession(terminal: TerminalProcess): void { + if (!terminal.projectPath) { + return; + } + + const store = getTerminalSessionStore(); + store.removeSession(terminal.projectPath, terminal.id); +} + +/** + * Update Claude session ID in persistent storage + */ +export function updateClaudeSessionId( + projectPath: string, + terminalId: string, + sessionId: string +): void { + const store = getTerminalSessionStore(); + store.updateClaudeSessionId(projectPath, terminalId, sessionId); +} + +/** + * Get saved sessions for a project + */ +export function getSavedSessions(projectPath: string): TerminalSession[] { + const store = getTerminalSessionStore(); + return store.getSessions(projectPath); +} + +/** + * Clear all saved sessions for a project + */ +export function clearSavedSessions(projectPath: string): void { + const store = getTerminalSessionStore(); + store.clearProjectSessions(projectPath); +} + +/** + * Get available session dates + */ +export function getAvailableSessionDates( + projectPath?: string +): import('../terminal-session-store').SessionDateInfo[] { + const store = getTerminalSessionStore(); + return store.getAvailableDates(projectPath); +} + +/** + * Get sessions for a specific date + */ +export function getSessionsForDate(date: string, projectPath: string): TerminalSession[] { + const store = getTerminalSessionStore(); + return store.getSessionsForDate(date, projectPath); +} + +/** + * Attempt to capture Claude session ID by polling the session directory + */ +export function captureClaudeSessionId( + terminalId: string, + projectPath: string, + startTime: number, + terminals: Map, + getWindow: WindowGetter +): void { + let attempts = 0; + const maxAttempts = 10; + + const checkForSession = () => { + attempts++; + + const terminal = terminals.get(terminalId); + if (!terminal || !terminal.isClaudeMode) { + return; + } + + if (terminal.claudeSessionId) { + return; + } + + const sessionId = findClaudeSessionAfter(projectPath, startTime); + + if (sessionId) { + terminal.claudeSessionId = sessionId; + console.log('[SessionHandler] Captured Claude session ID from directory:', sessionId); + + if (terminal.projectPath) { + updateClaudeSessionId(terminal.projectPath, terminalId, sessionId); + } + + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminalId, sessionId); + } + } else if (attempts < maxAttempts) { + setTimeout(checkForSession, 1000); + } else { + console.log('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts'); + } + }; + + setTimeout(checkForSession, 2000); +} diff --git a/auto-claude-ui/src/main/terminal/terminal-manager.ts b/auto-claude-ui/src/main/terminal/terminal-manager.ts new file mode 100644 index 00000000..e7c1e24a --- /dev/null +++ b/auto-claude-ui/src/main/terminal/terminal-manager.ts @@ -0,0 +1,630 @@ +/** + * Terminal Manager + * Main orchestrator for terminal lifecycle, Claude integration, and profile management + */ + +import { BrowserWindow } from 'electron'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { TerminalCreateOptions } from '../../shared/types'; +import { IPC_CHANNELS } from '../../shared/constants'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import type { TerminalSession } from '../terminal-session-store'; + +// Internal modules +import type { + TerminalProcess, + WindowGetter, + TerminalOperationResult, + RateLimitEvent, + OAuthTokenEvent +} from './types'; +import * as OutputParser from './output-parser'; +import * as PtyManager from './pty-manager'; +import * as SessionHandler from './session-handler'; + +export class TerminalManager { + private terminals: Map = new Map(); + private getWindow: WindowGetter; + private saveTimer: NodeJS.Timeout | null = null; + private lastNotifiedRateLimitReset: Map = new Map(); + + constructor(getWindow: WindowGetter) { + this.getWindow = getWindow; + + // Periodically save session data (every 30 seconds) + this.saveTimer = setInterval(() => { + SessionHandler.persistAllSessions(this.terminals); + }, 30000); + } + + /** + * Create a new terminal process + */ + async create( + options: TerminalCreateOptions & { projectPath?: string } + ): Promise { + const { id, cwd, cols = 80, rows = 24, projectPath } = options; + + console.log('[TerminalManager] Creating terminal:', { id, cwd, cols, rows, projectPath }); + + if (this.terminals.has(id)) { + console.log('[TerminalManager] Terminal already exists, returning success:', id); + return { success: true }; + } + + try { + const profileEnv = PtyManager.getActiveProfileEnv(); + + if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) { + console.log('[TerminalManager] Injecting OAuth token from active profile'); + } + + const ptyProcess = PtyManager.spawnPtyProcess( + cwd || os.homedir(), + cols, + rows, + profileEnv + ); + + console.log('[TerminalManager] PTY process spawned, pid:', ptyProcess.pid); + + const terminalCwd = cwd || os.homedir(); + const terminal: TerminalProcess = { + id, + pty: ptyProcess, + isClaudeMode: false, + projectPath, + cwd: terminalCwd, + outputBuffer: '', + title: `Terminal ${this.terminals.size + 1}` + }; + + this.terminals.set(id, terminal); + + PtyManager.setupPtyHandlers( + terminal, + this.terminals, + this.getWindow, + (term, data) => this.handleTerminalData(term, data), + (term) => this.handleTerminalExit(term) + ); + + if (projectPath) { + SessionHandler.persistSession(terminal); + } + + console.log('[TerminalManager] Terminal created successfully:', id); + return { success: true }; + } catch (error) { + console.error('[TerminalManager] Error creating terminal:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create terminal', + }; + } + } + + /** + * Handle data output from terminal + */ + private handleTerminalData(terminal: TerminalProcess, data: string): void { + // Try to extract Claude session ID + if (terminal.isClaudeMode && !terminal.claudeSessionId) { + const sessionId = OutputParser.extractClaudeSessionId(data); + if (sessionId) { + this.handleClaudeSessionIdCaptured(terminal, sessionId); + } + } + + // Check for rate limit messages + if (terminal.isClaudeMode) { + this.checkForRateLimit(terminal, data); + } + + // Check for OAuth token + this.checkForOAuthToken(terminal, data); + } + + /** + * Handle terminal exit event + */ + private handleTerminalExit(terminal: TerminalProcess): void { + SessionHandler.removePersistedSession(terminal); + this.lastNotifiedRateLimitReset.delete(terminal.id); + } + + /** + * Handle Claude session ID capture + */ + private handleClaudeSessionIdCaptured(terminal: TerminalProcess, sessionId: string): void { + terminal.claudeSessionId = sessionId; + console.log('[TerminalManager] Captured Claude session ID:', sessionId); + + if (terminal.projectPath) { + SessionHandler.updateClaudeSessionId(terminal.projectPath, terminal.id, sessionId); + } + + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId); + } + } + + /** + * Check for rate limit messages and handle profile switching + */ + private checkForRateLimit(terminal: TerminalProcess, data: string): void { + const resetTime = OutputParser.extractRateLimitReset(data); + if (!resetTime) { + return; + } + + const lastNotifiedReset = this.lastNotifiedRateLimitReset.get(terminal.id); + if (resetTime === lastNotifiedReset) { + return; + } + + this.lastNotifiedRateLimitReset.set(terminal.id, resetTime); + console.log('[TerminalManager] Rate limit detected, reset:', resetTime); + + const profileManager = getClaudeProfileManager(); + const currentProfileId = terminal.claudeProfileId || 'default'; + + try { + const rateLimitEvent = profileManager.recordRateLimitEvent(currentProfileId, resetTime); + console.log('[TerminalManager] Recorded rate limit event:', rateLimitEvent.type); + } catch (err) { + console.error('[TerminalManager] Failed to record rate limit event:', err); + } + + const autoSwitchSettings = profileManager.getAutoSwitchSettings(); + const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); + + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { + terminalId: terminal.id, + resetTime, + detectedAt: new Date().toISOString(), + profileId: currentProfileId, + suggestedProfileId: bestProfile?.id, + suggestedProfileName: bestProfile?.name, + autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit + } as RateLimitEvent); + } + + if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) { + console.log('[TerminalManager] Auto-switching to profile:', bestProfile.name); + this.switchClaudeProfile(terminal.id, bestProfile.id).then(result => { + if (result.success) { + console.log('[TerminalManager] Auto-switch successful'); + } else { + console.error('[TerminalManager] Auto-switch failed:', result.error); + } + }); + } + } + + /** + * Check for OAuth token and auto-save to profile + */ + private checkForOAuthToken(terminal: TerminalProcess, data: string): void { + const token = OutputParser.extractOAuthToken(data); + if (!token) { + return; + } + + console.log('[TerminalManager] OAuth token detected, length:', token.length); + + const email = OutputParser.extractEmail(terminal.outputBuffer); + const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+)-/); + + if (profileIdMatch) { + const profileId = profileIdMatch[1]; + const profileManager = getClaudeProfileManager(); + const success = profileManager.setProfileToken(profileId, token, email || undefined); + + if (success) { + console.log('[TerminalManager] OAuth token auto-saved to profile:', profileId); + + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email, + success: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); + } + } else { + console.error('[TerminalManager] Failed to save OAuth token to profile:', profileId); + } + } else { + console.log('[TerminalManager] OAuth token detected but not in a profile login terminal'); + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + email, + success: false, + message: 'Token detected but no profile associated with this terminal', + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); + } + } + } + + /** + * Restore a terminal session + */ + async restore( + session: TerminalSession, + cols = 80, + rows = 24 + ): Promise { + console.log('[TerminalManager] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode); + + const result = await this.create({ + id: session.id, + cwd: session.cwd, + cols, + rows, + projectPath: session.projectPath + }); + + if (!result.success) { + return result; + } + + const terminal = this.terminals.get(session.id); + if (!terminal) { + return { success: false, error: 'Terminal not found after creation' }; + } + + terminal.title = session.title; + + if (session.isClaudeMode) { + await new Promise(resolve => setTimeout(resolve, 1000)); + + terminal.isClaudeMode = true; + terminal.claudeSessionId = session.claudeSessionId; + + const projectDir = session.cwd || session.projectPath; + const startTime = Date.now(); + const clearCmd = process.platform === 'win32' ? 'cls' : 'clear'; + + let resumeCommand: string; + if (session.claudeSessionId) { + resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume "${session.claudeSessionId}"`; + console.log('[TerminalManager] Resuming Claude with session ID:', session.claudeSessionId, 'in', projectDir); + } else { + resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume`; + console.log('[TerminalManager] Opening Claude session picker in', projectDir); + } + + terminal.pty.write(`${resumeCommand}\r`); + + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, 'Claude'); + } + + if (!session.claudeSessionId && projectDir) { + SessionHandler.captureClaudeSessionId( + session.id, + projectDir, + startTime, + this.terminals, + this.getWindow + ); + } + } + + return { + success: true, + outputBuffer: session.outputBuffer + }; + } + + /** + * Destroy a terminal process + */ + async destroy(id: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return { success: false, error: 'Terminal not found' }; + } + + try { + SessionHandler.removePersistedSession(terminal); + this.lastNotifiedRateLimitReset.delete(id); + PtyManager.killPty(terminal); + this.terminals.delete(id); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to destroy terminal', + }; + } + } + + /** + * Send input to a terminal + */ + write(id: string, data: string): void { + const terminal = this.terminals.get(id); + if (terminal) { + PtyManager.writeToPty(terminal, data); + } + } + + /** + * Resize a terminal + */ + resize(id: string, cols: number, rows: number): void { + const terminal = this.terminals.get(id); + if (terminal) { + PtyManager.resizePty(terminal, cols, rows); + } + } + + /** + * Invoke Claude in a terminal with optional profile override + */ + invokeClaude(id: string, cwd?: string, profileId?: string): void { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + terminal.isClaudeMode = true; + terminal.claudeSessionId = undefined; + + const startTime = Date.now(); + const projectPath = cwd || terminal.projectPath || terminal.cwd; + + const profileManager = getClaudeProfileManager(); + const activeProfile = profileId + ? profileManager.getProfile(profileId) + : profileManager.getActiveProfile(); + + const previousProfileId = terminal.claudeProfileId; + terminal.claudeProfileId = activeProfile?.id; + + const cwdCommand = cwd ? `cd "${cwd}" && ` : ''; + const needsEnvOverride = profileId && profileId !== previousProfileId; + + if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { + const token = profileManager.getProfileToken(activeProfile.id); + + if (token) { + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}`); + fs.writeFileSync(tempFile, `export CLAUDE_CODE_OAUTH_TOKEN="${token}"\n`, { mode: 0o600 }); + + terminal.pty.write(`${cwdCommand}source "${tempFile}" && rm -f "${tempFile}" && claude\r`); + console.log('[TerminalManager] Switching to Claude profile:', activeProfile.name, '(via secure temp file)'); + return; + } else if (activeProfile.configDir) { + terminal.pty.write(`${cwdCommand}CLAUDE_CONFIG_DIR="${activeProfile.configDir}" claude\r`); + console.log('[TerminalManager] Using Claude profile:', activeProfile.name, 'config:', activeProfile.configDir); + return; + } + } + + if (activeProfile && !activeProfile.isDefault) { + console.log('[TerminalManager] Using Claude profile:', activeProfile.name, '(from terminal environment)'); + } + + terminal.pty.write(`${cwdCommand}claude\r`); + + if (activeProfile) { + profileManager.markProfileUsed(activeProfile.id); + } + + const win = this.getWindow(); + if (win) { + const title = activeProfile && !activeProfile.isDefault + ? `Claude (${activeProfile.name})` + : 'Claude'; + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, title); + } + + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + + if (projectPath) { + SessionHandler.captureClaudeSessionId( + id, + projectPath, + startTime, + this.terminals, + this.getWindow + ); + } + } + + /** + * Switch a terminal to a different Claude profile + */ + async switchClaudeProfile(id: string, profileId: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return { success: false, error: 'Terminal not found' }; + } + + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (!profile) { + return { success: false, error: 'Profile not found' }; + } + + console.log('[TerminalManager] Switching to Claude profile:', profile.name); + + if (terminal.isClaudeMode) { + terminal.pty.write('\x03'); + await new Promise(resolve => setTimeout(resolve, 500)); + terminal.pty.write('/exit\r'); + await new Promise(resolve => setTimeout(resolve, 500)); + } + + this.lastNotifiedRateLimitReset.delete(id); + + const projectPath = terminal.projectPath || terminal.cwd; + this.invokeClaude(id, projectPath, profileId); + + profileManager.setActiveProfile(profileId); + + return { success: true }; + } + + /** + * Resume Claude in a terminal with a specific session ID + */ + resumeClaude(id: string, sessionId?: string): void { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + terminal.isClaudeMode = true; + + let command: string; + if (sessionId) { + command = `claude --resume "${sessionId}"`; + terminal.claudeSessionId = sessionId; + } else { + command = 'claude --continue'; + } + + terminal.pty.write(`${command}\r`); + + const win = this.getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, id, 'Claude'); + } + } + + /** + * Get saved sessions for a project + */ + getSavedSessions(projectPath: string): TerminalSession[] { + return SessionHandler.getSavedSessions(projectPath); + } + + /** + * Clear saved sessions for a project + */ + clearSavedSessions(projectPath: string): void { + SessionHandler.clearSavedSessions(projectPath); + } + + /** + * Get available session dates + */ + getAvailableSessionDates(projectPath?: string): import('../terminal-session-store').SessionDateInfo[] { + return SessionHandler.getAvailableSessionDates(projectPath); + } + + /** + * Get sessions for a specific date + */ + getSessionsForDate(date: string, projectPath: string): TerminalSession[] { + return SessionHandler.getSessionsForDate(date, projectPath); + } + + /** + * Restore all sessions from a specific date + */ + async restoreSessionsFromDate( + date: string, + projectPath: string, + cols = 80, + rows = 24 + ): Promise<{ restored: number; failed: number; sessions: Array<{ id: string; success: boolean; error?: string }> }> { + const sessions = SessionHandler.getSessionsForDate(date, projectPath); + const results: Array<{ id: string; success: boolean; error?: string }> = []; + + for (const session of sessions) { + const result = await this.restore(session, cols, rows); + results.push({ + id: session.id, + success: result.success, + error: result.error + }); + } + + return { + restored: results.filter(r => r.success).length, + failed: results.filter(r => !r.success).length, + sessions: results + }; + } + + /** + * Kill all terminal processes + */ + async killAll(): Promise { + SessionHandler.persistAllSessions(this.terminals); + + if (this.saveTimer) { + clearInterval(this.saveTimer); + this.saveTimer = null; + } + + const promises: Promise[] = []; + + this.terminals.forEach((terminal) => { + promises.push( + new Promise((resolve) => { + try { + PtyManager.killPty(terminal); + } catch { + // Ignore errors during cleanup + } + resolve(); + }) + ); + }); + + await Promise.all(promises); + this.terminals.clear(); + } + + /** + * Get all active terminal IDs + */ + getActiveTerminalIds(): string[] { + return Array.from(this.terminals.keys()); + } + + /** + * Check if a terminal is in Claude mode + */ + isClaudeMode(id: string): boolean { + const terminal = this.terminals.get(id); + return terminal?.isClaudeMode ?? false; + } + + /** + * Get Claude session ID for a terminal + */ + getClaudeSessionId(id: string): string | undefined { + const terminal = this.terminals.get(id); + return terminal?.claudeSessionId; + } + + /** + * Update terminal title + */ + setTitle(id: string, title: string): void { + const terminal = this.terminals.get(id); + if (terminal) { + terminal.title = title; + } + } +} diff --git a/auto-claude-ui/src/main/terminal/types.ts b/auto-claude-ui/src/main/terminal/types.ts new file mode 100644 index 00000000..5cbd2eca --- /dev/null +++ b/auto-claude-ui/src/main/terminal/types.ts @@ -0,0 +1,64 @@ +import type * as pty from 'node-pty'; +import type { BrowserWindow } from 'electron'; + +/** + * Terminal process tracking + */ +export interface TerminalProcess { + id: string; + pty: pty.IPty; + isClaudeMode: boolean; + projectPath?: string; + cwd: string; + claudeSessionId?: string; + claudeProfileId?: string; + outputBuffer: string; + title: string; +} + +/** + * Rate limit event data + */ +export interface RateLimitEvent { + terminalId: string; + resetTime: string; + detectedAt: string; + profileId: string; + suggestedProfileId?: string; + suggestedProfileName?: string; + autoSwitchEnabled: boolean; +} + +/** + * OAuth token event data + */ +export interface OAuthTokenEvent { + terminalId: string; + profileId?: string; + email?: string; + success: boolean; + message?: string; + detectedAt: string; +} + +/** + * Session capture result + */ +export interface SessionCaptureResult { + sessionId: string | null; + captured: boolean; +} + +/** + * Terminal creation result + */ +export interface TerminalOperationResult { + success: boolean; + error?: string; + outputBuffer?: string; +} + +/** + * Window getter function type + */ +export type WindowGetter = () => BrowserWindow | null; diff --git a/auto-claude-ui/src/preload/api/agent-api.ts b/auto-claude-ui/src/preload/api/agent-api.ts new file mode 100644 index 00000000..a95561ff --- /dev/null +++ b/auto-claude-ui/src/preload/api/agent-api.ts @@ -0,0 +1,656 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + Task, + IPCResult, + Roadmap, + RoadmapFeatureStatus, + RoadmapGenerationStatus, + IdeationSession, + IdeationConfig, + IdeationStatus, + IdeationGenerationStatus, + Idea, + LinearTeam, + LinearProject, + LinearIssue, + LinearImportResult, + LinearSyncStatus, + GitHubRepository, + GitHubIssue, + GitHubSyncStatus, + GitHubImportResult, + GitHubInvestigationStatus, + GitHubInvestigationResult, + AutoBuildSourceUpdateCheck, + AutoBuildSourceUpdateProgress, + ChangelogTask, + TaskSpecContent, + ChangelogGenerationRequest, + ChangelogGenerationResult, + ChangelogSaveRequest, + ChangelogSaveResult, + ChangelogGenerationProgress, + ExistingChangelog, + GitBranchInfo, + GitTagInfo, + GitCommit, + GitHistoryOptions, + BranchDiffOptions, + InsightsSession, + InsightsSessionSummary, + InsightsChatStatus, + InsightsStreamChunk, + TaskMetadata +} from '../../shared/types'; + +export interface AgentAPI { + // Roadmap Operations + getRoadmap: (projectId: string) => Promise>; + generateRoadmap: (projectId: string) => void; + refreshRoadmap: (projectId: string) => void; + updateFeatureStatus: ( + projectId: string, + featureId: string, + status: RoadmapFeatureStatus + ) => Promise; + convertFeatureToSpec: ( + projectId: string, + featureId: string + ) => Promise>; + + // Roadmap Event Listeners + onRoadmapProgress: (callback: (projectId: string, status: RoadmapGenerationStatus) => void) => () => void; + onRoadmapComplete: (callback: (projectId: string, roadmap: Roadmap) => void) => () => void; + onRoadmapError: (callback: (projectId: string, error: string) => void) => () => void; + + // Ideation Operations + getIdeation: (projectId: string) => Promise>; + generateIdeation: (projectId: string, config: IdeationConfig) => void; + refreshIdeation: (projectId: string, config: IdeationConfig) => void; + stopIdeation: (projectId: string) => Promise; + updateIdeaStatus: (projectId: string, ideaId: string, status: IdeationStatus) => Promise; + convertIdeaToTask: (projectId: string, ideaId: string) => Promise>; + dismissIdea: (projectId: string, ideaId: string) => Promise; + dismissAllIdeas: (projectId: string) => Promise; + + // Ideation Event Listeners + onIdeationProgress: (callback: (projectId: string, status: IdeationGenerationStatus) => void) => () => void; + onIdeationLog: (callback: (projectId: string, log: string) => void) => () => void; + onIdeationComplete: (callback: (projectId: string, session: IdeationSession) => void) => () => void; + onIdeationError: (callback: (projectId: string, error: string) => void) => () => void; + onIdeationStopped: (callback: (projectId: string) => void) => () => void; + onIdeationTypeComplete: (callback: (projectId: string, ideationType: string, ideas: Idea[]) => void) => () => void; + onIdeationTypeFailed: (callback: (projectId: string, ideationType: string) => void) => () => void; + + // Insights Operations + getInsightsSession: (projectId: string) => Promise>; + sendInsightsMessage: (projectId: string, message: string) => void; + clearInsightsSession: (projectId: string) => Promise; + createTaskFromInsights: ( + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ) => Promise>; + listInsightsSessions: (projectId: string) => Promise>; + newInsightsSession: (projectId: string) => Promise>; + switchInsightsSession: (projectId: string, sessionId: string) => Promise>; + deleteInsightsSession: (projectId: string, sessionId: string) => Promise; + renameInsightsSession: (projectId: string, sessionId: string, newTitle: string) => Promise; + + // Insights Event Listeners + onInsightsStreamChunk: (callback: (projectId: string, chunk: InsightsStreamChunk) => void) => () => void; + onInsightsStatus: (callback: (projectId: string, status: InsightsChatStatus) => void) => () => void; + onInsightsError: (callback: (projectId: string, error: string) => void) => () => void; + + // Changelog Operations + getChangelogDoneTasks: (projectId: string, tasks?: Task[]) => Promise>; + loadTaskSpecs: (projectId: string, taskIds: string[]) => Promise>; + generateChangelog: (request: ChangelogGenerationRequest) => void; + saveChangelog: (request: ChangelogSaveRequest) => Promise>; + readExistingChangelog: (projectId: string) => Promise>; + suggestChangelogVersion: ( + projectId: string, + taskIds: string[] + ) => Promise>; + getChangelogBranches: (projectId: string) => Promise>; + getChangelogTags: (projectId: string) => Promise>; + getChangelogCommitsPreview: ( + projectId: string, + options: GitHistoryOptions | BranchDiffOptions, + mode: 'git-history' | 'branch-diff' + ) => Promise>; + + // Changelog Event Listeners + onChangelogGenerationProgress: (callback: (projectId: string, progress: ChangelogGenerationProgress) => void) => () => void; + onChangelogGenerationComplete: (callback: (projectId: string, result: ChangelogGenerationResult) => void) => () => void; + onChangelogGenerationError: (callback: (projectId: string, error: string) => void) => () => void; + + // Linear Integration + getLinearTeams: (projectId: string) => Promise>; + getLinearProjects: (projectId: string, teamId: string) => Promise>; + getLinearIssues: (projectId: string, teamId?: string, linearProjectId?: string) => Promise>; + importLinearIssues: (projectId: string, issueIds: string[]) => Promise>; + checkLinearConnection: (projectId: string) => Promise>; + + // GitHub Integration + getGitHubRepositories: (projectId: string) => Promise>; + getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all') => Promise>; + getGitHubIssue: (projectId: string, issueNumber: number) => Promise>; + checkGitHubConnection: (projectId: string) => Promise>; + investigateGitHubIssue: (projectId: string, issueNumber: number) => void; + importGitHubIssues: (projectId: string, issueNumbers: number[]) => Promise>; + createGitHubRelease: ( + projectId: string, + version: string, + releaseNotes: string, + options?: { draft?: boolean; prerelease?: boolean } + ) => Promise>; + + // GitHub Event Listeners + onGitHubInvestigationProgress: (callback: (projectId: string, status: GitHubInvestigationStatus) => void) => () => void; + onGitHubInvestigationComplete: (callback: (projectId: string, result: GitHubInvestigationResult) => void) => () => void; + onGitHubInvestigationError: (callback: (projectId: string, error: string) => void) => () => void; + + // Auto-Build Source Update + checkAutoBuildSourceUpdate: () => Promise>; + downloadAutoBuildSourceUpdate: () => void; + getAutoBuildSourceVersion: () => Promise>; + onAutoBuildSourceUpdateProgress: (callback: (progress: AutoBuildSourceUpdateProgress) => void) => () => void; +} + +export const createAgentAPI = (): AgentAPI => ({ + // Roadmap Operations + getRoadmap: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_GET, projectId), + + generateRoadmap: (projectId: string): void => + ipcRenderer.send(IPC_CHANNELS.ROADMAP_GENERATE, projectId), + + refreshRoadmap: (projectId: string): void => + ipcRenderer.send(IPC_CHANNELS.ROADMAP_REFRESH, projectId), + + updateFeatureStatus: ( + projectId: string, + featureId: string, + status: RoadmapFeatureStatus + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, projectId, featureId, status), + + convertFeatureToSpec: ( + projectId: string, + featureId: string + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId), + + // Roadmap Event Listeners + onRoadmapProgress: ( + callback: (projectId: string, status: RoadmapGenerationStatus) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + status: RoadmapGenerationStatus + ): void => { + callback(projectId, status); + }; + ipcRenderer.on(IPC_CHANNELS.ROADMAP_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_PROGRESS, handler); + }; + }, + + onRoadmapComplete: ( + callback: (projectId: string, roadmap: Roadmap) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + roadmap: Roadmap + ): void => { + callback(projectId, roadmap); + }; + ipcRenderer.on(IPC_CHANNELS.ROADMAP_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_COMPLETE, handler); + }; + }, + + onRoadmapError: ( + callback: (projectId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + error: string + ): void => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.ROADMAP_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_ERROR, handler); + }; + }, + + // Ideation Operations + getIdeation: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_GET, projectId), + + generateIdeation: (projectId: string, config: IdeationConfig): void => + ipcRenderer.send(IPC_CHANNELS.IDEATION_GENERATE, projectId, config), + + refreshIdeation: (projectId: string, config: IdeationConfig): void => + ipcRenderer.send(IPC_CHANNELS.IDEATION_REFRESH, projectId, config), + + stopIdeation: (projectId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_STOP, projectId), + + updateIdeaStatus: (projectId: string, ideaId: string, status: IdeationStatus): Promise => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_UPDATE_IDEA, projectId, ideaId, status), + + convertIdeaToTask: (projectId: string, ideaId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, projectId, ideaId), + + dismissIdea: (projectId: string, ideaId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS, projectId, ideaId), + + dismissAllIdeas: (projectId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS_ALL, projectId), + + // Ideation Event Listeners + onIdeationProgress: ( + callback: (projectId: string, status: IdeationGenerationStatus) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + status: IdeationGenerationStatus + ): void => { + callback(projectId, status); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_PROGRESS, handler); + }; + }, + + onIdeationLog: ( + callback: (projectId: string, log: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + log: string + ): void => { + callback(projectId, log); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_LOG, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_LOG, handler); + }; + }, + + onIdeationComplete: ( + callback: (projectId: string, session: IdeationSession) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + session: IdeationSession + ): void => { + callback(projectId, session); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_COMPLETE, handler); + }; + }, + + onIdeationError: ( + callback: (projectId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + error: string + ): void => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_ERROR, handler); + }; + }, + + onIdeationStopped: ( + callback: (projectId: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string + ): void => { + callback(projectId); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_STOPPED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_STOPPED, handler); + }; + }, + + onIdeationTypeComplete: ( + callback: (projectId: string, ideationType: string, ideas: Idea[]) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + ideationType: string, + ideas: Idea[] + ): void => { + callback(projectId, ideationType, ideas); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler); + }; + }, + + onIdeationTypeFailed: ( + callback: (projectId: string, ideationType: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + ideationType: string + ): void => { + callback(projectId, ideationType); + }; + ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler); + }; + }, + + // Insights Operations + getInsightsSession: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId), + + sendInsightsMessage: (projectId: string, message: string): void => + ipcRenderer.send(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message), + + clearInsightsSession: (projectId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId), + + createTaskFromInsights: ( + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CREATE_TASK, projectId, title, description, metadata), + + listInsightsSessions: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, projectId), + + newInsightsSession: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_NEW_SESSION, projectId), + + switchInsightsSession: (projectId: string, sessionId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_SWITCH_SESSION, projectId, sessionId), + + deleteInsightsSession: (projectId: string, sessionId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_DELETE_SESSION, projectId, sessionId), + + renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle), + + // Insights Event Listeners + onInsightsStreamChunk: ( + callback: (projectId: string, chunk: InsightsStreamChunk) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + chunk: InsightsStreamChunk + ): void => { + callback(projectId, chunk); + }; + ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler); + }; + }, + + onInsightsStatus: ( + callback: (projectId: string, status: InsightsChatStatus) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + status: InsightsChatStatus + ): void => { + callback(projectId, status); + }; + ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STATUS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STATUS, handler); + }; + }, + + onInsightsError: ( + callback: (projectId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + error: string + ): void => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.INSIGHTS_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_ERROR, handler); + }; + }, + + // Changelog Operations + getChangelogDoneTasks: (projectId: string, tasks?: Task[]): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, projectId, tasks), + + loadTaskSpecs: (projectId: string, taskIds: string[]): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, projectId, taskIds), + + generateChangelog: (request: ChangelogGenerationRequest): void => + ipcRenderer.send(IPC_CHANNELS.CHANGELOG_GENERATE, request), + + saveChangelog: (request: ChangelogSaveRequest): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SAVE, request), + + readExistingChangelog: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_READ_EXISTING, projectId), + + suggestChangelogVersion: ( + projectId: string, + taskIds: string[] + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, projectId, taskIds), + + getChangelogBranches: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_BRANCHES, projectId), + + getChangelogTags: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_TAGS, projectId), + + getChangelogCommitsPreview: ( + projectId: string, + options: GitHistoryOptions | BranchDiffOptions, + mode: 'git-history' | 'branch-diff' + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, projectId, options, mode), + + // Changelog Event Listeners + onChangelogGenerationProgress: ( + callback: (projectId: string, progress: ChangelogGenerationProgress) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + progress: ChangelogGenerationProgress + ): void => { + callback(projectId, progress); + }; + ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler); + }; + }, + + onChangelogGenerationComplete: ( + callback: (projectId: string, result: ChangelogGenerationResult) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + result: ChangelogGenerationResult + ): void => { + callback(projectId, result); + }; + ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler); + }; + }, + + onChangelogGenerationError: ( + callback: (projectId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + error: string + ): void => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler); + }; + }, + + // Linear Integration + getLinearTeams: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_TEAMS, projectId), + + getLinearProjects: (projectId: string, teamId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_PROJECTS, projectId, teamId), + + getLinearIssues: (projectId: string, teamId?: string, linearProjectId?: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_ISSUES, projectId, teamId, linearProjectId), + + importLinearIssues: (projectId: string, issueIds: string[]): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.LINEAR_IMPORT_ISSUES, projectId, issueIds), + + checkLinearConnection: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.LINEAR_CHECK_CONNECTION, projectId), + + // GitHub Integration + getGitHubRepositories: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_REPOSITORIES, projectId), + + getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state), + + getGitHubIssue: (projectId: string, issueNumber: number): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber), + + checkGitHubConnection: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId), + + investigateGitHubIssue: (projectId: string, issueNumber: number): void => + ipcRenderer.send(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber), + + importGitHubIssues: (projectId: string, issueNumbers: number[]): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_IMPORT_ISSUES, projectId, issueNumbers), + + createGitHubRelease: ( + projectId: string, + version: string, + releaseNotes: string, + options?: { draft?: boolean; prerelease?: boolean } + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options), + + // GitHub Event Listeners + onGitHubInvestigationProgress: ( + callback: (projectId: string, status: GitHubInvestigationStatus) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + status: GitHubInvestigationStatus + ): void => { + callback(projectId, status); + }; + ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler); + }; + }, + + onGitHubInvestigationComplete: ( + callback: (projectId: string, result: GitHubInvestigationResult) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + result: GitHubInvestigationResult + ): void => { + callback(projectId, result); + }; + ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler); + }; + }, + + onGitHubInvestigationError: ( + callback: (projectId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + projectId: string, + error: string + ): void => { + callback(projectId, error); + }; + ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler); + }; + }, + + // Auto-Build Source Update + checkAutoBuildSourceUpdate: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK), + + downloadAutoBuildSourceUpdate: (): void => + ipcRenderer.send(IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD), + + getAutoBuildSourceVersion: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION), + + onAutoBuildSourceUpdateProgress: ( + callback: (progress: AutoBuildSourceUpdateProgress) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + progress: AutoBuildSourceUpdateProgress + ): void => { + callback(progress); + }; + ipcRenderer.on(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler); + }; + } +}); diff --git a/auto-claude-ui/src/preload/api/file-api.ts b/auto-claude-ui/src/preload/api/file-api.ts new file mode 100644 index 00000000..5a07d05c --- /dev/null +++ b/auto-claude-ui/src/preload/api/file-api.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { IPCResult } from '../../shared/types'; + +export interface FileAPI { + // File Explorer Operations + listDirectory: (dirPath: string) => Promise>; +} + +export const createFileAPI = (): FileAPI => ({ + // File Explorer Operations + listDirectory: (dirPath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath) +}); diff --git a/auto-claude-ui/src/preload/api/index.ts b/auto-claude-ui/src/preload/api/index.ts new file mode 100644 index 00000000..5b0d393d --- /dev/null +++ b/auto-claude-ui/src/preload/api/index.ts @@ -0,0 +1,42 @@ +import { ProjectAPI, createProjectAPI } from './project-api'; +import { TerminalAPI, createTerminalAPI } from './terminal-api'; +import { TaskAPI, createTaskAPI } from './task-api'; +import { SettingsAPI, createSettingsAPI } from './settings-api'; +import { FileAPI, createFileAPI } from './file-api'; +import { AgentAPI, createAgentAPI } from './agent-api'; + +export interface ElectronAPI extends + ProjectAPI, + TerminalAPI, + TaskAPI, + SettingsAPI, + FileAPI, + AgentAPI {} + +export const createElectronAPI = (): ElectronAPI => ({ + ...createProjectAPI(), + ...createTerminalAPI(), + ...createTaskAPI(), + ...createSettingsAPI(), + ...createFileAPI(), + ...createAgentAPI() +}); + +// Export individual API creators for potential use in tests or specialized contexts +export { + createProjectAPI, + createTerminalAPI, + createTaskAPI, + createSettingsAPI, + createFileAPI, + createAgentAPI +}; + +export type { + ProjectAPI, + TerminalAPI, + TaskAPI, + SettingsAPI, + FileAPI, + AgentAPI +}; diff --git a/auto-claude-ui/src/preload/api/project-api.ts b/auto-claude-ui/src/preload/api/project-api.ts new file mode 100644 index 00000000..85678058 --- /dev/null +++ b/auto-claude-ui/src/preload/api/project-api.ts @@ -0,0 +1,122 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + Project, + ProjectSettings, + IPCResult, + InitializationResult, + AutoBuildVersionInfo, + ProjectContextData, + ProjectIndex, + GraphitiMemoryStatus, + ContextSearchResult, + MemoryEpisode, + ProjectEnvConfig, + ClaudeAuthResult +} from '../../shared/types'; + +export interface ProjectAPI { + // Project Management + addProject: (projectPath: string) => Promise>; + removeProject: (projectId: string) => Promise; + getProjects: () => Promise>; + updateProjectSettings: ( + projectId: string, + settings: Partial + ) => Promise; + initializeProject: (projectId: string) => Promise>; + updateProjectAutoBuild: (projectId: string) => Promise>; + checkProjectVersion: (projectId: string) => Promise>; + + // Context Operations + getProjectContext: (projectId: string) => Promise; + refreshProjectIndex: (projectId: string) => Promise; + getMemoryStatus: (projectId: string) => Promise; + searchMemories: (projectId: string, query: string) => Promise; + getRecentMemories: (projectId: string, limit?: number) => Promise; + + // Environment Configuration + getProjectEnv: (projectId: string) => Promise>; + updateProjectEnv: (projectId: string, config: Partial) => Promise; + checkClaudeAuth: (projectId: string) => Promise>; + invokeClaudeSetup: (projectId: string) => Promise>; + + // Dialog Operations + selectDirectory: () => Promise; + createProjectFolder: ( + location: string, + name: string, + initGit: boolean + ) => Promise>; + getDefaultProjectLocation: () => Promise; +} + +export const createProjectAPI = (): ProjectAPI => ({ + // Project Management + addProject: (projectPath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_ADD, projectPath), + + removeProject: (projectId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectId), + + getProjects: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), + + updateProjectSettings: ( + projectId: string, + settings: Partial + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_SETTINGS, projectId, settings), + + initializeProject: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_INITIALIZE, projectId), + + updateProjectAutoBuild: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_AUTOBUILD, projectId), + + checkProjectVersion: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CHECK_VERSION, projectId), + + // Context Operations + getProjectContext: (projectId: string) => + ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET, projectId), + + refreshProjectIndex: (projectId: string) => + ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_REFRESH_INDEX, projectId), + + getMemoryStatus: (projectId: string) => + ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_MEMORY_STATUS, projectId), + + searchMemories: (projectId: string, query: string) => + ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, projectId, query), + + getRecentMemories: (projectId: string, limit?: number) => + ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET_MEMORIES, projectId, limit), + + // Environment Configuration + getProjectEnv: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ENV_GET, projectId), + + updateProjectEnv: (projectId: string, config: Partial): Promise => + ipcRenderer.invoke(IPC_CHANNELS.ENV_UPDATE, projectId, config), + + checkClaudeAuth: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH, projectId), + + invokeClaudeSetup: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP, projectId), + + // Dialog Operations + selectDirectory: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY), + + createProjectFolder: ( + location: string, + name: string, + initGit: boolean + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER, location, name, initGit), + + getDefaultProjectLocation: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.DIALOG_GET_DEFAULT_PROJECT_LOCATION) +}); diff --git a/auto-claude-ui/src/preload/api/settings-api.ts b/auto-claude-ui/src/preload/api/settings-api.ts new file mode 100644 index 00000000..9ce29fec --- /dev/null +++ b/auto-claude-ui/src/preload/api/settings-api.ts @@ -0,0 +1,45 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + AppSettings, + IPCResult, + SourceEnvConfig, + SourceEnvCheckResult +} from '../../shared/types'; + +export interface SettingsAPI { + // App Settings + getSettings: () => Promise>; + saveSettings: (settings: Partial) => Promise; + + // App Info + getAppVersion: () => Promise; + + // Auto-Build Source Environment + getSourceEnv: () => Promise>; + updateSourceEnv: (config: { claudeOAuthToken?: string }) => Promise; + checkSourceToken: () => Promise>; +} + +export const createSettingsAPI = (): SettingsAPI => ({ + // App Settings + getSettings: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET), + + saveSettings: (settings: Partial): Promise => + ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SAVE, settings), + + // App Info + getAppVersion: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.APP_VERSION), + + // Auto-Build Source Environment + getSourceEnv: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET), + + updateSourceEnv: (config: { claudeOAuthToken?: string }): Promise => + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config), + + checkSourceToken: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN) +}); diff --git a/auto-claude-ui/src/preload/api/task-api.ts b/auto-claude-ui/src/preload/api/task-api.ts new file mode 100644 index 00000000..f0adb5f2 --- /dev/null +++ b/auto-claude-ui/src/preload/api/task-api.ts @@ -0,0 +1,266 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + Task, + IPCResult, + TaskStartOptions, + TaskStatus, + TaskRecoveryResult, + ImplementationPlan, + TaskMetadata, + TaskLogs, + TaskLogStreamChunk +} from '../../shared/types'; + +export interface TaskAPI { + // Task Operations + getTasks: (projectId: string) => Promise>; + createTask: ( + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ) => Promise>; + deleteTask: (taskId: string) => Promise; + updateTask: ( + taskId: string, + updates: { title?: string; description?: string } + ) => Promise>; + startTask: (taskId: string, options?: TaskStartOptions) => void; + stopTask: (taskId: string) => void; + submitReview: ( + taskId: string, + approved: boolean, + feedback?: string + ) => Promise; + updateTaskStatus: ( + taskId: string, + status: TaskStatus + ) => Promise; + recoverStuckTask: ( + taskId: string, + options?: import('../../shared/types').TaskRecoveryOptions + ) => Promise>; + checkTaskRunning: (taskId: string) => Promise>; + + // Workspace Management (for human review) + getWorktreeStatus: (taskId: string) => Promise>; + getWorktreeDiff: (taskId: string) => Promise>; + mergeWorktree: (taskId: string, options?: { noCommit?: boolean }) => Promise>; + discardWorktree: (taskId: string) => Promise>; + listWorktrees: (projectId: string) => Promise>; + archiveTasks: (projectId: string, taskIds: string[], version?: string) => Promise>; + unarchiveTasks: (projectId: string, taskIds: string[]) => Promise>; + + // Task Event Listeners + onTaskProgress: (callback: (taskId: string, plan: ImplementationPlan) => void) => () => void; + onTaskError: (callback: (taskId: string, error: string) => void) => () => void; + onTaskLog: (callback: (taskId: string, log: string) => void) => () => void; + onTaskStatusChange: (callback: (taskId: string, status: TaskStatus) => void) => () => void; + onTaskExecutionProgress: ( + callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void + ) => () => void; + + // Task Phase Logs + getTaskLogs: (projectId: string, specId: string) => Promise>; + watchTaskLogs: (projectId: string, specId: string) => Promise; + unwatchTaskLogs: (specId: string) => Promise; + onTaskLogsChanged: (callback: (specId: string, logs: TaskLogs) => void) => () => void; + onTaskLogsStream: (callback: (specId: string, chunk: TaskLogStreamChunk) => void) => () => void; +} + +export const createTaskAPI = (): TaskAPI => ({ + // Task Operations + getTasks: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId), + + createTask: ( + projectId: string, + title: string, + description: string, + metadata?: TaskMetadata + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_CREATE, projectId, title, description, metadata), + + deleteTask: (taskId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_DELETE, taskId), + + updateTask: ( + taskId: string, + updates: { title?: string; description?: string } + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE, taskId, updates), + + startTask: (taskId: string, options?: TaskStartOptions): void => + ipcRenderer.send(IPC_CHANNELS.TASK_START, taskId, options), + + stopTask: (taskId: string): void => + ipcRenderer.send(IPC_CHANNELS.TASK_STOP, taskId), + + submitReview: ( + taskId: string, + approved: boolean, + feedback?: string + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback), + + updateTaskStatus: ( + taskId: string, + status: TaskStatus + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status), + + recoverStuckTask: ( + taskId: string, + options?: import('../../shared/types').TaskRecoveryOptions + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_RECOVER_STUCK, taskId, options), + + checkTaskRunning: (taskId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId), + + // Workspace Management + getWorktreeStatus: (taskId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_STATUS, taskId), + + getWorktreeDiff: (taskId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DIFF, taskId), + + mergeWorktree: (taskId: string, options?: { noCommit?: boolean }): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_MERGE, taskId, options), + + discardWorktree: (taskId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DISCARD, taskId), + + listWorktrees: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId), + + archiveTasks: (projectId: string, taskIds: string[], version?: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_ARCHIVE, projectId, taskIds, version), + + unarchiveTasks: (projectId: string, taskIds: string[]): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_UNARCHIVE, projectId, taskIds), + + // Task Event Listeners + onTaskProgress: ( + callback: (taskId: string, plan: ImplementationPlan) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + plan: ImplementationPlan + ): void => { + callback(taskId, plan); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_PROGRESS, handler); + }; + }, + + onTaskError: ( + callback: (taskId: string, error: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + error: string + ): void => { + callback(taskId, error); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_ERROR, handler); + }; + }, + + onTaskLog: ( + callback: (taskId: string, log: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + log: string + ): void => { + callback(taskId, log); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOG, handler); + }; + }, + + onTaskStatusChange: ( + callback: (taskId: string, status: TaskStatus) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + status: TaskStatus + ): void => { + callback(taskId, status); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); + }; + }, + + onTaskExecutionProgress: ( + callback: (taskId: string, progress: import('../../shared/types').ExecutionProgress) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + taskId: string, + progress: import('../../shared/types').ExecutionProgress + ): void => { + callback(taskId, progress); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); + }; + }, + + // Task Phase Logs + getTaskLogs: (projectId: string, specId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_GET, projectId, specId), + + watchTaskLogs: (projectId: string, specId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_WATCH, projectId, specId), + + unwatchTaskLogs: (specId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_UNWATCH, specId), + + onTaskLogsChanged: ( + callback: (specId: string, logs: TaskLogs) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + specId: string, + logs: TaskLogs + ): void => { + callback(specId, logs); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_CHANGED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_CHANGED, handler); + }; + }, + + onTaskLogsStream: ( + callback: (specId: string, chunk: TaskLogStreamChunk) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + specId: string, + chunk: TaskLogStreamChunk + ): void => { + callback(specId, chunk); + }; + ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_STREAM, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_STREAM, handler); + }; + } +}); diff --git a/auto-claude-ui/src/preload/api/terminal-api.ts b/auto-claude-ui/src/preload/api/terminal-api.ts new file mode 100644 index 00000000..c735c2a4 --- /dev/null +++ b/auto-claude-ui/src/preload/api/terminal-api.ts @@ -0,0 +1,267 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../../shared/constants'; +import type { + IPCResult, + TerminalCreateOptions, + RateLimitInfo, + ClaudeProfile, + ClaudeProfileSettings +} from '../../shared/types'; + +export interface TerminalAPI { + // Terminal Operations + createTerminal: (options: TerminalCreateOptions) => Promise; + destroyTerminal: (id: string) => Promise; + sendTerminalInput: (id: string, data: string) => void; + resizeTerminal: (id: string, cols: number, rows: number) => void; + invokeClaudeInTerminal: (id: string, cwd?: string) => void; + + // Terminal Session Management + getTerminalSessions: (projectPath: string) => Promise>; + restoreTerminalSession: ( + session: import('../../shared/types').TerminalSession, + cols?: number, + rows?: number + ) => Promise>; + clearTerminalSessions: (projectPath: string) => Promise; + resumeClaudeInTerminal: (id: string, sessionId?: string) => void; + getTerminalSessionDates: (projectPath?: string) => Promise>; + getTerminalSessionsForDate: ( + date: string, + projectPath: string + ) => Promise>; + restoreTerminalSessionsFromDate: ( + date: string, + projectPath: string, + cols?: number, + rows?: number + ) => Promise>; + + // Terminal Event Listeners + onTerminalOutput: (callback: (id: string, data: string) => void) => () => void; + onTerminalExit: (callback: (id: string, exitCode: number) => void) => () => void; + onTerminalTitleChange: (callback: (id: string, title: string) => void) => () => void; + onTerminalClaudeSession: (callback: (id: string, sessionId: string) => void) => () => void; + onTerminalRateLimit: (callback: (info: RateLimitInfo) => void) => () => void; + onTerminalOAuthToken: ( + callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void + ) => () => void; + + // Claude Profile Management + getClaudeProfiles: () => Promise>; + saveClaudeProfile: (profile: ClaudeProfile) => Promise>; + deleteClaudeProfile: (profileId: string) => Promise; + renameClaudeProfile: (profileId: string, newName: string) => Promise; + setActiveClaudeProfile: (profileId: string) => Promise; + switchClaudeProfile: (terminalId: string, profileId: string) => Promise; + initializeClaudeProfile: (profileId: string) => Promise; + setClaudeProfileToken: (profileId: string, token: string, email?: string) => Promise; + getAutoSwitchSettings: () => Promise>; + updateAutoSwitchSettings: (settings: Partial) => Promise; + fetchClaudeUsage: (terminalId: string) => Promise; + getBestAvailableProfile: (excludeProfileId?: string) => Promise>; + onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void; + retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise; +} + +export const createTerminalAPI = (): TerminalAPI => ({ + // Terminal Operations + createTerminal: (options: TerminalCreateOptions): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CREATE, options), + + destroyTerminal: (id: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_DESTROY, id), + + sendTerminalInput: (id: string, data: string): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data), + + resizeTerminal: (id: string, cols: number, rows: number): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows), + + invokeClaudeInTerminal: (id: string, cwd?: string): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd), + + // Terminal Session Management + getTerminalSessions: (projectPath: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath), + + restoreTerminalSession: ( + session: import('../../shared/types').TerminalSession, + cols?: number, + rows?: number + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_SESSION, session, cols, rows), + + clearTerminalSessions: (projectPath: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath), + + resumeClaudeInTerminal: (id: string, sessionId?: string): void => + ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId), + + getTerminalSessionDates: (projectPath?: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, projectPath), + + getTerminalSessionsForDate: ( + date: string, + projectPath: string + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, date, projectPath), + + restoreTerminalSessionsFromDate: ( + date: string, + projectPath: string, + cols?: number, + rows?: number + ): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, date, projectPath, cols, rows), + + // Terminal Event Listeners + onTerminalOutput: ( + callback: (id: string, data: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string, + data: string + ): void => { + callback(id, data); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_OUTPUT, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OUTPUT, handler); + }; + }, + + onTerminalExit: ( + callback: (id: string, exitCode: number) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string, + exitCode: number + ): void => { + callback(id, exitCode); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_EXIT, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_EXIT, handler); + }; + }, + + onTerminalTitleChange: ( + callback: (id: string, title: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string, + title: string + ): void => { + callback(id, title); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler); + }; + }, + + onTerminalClaudeSession: ( + callback: (id: string, sessionId: string) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + id: string, + sessionId: string + ): void => { + callback(id, sessionId); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler); + }; + }, + + onTerminalRateLimit: ( + callback: (info: RateLimitInfo) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: RateLimitInfo + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler); + }; + }, + + onTerminalOAuthToken: ( + callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string } + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler); + }; + }, + + // Claude Profile Management + getClaudeProfiles: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET), + + saveClaudeProfile: (profile: ClaudeProfile): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SAVE, profile), + + deleteClaudeProfile: (profileId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_DELETE, profileId), + + renameClaudeProfile: (profileId: string, newName: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_RENAME, profileId, newName), + + setActiveClaudeProfile: (profileId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, profileId), + + switchClaudeProfile: (terminalId: string, profileId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, terminalId, profileId), + + initializeClaudeProfile: (profileId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, profileId), + + setClaudeProfileToken: (profileId: string, token: string, email?: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, profileId, token, email), + + getAutoSwitchSettings: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS), + + updateAutoSwitchSettings: (settings: Partial): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, settings), + + fetchClaudeUsage: (terminalId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, terminalId), + + getBestAvailableProfile: (excludeProfileId?: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, excludeProfileId), + + onSDKRateLimit: ( + callback: (info: import('../../shared/types').SDKRateLimitInfo) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: import('../../shared/types').SDKRateLimitInfo + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler); + }; + }, + + retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest): Promise => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request) +}); diff --git a/auto-claude-ui/src/preload/index.ts b/auto-claude-ui/src/preload/index.ts index 09be7f40..a2512a91 100644 --- a/auto-claude-ui/src/preload/index.ts +++ b/auto-claude-ui/src/preload/index.ts @@ -1,1139 +1,8 @@ -import { contextBridge, ipcRenderer } from 'electron'; -import { IPC_CHANNELS } from '../shared/constants'; -import type { - Project, - ProjectSettings, - Task, - AppSettings, - IPCResult, - TaskStartOptions, - TaskStatus, - TaskRecoveryResult, - ImplementationPlan, - ElectronAPI, - TerminalCreateOptions, - Roadmap, - RoadmapFeatureStatus, - RoadmapGenerationStatus, - InitializationResult, - AutoBuildVersionInfo, - ProjectContextData, - ProjectIndex, - GraphitiMemoryStatus, - ContextSearchResult, - MemoryEpisode, - ProjectEnvConfig, - ClaudeAuthResult, - LinearTeam, - LinearProject, - LinearIssue, - LinearImportResult, - LinearSyncStatus, - GitHubRepository, - GitHubIssue, - GitHubSyncStatus, - GitHubImportResult, - GitHubInvestigationStatus, - GitHubInvestigationResult, - IdeationSession, - IdeationConfig, - IdeationStatus, - IdeationGenerationStatus, - Idea, - AutoBuildSourceUpdateCheck, - AutoBuildSourceUpdateProgress, - SourceEnvConfig, - SourceEnvCheckResult, - ChangelogTask, - TaskSpecContent, - ChangelogGenerationRequest, - ChangelogGenerationResult, - ChangelogSaveRequest, - ChangelogSaveResult, - ChangelogGenerationProgress, - ExistingChangelog, - GitBranchInfo, - GitTagInfo, - GitCommit, - GitHistoryOptions, - BranchDiffOptions, - InsightsSession, - InsightsSessionSummary, - InsightsChatStatus, - InsightsStreamChunk, - TaskMetadata, - TaskLogs, - TaskLogStreamChunk, - RateLimitInfo, - ClaudeProfile, - ClaudeProfileSettings -} from '../shared/types'; +import { contextBridge } from 'electron'; +import { createElectronAPI } from './api'; -// Expose a secure API to the renderer process -const electronAPI: ElectronAPI = { - // ============================================ - // Project Operations - // ============================================ - - addProject: (projectPath: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_ADD, projectPath), - - removeProject: (projectId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectId), - - getProjects: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), - - updateProjectSettings: ( - projectId: string, - settings: Partial - ): Promise => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_SETTINGS, projectId, settings), - - initializeProject: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_INITIALIZE, projectId), - - updateProjectAutoBuild: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_UPDATE_AUTOBUILD, projectId), - - checkProjectVersion: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CHECK_VERSION, projectId), - - // ============================================ - // Task Operations - // ============================================ - - getTasks: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST, projectId), - - createTask: ( - projectId: string, - title: string, - description: string, - metadata?: import('../shared/types').TaskMetadata - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_CREATE, projectId, title, description, metadata), - - deleteTask: (taskId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TASK_DELETE, taskId), - - updateTask: ( - taskId: string, - updates: { title?: string; description?: string } - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE, taskId, updates), - - startTask: (taskId: string, options?: TaskStartOptions): void => - ipcRenderer.send(IPC_CHANNELS.TASK_START, taskId, options), - - stopTask: (taskId: string): void => - ipcRenderer.send(IPC_CHANNELS.TASK_STOP, taskId), - - submitReview: ( - taskId: string, - approved: boolean, - feedback?: string - ): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TASK_REVIEW, taskId, approved, feedback), - - updateTaskStatus: ( - taskId: string, - status: import('../shared/types').TaskStatus - ): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TASK_UPDATE_STATUS, taskId, status), - - recoverStuckTask: ( - taskId: string, - options?: import('../shared/types').TaskRecoveryOptions - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_RECOVER_STUCK, taskId, options), - - checkTaskRunning: (taskId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId), - - // ============================================ - // Workspace Management (for human review) - // ============================================ - - getWorktreeStatus: (taskId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_STATUS, taskId), - - getWorktreeDiff: (taskId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DIFF, taskId), - - mergeWorktree: (taskId: string, options?: { noCommit?: boolean }): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_MERGE, taskId, options), - - discardWorktree: (taskId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_WORKTREE_DISCARD, taskId), - - listWorktrees: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LIST_WORKTREES, projectId), - - archiveTasks: (projectId: string, taskIds: string[], version?: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_ARCHIVE, projectId, taskIds, version), - - unarchiveTasks: (projectId: string, taskIds: string[]): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_UNARCHIVE, projectId, taskIds), - - // ============================================ - // Event Listeners (main → renderer) - // ============================================ - - onTaskProgress: ( - callback: (taskId: string, plan: ImplementationPlan) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - taskId: string, - plan: ImplementationPlan - ): void => { - callback(taskId, plan); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_PROGRESS, handler); - // Return cleanup function - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_PROGRESS, handler); - }; - }, - - onTaskError: ( - callback: (taskId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - taskId: string, - error: string - ): void => { - callback(taskId, error); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_ERROR, handler); - }; - }, - - onTaskLog: ( - callback: (taskId: string, log: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - taskId: string, - log: string - ): void => { - callback(taskId, log); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_LOG, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOG, handler); - }; - }, - - onTaskStatusChange: ( - callback: (taskId: string, status: TaskStatus) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - taskId: string, - status: TaskStatus - ): void => { - callback(taskId, status); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_STATUS_CHANGE, handler); - }; - }, - - onTaskExecutionProgress: ( - callback: (taskId: string, progress: import('../shared/types').ExecutionProgress) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - taskId: string, - progress: import('../shared/types').ExecutionProgress - ): void => { - callback(taskId, progress); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_EXECUTION_PROGRESS, handler); - }; - }, - - // ============================================ - // Task Phase Logs (collapsible by phase) - // ============================================ - - getTaskLogs: (projectId: string, specId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_GET, projectId, specId), - - watchTaskLogs: (projectId: string, specId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_WATCH, projectId, specId), - - unwatchTaskLogs: (specId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TASK_LOGS_UNWATCH, specId), - - onTaskLogsChanged: ( - callback: (specId: string, logs: TaskLogs) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - specId: string, - logs: TaskLogs - ): void => { - callback(specId, logs); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_CHANGED, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_CHANGED, handler); - }; - }, - - onTaskLogsStream: ( - callback: (specId: string, chunk: TaskLogStreamChunk) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - specId: string, - chunk: TaskLogStreamChunk - ): void => { - callback(specId, chunk); - }; - ipcRenderer.on(IPC_CHANNELS.TASK_LOGS_STREAM, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TASK_LOGS_STREAM, handler); - }; - }, - - // ============================================ - // Terminal Operations - // ============================================ - - createTerminal: (options: TerminalCreateOptions): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CREATE, options), - - destroyTerminal: (id: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_DESTROY, id), - - sendTerminalInput: (id: string, data: string): void => - ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data), - - resizeTerminal: (id: string, cols: number, rows: number): void => - ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows), - - invokeClaudeInTerminal: (id: string, cwd?: string): void => - ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd), - - // Terminal Session Management (persistence/restore) - getTerminalSessions: (projectPath: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS, projectPath), - - restoreTerminalSession: ( - session: import('../shared/types').TerminalSession, - cols?: number, - rows?: number - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_SESSION, session, cols, rows), - - clearTerminalSessions: (projectPath: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_CLEAR_SESSIONS, projectPath), - - resumeClaudeInTerminal: (id: string, sessionId?: string): void => - ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, id, sessionId), - - getTerminalSessionDates: (projectPath?: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSION_DATES, projectPath), - - getTerminalSessionsForDate: ( - date: string, - projectPath: string - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_GET_SESSIONS_FOR_DATE, date, projectPath), - - restoreTerminalSessionsFromDate: ( - date: string, - projectPath: string, - cols?: number, - rows?: number - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESTORE_FROM_DATE, date, projectPath, cols, rows), - - // ============================================ - // Terminal Event Listeners - // ============================================ - - onTerminalOutput: ( - callback: (id: string, data: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - id: string, - data: string - ): void => { - callback(id, data); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_OUTPUT, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OUTPUT, handler); - }; - }, - - onTerminalExit: ( - callback: (id: string, exitCode: number) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - id: string, - exitCode: number - ): void => { - callback(id, exitCode); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_EXIT, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_EXIT, handler); - }; - }, - - onTerminalTitleChange: ( - callback: (id: string, title: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - id: string, - title: string - ): void => { - callback(id, title); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, handler); - }; - }, - - onTerminalClaudeSession: ( - callback: (id: string, sessionId: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - id: string, - sessionId: string - ): void => { - callback(id, sessionId); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, handler); - }; - }, - - onTerminalRateLimit: ( - callback: (info: RateLimitInfo) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - info: RateLimitInfo - ): void => { - callback(info); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_RATE_LIMIT, handler); - }; - }, - - onTerminalOAuthToken: ( - callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string } - ): void => { - callback(info); - }; - ipcRenderer.on(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, handler); - }; - }, - - // ============================================ - // Claude Profile Management (Multi-Account Support) - // ============================================ - - getClaudeProfiles: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET), - - saveClaudeProfile: (profile: ClaudeProfile): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SAVE, profile), - - deleteClaudeProfile: (profileId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_DELETE, profileId), - - renameClaudeProfile: (profileId: string, newName: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_RENAME, profileId, newName), - - setActiveClaudeProfile: (profileId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_ACTIVE, profileId), - - switchClaudeProfile: (terminalId: string, profileId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SWITCH, terminalId, profileId), - - initializeClaudeProfile: (profileId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, profileId), - - setClaudeProfileToken: (profileId: string, token: string, email?: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, profileId, token, email), - - getAutoSwitchSettings: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS), - - updateAutoSwitchSettings: (settings: Partial): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, settings), - - fetchClaudeUsage: (terminalId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, terminalId), - - getBestAvailableProfile: (excludeProfileId?: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_GET_BEST_PROFILE, excludeProfileId), - - onSDKRateLimit: ( - callback: (info: import('../shared/types').SDKRateLimitInfo) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - info: import('../shared/types').SDKRateLimitInfo - ): void => { - callback(info); - }; - ipcRenderer.on(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, handler); - }; - }, - - retryWithProfile: (request: import('../shared/types').RetryWithProfileRequest): Promise => - ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request), - - // ============================================ - // App Settings - // ============================================ - - getSettings: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_GET), - - saveSettings: (settings: Partial): Promise => - ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SAVE, settings), - - // ============================================ - // Dialog Operations - // ============================================ - - selectDirectory: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIRECTORY), - - createProjectFolder: ( - location: string, - name: string, - initGit: boolean - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.DIALOG_CREATE_PROJECT_FOLDER, location, name, initGit), - - getDefaultProjectLocation: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.DIALOG_GET_DEFAULT_PROJECT_LOCATION), - - // ============================================ - // App Info - // ============================================ - - getAppVersion: (): Promise => - ipcRenderer.invoke(IPC_CHANNELS.APP_VERSION), - - // ============================================ - // Roadmap Operations - // ============================================ - - getRoadmap: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_GET, projectId), - - generateRoadmap: (projectId: string): void => - ipcRenderer.send(IPC_CHANNELS.ROADMAP_GENERATE, projectId), - - refreshRoadmap: (projectId: string): void => - ipcRenderer.send(IPC_CHANNELS.ROADMAP_REFRESH, projectId), - - updateFeatureStatus: ( - projectId: string, - featureId: string, - status: RoadmapFeatureStatus - ): Promise => - ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_UPDATE_FEATURE, projectId, featureId, status), - - convertFeatureToSpec: ( - projectId: string, - featureId: string - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ROADMAP_CONVERT_TO_SPEC, projectId, featureId), - - // ============================================ - // Roadmap Event Listeners - // ============================================ - - onRoadmapProgress: ( - callback: (projectId: string, status: RoadmapGenerationStatus) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - status: RoadmapGenerationStatus - ): void => { - callback(projectId, status); - }; - ipcRenderer.on(IPC_CHANNELS.ROADMAP_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_PROGRESS, handler); - }; - }, - - onRoadmapComplete: ( - callback: (projectId: string, roadmap: Roadmap) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - roadmap: Roadmap - ): void => { - callback(projectId, roadmap); - }; - ipcRenderer.on(IPC_CHANNELS.ROADMAP_COMPLETE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_COMPLETE, handler); - }; - }, - - onRoadmapError: ( - callback: (projectId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - error: string - ): void => { - callback(projectId, error); - }; - ipcRenderer.on(IPC_CHANNELS.ROADMAP_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.ROADMAP_ERROR, handler); - }; - }, - - // ============================================ - // Context Operations - // ============================================ - - getProjectContext: (projectId: string) => - ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET, projectId), - - refreshProjectIndex: (projectId: string) => - ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_REFRESH_INDEX, projectId), - - getMemoryStatus: (projectId: string) => - ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_MEMORY_STATUS, projectId), - - searchMemories: (projectId: string, query: string) => - ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_SEARCH_MEMORIES, projectId, query), - - getRecentMemories: (projectId: string, limit?: number) => - ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET_MEMORIES, projectId, limit), - - // ============================================ - // Environment Configuration Operations - // ============================================ - - getProjectEnv: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ENV_GET, projectId), - - updateProjectEnv: (projectId: string, config: Partial): Promise => - ipcRenderer.invoke(IPC_CHANNELS.ENV_UPDATE, projectId, config), - - checkClaudeAuth: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH, projectId), - - invokeClaudeSetup: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP, projectId), - - // ============================================ - // Linear Integration Operations - // ============================================ - - getLinearTeams: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_TEAMS, projectId), - - getLinearProjects: (projectId: string, teamId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_PROJECTS, projectId, teamId), - - getLinearIssues: (projectId: string, teamId?: string, linearProjectId?: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.LINEAR_GET_ISSUES, projectId, teamId, linearProjectId), - - importLinearIssues: (projectId: string, issueIds: string[]): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.LINEAR_IMPORT_ISSUES, projectId, issueIds), - - checkLinearConnection: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.LINEAR_CHECK_CONNECTION, projectId), - - // ============================================ - // GitHub Integration Operations - // ============================================ - - getGitHubRepositories: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_REPOSITORIES, projectId), - - getGitHubIssues: (projectId: string, state?: 'open' | 'closed' | 'all'): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUES, projectId, state), - - getGitHubIssue: (projectId: string, issueNumber: number): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_GET_ISSUE, projectId, issueNumber), - - checkGitHubConnection: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CHECK_CONNECTION, projectId), - - investigateGitHubIssue: (projectId: string, issueNumber: number): void => - ipcRenderer.send(IPC_CHANNELS.GITHUB_INVESTIGATE_ISSUE, projectId, issueNumber), - - importGitHubIssues: (projectId: string, issueNumbers: number[]): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_IMPORT_ISSUES, projectId, issueNumbers), - - createGitHubRelease: ( - projectId: string, - version: string, - releaseNotes: string, - options?: { draft?: boolean; prerelease?: boolean } - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.GITHUB_CREATE_RELEASE, projectId, version, releaseNotes, options), - - // ============================================ - // GitHub Event Listeners - // ============================================ - - onGitHubInvestigationProgress: ( - callback: (projectId: string, status: GitHubInvestigationStatus) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - status: GitHubInvestigationStatus - ): void => { - callback(projectId, status); - }; - ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_PROGRESS, handler); - }; - }, - - onGitHubInvestigationComplete: ( - callback: (projectId: string, result: GitHubInvestigationResult) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - result: GitHubInvestigationResult - ): void => { - callback(projectId, result); - }; - ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_COMPLETE, handler); - }; - }, - - onGitHubInvestigationError: ( - callback: (projectId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - error: string - ): void => { - callback(projectId, error); - }; - ipcRenderer.on(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.GITHUB_INVESTIGATION_ERROR, handler); - }; - }, - - // ============================================ - // Ideation Operations - // ============================================ - - getIdeation: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_GET, projectId), - - generateIdeation: (projectId: string, config: IdeationConfig): void => - ipcRenderer.send(IPC_CHANNELS.IDEATION_GENERATE, projectId, config), - - refreshIdeation: (projectId: string, config: IdeationConfig): void => - ipcRenderer.send(IPC_CHANNELS.IDEATION_REFRESH, projectId, config), - - stopIdeation: (projectId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_STOP, projectId), - - updateIdeaStatus: (projectId: string, ideaId: string, status: IdeationStatus): Promise => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_UPDATE_IDEA, projectId, ideaId, status), - - convertIdeaToTask: (projectId: string, ideaId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_CONVERT_TO_TASK, projectId, ideaId), - - dismissIdea: (projectId: string, ideaId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS, projectId, ideaId), - - dismissAllIdeas: (projectId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.IDEATION_DISMISS_ALL, projectId), - - // ============================================ - // Ideation Event Listeners - // ============================================ - - onIdeationProgress: ( - callback: (projectId: string, status: IdeationGenerationStatus) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - status: IdeationGenerationStatus - ): void => { - callback(projectId, status); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_PROGRESS, handler); - }; - }, - - onIdeationLog: ( - callback: (projectId: string, log: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - log: string - ): void => { - callback(projectId, log); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_LOG, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_LOG, handler); - }; - }, - - onIdeationComplete: ( - callback: (projectId: string, session: IdeationSession) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - session: IdeationSession - ): void => { - callback(projectId, session); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_COMPLETE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_COMPLETE, handler); - }; - }, - - onIdeationError: ( - callback: (projectId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - error: string - ): void => { - callback(projectId, error); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_ERROR, handler); - }; - }, - - onIdeationStopped: ( - callback: (projectId: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string - ): void => { - callback(projectId); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_STOPPED, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_STOPPED, handler); - }; - }, - - onIdeationTypeComplete: ( - callback: (projectId: string, ideationType: string, ideas: Idea[]) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - ideationType: string, - ideas: Idea[] - ): void => { - callback(projectId, ideationType, ideas); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_COMPLETE, handler); - }; - }, - - onIdeationTypeFailed: ( - callback: (projectId: string, ideationType: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - ideationType: string - ): void => { - callback(projectId, ideationType); - }; - ipcRenderer.on(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.IDEATION_TYPE_FAILED, handler); - }; - }, - - // ============================================ - // Auto-Build Source Update Operations - // ============================================ - - checkAutoBuildSourceUpdate: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK), - - downloadAutoBuildSourceUpdate: (): void => - ipcRenderer.send(IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD), - - getAutoBuildSourceVersion: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION), - - onAutoBuildSourceUpdateProgress: ( - callback: (progress: AutoBuildSourceUpdateProgress) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - progress: AutoBuildSourceUpdateProgress - ): void => { - callback(progress); - }; - ipcRenderer.on(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS, handler); - }; - }, - - // ============================================ - // Auto-Build Source Environment Operations - // ============================================ - - getSourceEnv: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET), - - updateSourceEnv: (config: { claudeOAuthToken?: string }): Promise => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE, config), - - checkSourceToken: (): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN), - - // ============================================ - // Changelog Operations - // ============================================ - - getChangelogDoneTasks: (projectId: string, tasks?: Task[]): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_DONE_TASKS, projectId, tasks), - - loadTaskSpecs: (projectId: string, taskIds: string[]): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_LOAD_TASK_SPECS, projectId, taskIds), - - generateChangelog: (request: ChangelogGenerationRequest): void => - ipcRenderer.send(IPC_CHANNELS.CHANGELOG_GENERATE, request), - - saveChangelog: (request: ChangelogSaveRequest): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SAVE, request), - - readExistingChangelog: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_READ_EXISTING, projectId), - - suggestChangelogVersion: ( - projectId: string, - taskIds: string[] - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_SUGGEST_VERSION, projectId, taskIds), - - // Changelog git operations (for git-based changelog generation) - getChangelogBranches: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_BRANCHES, projectId), - - getChangelogTags: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_TAGS, projectId), - - getChangelogCommitsPreview: ( - projectId: string, - options: GitHistoryOptions | BranchDiffOptions, - mode: 'git-history' | 'branch-diff' - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.CHANGELOG_GET_COMMITS_PREVIEW, projectId, options, mode), - - // ============================================ - // Changelog Event Listeners - // ============================================ - - onChangelogGenerationProgress: ( - callback: (projectId: string, progress: ChangelogGenerationProgress) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - progress: ChangelogGenerationProgress - ): void => { - callback(projectId, progress); - }; - ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_PROGRESS, handler); - }; - }, - - onChangelogGenerationComplete: ( - callback: (projectId: string, result: ChangelogGenerationResult) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - result: ChangelogGenerationResult - ): void => { - callback(projectId, result); - }; - ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_COMPLETE, handler); - }; - }, - - onChangelogGenerationError: ( - callback: (projectId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - error: string - ): void => { - callback(projectId, error); - }; - ipcRenderer.on(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.CHANGELOG_GENERATION_ERROR, handler); - }; - }, - - // ============================================ - // Insights Operations - // ============================================ - - getInsightsSession: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_GET_SESSION, projectId), - - sendInsightsMessage: (projectId: string, message: string): void => - ipcRenderer.send(IPC_CHANNELS.INSIGHTS_SEND_MESSAGE, projectId, message), - - clearInsightsSession: (projectId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CLEAR_SESSION, projectId), - - createTaskFromInsights: ( - projectId: string, - title: string, - description: string, - metadata?: TaskMetadata - ): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_CREATE_TASK, projectId, title, description, metadata), - - listInsightsSessions: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_LIST_SESSIONS, projectId), - - newInsightsSession: (projectId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_NEW_SESSION, projectId), - - switchInsightsSession: (projectId: string, sessionId: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_SWITCH_SESSION, projectId, sessionId), - - deleteInsightsSession: (projectId: string, sessionId: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_DELETE_SESSION, projectId, sessionId), - - renameInsightsSession: (projectId: string, sessionId: string, newTitle: string): Promise => - ipcRenderer.invoke(IPC_CHANNELS.INSIGHTS_RENAME_SESSION, projectId, sessionId, newTitle), - - // ============================================ - // Insights Event Listeners - // ============================================ - - onInsightsStreamChunk: ( - callback: (projectId: string, chunk: InsightsStreamChunk) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - chunk: InsightsStreamChunk - ): void => { - callback(projectId, chunk); - }; - ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STREAM_CHUNK, handler); - }; - }, - - onInsightsStatus: ( - callback: (projectId: string, status: InsightsChatStatus) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - status: InsightsChatStatus - ): void => { - callback(projectId, status); - }; - ipcRenderer.on(IPC_CHANNELS.INSIGHTS_STATUS, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_STATUS, handler); - }; - }, - - onInsightsError: ( - callback: (projectId: string, error: string) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - projectId: string, - error: string - ): void => { - callback(projectId, error); - }; - ipcRenderer.on(IPC_CHANNELS.INSIGHTS_ERROR, handler); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.INSIGHTS_ERROR, handler); - }; - }, - - // ============================================ - // File Explorer Operations - // ============================================ - - listDirectory: (dirPath: string): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.FILE_EXPLORER_LIST, dirPath) -}; +// Create the unified API by combining all domain-specific APIs +const electronAPI = createElectronAPI(); // Expose to renderer via contextBridge contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/auto-claude-ui/src/renderer/App.tsx b/auto-claude-ui/src/renderer/App.tsx index 483a6856..cc6dde78 100644 --- a/auto-claude-ui/src/renderer/App.tsx +++ b/auto-claude-ui/src/renderer/App.tsx @@ -20,7 +20,7 @@ import { KanbanBoard } from './components/KanbanBoard'; import { TaskDetailPanel } from './components/TaskDetailPanel'; import { TaskCreationWizard } from './components/TaskCreationWizard'; import { AppSettingsDialog } from './components/AppSettings'; -import { ProjectSettings } from './components/ProjectSettings'; +import { ProjectSettings } from './components/project-settings'; import { TerminalGrid } from './components/TerminalGrid'; import { Roadmap } from './components/Roadmap'; import { Context } from './components/Context'; @@ -70,6 +70,15 @@ export function App() { loadSettings(); }, []); + // Check if selected project needs initialization (e.g., .auto-claude folder was deleted) + useEffect(() => { + if (selectedProject && !selectedProject.autoBuildPath && !showInitDialog) { + // Project exists but isn't initialized - show init dialog + setPendingProject(selectedProject); + setShowInitDialog(true); + } + }, [selectedProject, showInitDialog]); + // Load tasks when project changes useEffect(() => { if (selectedProjectId) { @@ -247,7 +256,10 @@ export function App() { )} {/* TerminalGrid is always mounted but hidden when not active to preserve terminal state */}
- + setIsNewTaskDialogOpen(true)} + />
{activeView === 'roadmap' && selectedProjectId && ( diff --git a/auto-claude-ui/src/renderer/__tests__/TaskEditDialog.test.ts b/auto-claude-ui/src/renderer/__tests__/TaskEditDialog.test.ts index c421d981..95b77cbd 100644 --- a/auto-claude-ui/src/renderer/__tests__/TaskEditDialog.test.ts +++ b/auto-claude-ui/src/renderer/__tests__/TaskEditDialog.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useTaskStore, persistUpdateTask } from '../stores/task-store'; -import type { Task, TaskStatus } from '../../shared/types'; +import type { Task, TaskStatus, ElectronAPI } from '../../shared/types'; // Helper to create test tasks function createTestTask(overrides: Partial = {}): Task { @@ -23,15 +23,11 @@ function createTestTask(overrides: Partial = {}): Task { }; } -// Mock the window.electronAPI -const mockUpdateTask = vi.fn(); +// Import browser mock to get full ElectronAPI structure +import '../lib/browser-mock'; -// We need to mock at module level before imports -vi.mock('../../preload/index', () => ({ - electronAPI: { - updateTask: mockUpdateTask - } -})); +// Mock the window.electronAPI.updateTask specifically +const mockUpdateTask = vi.fn(); // Override window.electronAPI for these tests const originalWindow = global.window; @@ -46,13 +42,10 @@ describe('TaskEditDialog Logic', () => { error: null }); - // Setup mock window.electronAPI - (global as typeof globalThis & { window: { electronAPI: { updateTask: typeof mockUpdateTask } } }).window = { - ...originalWindow, - electronAPI: { - updateTask: mockUpdateTask - } - } as typeof window; + // Override just the updateTask method on the existing electronAPI + if (window.electronAPI) { + window.electronAPI.updateTask = mockUpdateTask; + } // Clear mock calls mockUpdateTask.mockReset(); diff --git a/auto-claude-ui/src/renderer/components/AppSettings.tsx b/auto-claude-ui/src/renderer/components/AppSettings.tsx index 2109611e..93318dcd 100644 --- a/auto-claude-ui/src/renderer/components/AppSettings.tsx +++ b/auto-claude-ui/src/renderer/components/AppSettings.tsx @@ -1,1013 +1,9 @@ -import { useState, useEffect } from 'react'; -import { - Settings, - Save, - Loader2, - Moon, - Sun, - Monitor, - Download, - RefreshCw, - CheckCircle2, - AlertCircle, - CloudDownload, - Key, - Eye, - EyeOff, - Info, - Palette, - Bot, - FolderOpen, - Bell, - Package, - Users, - Plus, - Trash2, - Star, - Check, - Pencil, - X -} from 'lucide-react'; -import { - FullScreenDialog, - FullScreenDialogContent, - FullScreenDialogHeader, - FullScreenDialogBody, - FullScreenDialogFooter, - FullScreenDialogTitle, - FullScreenDialogDescription -} from './ui/full-screen-dialog'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Switch } from './ui/switch'; -import { ScrollArea } from './ui/scroll-area'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from './ui/select'; -import { Separator } from './ui/separator'; -import { cn } from '../lib/utils'; -import { useSettingsStore, saveSettings, loadSettings } from '../stores/settings-store'; -import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../stores/claude-profile-store'; -import { AVAILABLE_MODELS } from '../../shared/constants'; -import type { - AppSettings as AppSettingsType, - AutoBuildSourceUpdateCheck, - AutoBuildSourceUpdateProgress, - ClaudeProfile -} from '../../shared/types'; -import { Progress } from './ui/progress'; +/** + * AppSettings - Legacy re-export for backward compatibility + * The actual implementation has been refactored into modular components in ./settings/ + * + * This file maintains backward compatibility for existing imports. + * New code should import from './settings' instead. + */ -interface AppSettingsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -type SettingsSection = 'appearance' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications'; - -interface NavItem { - id: SettingsSection; - label: string; - icon: React.ElementType; - description: string; -} - -const navItems: NavItem[] = [ - { id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and visual preferences' }, - { id: 'agent', label: 'Agent Settings', icon: Bot, description: 'Default model and framework' }, - { id: 'paths', label: 'Paths', icon: FolderOpen, description: 'Python and framework paths' }, - { id: 'integrations', label: 'Integrations', icon: Key, description: 'API keys & Claude accounts' }, - { id: 'updates', label: 'Updates', icon: Package, description: 'Auto Claude updates' }, - { id: 'notifications', label: 'Notifications', icon: Bell, description: 'Alert preferences' } -]; - -export function AppSettingsDialog({ open, onOpenChange }: AppSettingsDialogProps) { - const currentSettings = useSettingsStore((state) => state.settings); - const [settings, setSettings] = useState(currentSettings); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [version, setVersion] = useState(''); - const [activeSection, setActiveSection] = useState('appearance'); - - // Auto Claude source update state - const [sourceUpdateCheck, setSourceUpdateCheck] = useState(null); - const [isCheckingSourceUpdate, setIsCheckingSourceUpdate] = useState(false); - const [isDownloadingUpdate, setIsDownloadingUpdate] = useState(false); - const [downloadProgress, setDownloadProgress] = useState(null); - - // Password visibility toggles for global API keys - const [showGlobalClaudeToken, setShowGlobalClaudeToken] = useState(false); - const [showGlobalOpenAIKey, setShowGlobalOpenAIKey] = useState(false); - - // Claude Accounts state - const [claudeProfiles, setClaudeProfiles] = useState([]); - const [activeProfileId, setActiveProfileId] = useState(null); - const [isLoadingProfiles, setIsLoadingProfiles] = useState(false); - const [newProfileName, setNewProfileName] = useState(''); - const [isAddingProfile, setIsAddingProfile] = useState(false); - const [deletingProfileId, setDeletingProfileId] = useState(null); - const [editingProfileId, setEditingProfileId] = useState(null); - const [editingProfileName, setEditingProfileName] = useState(''); - - // Load settings on mount - useEffect(() => { - loadSettings(); - window.electronAPI.getAppVersion().then(setVersion); - - // Check for auto-claude source updates - checkForSourceUpdates(); - }, []); - - // Listen for download progress - useEffect(() => { - const cleanup = window.electronAPI.onAutoBuildSourceUpdateProgress((progress) => { - setDownloadProgress(progress); - if (progress.stage === 'complete') { - setIsDownloadingUpdate(false); - // Refresh the update check - checkForSourceUpdates(); - } else if (progress.stage === 'error') { - setIsDownloadingUpdate(false); - } - }); - - return cleanup; - }, []); - - const checkForSourceUpdates = async () => { - setIsCheckingSourceUpdate(true); - try { - const result = await window.electronAPI.checkAutoBuildSourceUpdate(); - if (result.success && result.data) { - setSourceUpdateCheck(result.data); - } - } catch (err) { - console.error('Failed to check for source updates:', err); - } finally { - setIsCheckingSourceUpdate(false); - } - }; - - // Load Claude profiles when integrations section is shown - useEffect(() => { - if (activeSection === 'integrations' && open) { - loadClaudeProfiles(); - } - }, [activeSection, open]); - - // Listen for OAuth authentication completion (token is auto-saved in backend) - useEffect(() => { - const unsubscribe = window.electronAPI.onTerminalOAuthToken(async (info) => { - console.log('[AppSettings] OAuth authentication event:', { - terminalId: info.terminalId, - profileId: info.profileId, - email: info.email, - success: info.success - }); - - if (info.success && info.profileId) { - // Reload profiles to show updated state - await loadClaudeProfiles(); - // Show simple success notification (no token exposed) - alert(`✅ Profile authenticated successfully!\n\n${info.email ? `Account: ${info.email}` : 'Authentication complete.'}\n\nYou can now use this profile.`); - } else if (!info.success) { - console.log('[AppSettings] Authentication detected but not saved:', info.message); - } - }); - - return unsubscribe; - }, []); - - const loadClaudeProfiles = async () => { - setIsLoadingProfiles(true); - try { - const result = await window.electronAPI.getClaudeProfiles(); - if (result.success && result.data) { - setClaudeProfiles(result.data.profiles); - setActiveProfileId(result.data.activeProfileId); - // Also update the global store so rate limit modals see the changes - await loadGlobalClaudeProfiles(); - } - } catch (err) { - console.error('Failed to load Claude profiles:', err); - } finally { - setIsLoadingProfiles(false); - } - }; - - const handleAddProfile = async () => { - if (!newProfileName.trim()) return; - - setIsAddingProfile(true); - try { - const profileName = newProfileName.trim(); - const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); - - const result = await window.electronAPI.saveClaudeProfile({ - id: `profile-${Date.now()}`, - name: profileName, - // Use a placeholder - the backend will resolve the actual path - configDir: `~/.claude-profiles/${profileSlug}`, - isDefault: false, - createdAt: new Date() - }); - - if (result.success && result.data) { - // Initialize the profile (creates terminal and runs claude setup-token) - const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); - - if (initResult.success) { - // Reload profiles - await loadClaudeProfiles(); - setNewProfileName(''); - - // Alert the user - browser will open for OAuth - alert( - `Authenticating "${profileName}"...\n\n` + - `A browser window will open for you to log in with your Claude account.\n\n` + - `The authentication will be saved automatically once complete.` - ); - } else { - // Still reload profiles in case it partially worked - await loadClaudeProfiles(); - alert(`Failed to start authentication: ${initResult.error || 'Please try again.'}`); - } - } - } catch (err) { - console.error('Failed to add profile:', err); - alert('Failed to add profile. Please try again.'); - } finally { - setIsAddingProfile(false); - } - }; - - const handleDeleteProfile = async (profileId: string) => { - setDeletingProfileId(profileId); - try { - const result = await window.electronAPI.deleteClaudeProfile(profileId); - if (result.success) { - await loadClaudeProfiles(); - } - } catch (err) { - console.error('Failed to delete profile:', err); - } finally { - setDeletingProfileId(null); - } - }; - - const startEditingProfile = (profile: ClaudeProfile) => { - setEditingProfileId(profile.id); - setEditingProfileName(profile.name); - }; - - const cancelEditingProfile = () => { - setEditingProfileId(null); - setEditingProfileName(''); - }; - - const handleRenameProfile = async () => { - if (!editingProfileId || !editingProfileName.trim()) return; - - try { - const result = await window.electronAPI.renameClaudeProfile(editingProfileId, editingProfileName.trim()); - if (result.success) { - await loadClaudeProfiles(); - } - } catch (err) { - console.error('Failed to rename profile:', err); - } finally { - setEditingProfileId(null); - setEditingProfileName(''); - } - }; - - const handleSetActiveProfile = async (profileId: string) => { - try { - const result = await window.electronAPI.setActiveClaudeProfile(profileId); - if (result.success) { - setActiveProfileId(profileId); - // Also update the global store so other components see the change - await loadGlobalClaudeProfiles(); - } - } catch (err) { - console.error('Failed to set active profile:', err); - } - }; - - const handleDownloadSourceUpdate = () => { - setIsDownloadingUpdate(true); - setDownloadProgress(null); - window.electronAPI.downloadAutoBuildSourceUpdate(); - }; - - // Sync with store - useEffect(() => { - setSettings(currentSettings); - }, [currentSettings]); - - const handleSave = async () => { - setIsSaving(true); - setError(null); - - try { - const success = await saveSettings(settings); - if (success) { - // Apply theme immediately - applyTheme(settings.theme); - onOpenChange(false); - } else { - setError('Failed to save settings'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setIsSaving(false); - } - }; - - const applyTheme = (theme: 'light' | 'dark' | 'system') => { - if (theme === 'dark') { - document.documentElement.classList.add('dark'); - } else if (theme === 'light') { - document.documentElement.classList.remove('dark'); - } else { - // System preference - if (window.matchMedia('(prefers-color-scheme: dark)').matches) { - document.documentElement.classList.add('dark'); - } else { - document.documentElement.classList.remove('dark'); - } - } - }; - - const getThemeIcon = (theme: string) => { - switch (theme) { - case 'light': - return ; - case 'dark': - return ; - default: - return ; - } - }; - - const renderSection = () => { - switch (activeSection) { - case 'appearance': - return ( -
-
-

Appearance

-

Customize how Auto Claude looks

-
- -
-
- -

Choose your preferred color scheme

-
- {(['system', 'light', 'dark'] as const).map((theme) => ( - - ))} -
-
-
-
- ); - - case 'agent': - return ( -
-
-

Default Agent Settings

-

Configure defaults for new projects

-
- -
-
- -

The AI model used for agent tasks

- -
-
- -

The coding framework used for autonomous tasks

- -
-
-
- ); - - case 'paths': - return ( -
-
-

Paths

-

Configure executable and framework paths

-
- -
-
- -

Path to Python executable (leave empty for default)

- setSettings({ ...settings, pythonPath: e.target.value })} - /> -
-
- -

Relative path to auto-claude directory in projects

- setSettings({ ...settings, autoBuildPath: e.target.value })} - /> -
-
-
- ); - - case 'integrations': - return ( -
-
-

Integrations

-

Manage Claude accounts and API keys

-
- - - {/* Claude Accounts Section */} -
-
- -

Claude Accounts

-
- -
-

- Add multiple Claude subscriptions to automatically switch between them when you hit rate limits. -

- - {/* Accounts list */} - {isLoadingProfiles ? ( -
- -
- ) : claudeProfiles.length === 0 ? ( -
-

No accounts configured yet

-
- ) : ( -
- {claudeProfiles.map((profile) => ( -
-
-
- {(editingProfileId === profile.id ? editingProfileName : profile.name).charAt(0).toUpperCase()} -
-
- {editingProfileId === profile.id ? ( -
- setEditingProfileName(e.target.value)} - className="h-7 text-sm w-40" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleRenameProfile(); - if (e.key === 'Escape') cancelEditingProfile(); - }} - /> - - -
- ) : ( - <> -
- {profile.name} - {profile.isDefault && ( - Default - )} - {profile.id === activeProfileId && ( - - - Active - - )} - {profile.oauthToken ? ( - - - Authenticated - - ) : !profile.isDefault && ( - - Needs Auth - - )} -
- {profile.email && ( - {profile.email} - )} - - )} -
-
- {editingProfileId !== profile.id && ( -
- {profile.id !== activeProfileId && ( - - )} - {!profile.isDefault && ( - - )} - {!profile.isDefault && ( - - )} -
- )} -
- ))} -
- )} - - {/* Add new account */} -
- setNewProfileName(e.target.value)} - className="flex-1 h-8 text-sm" - onKeyDown={(e) => { - if (e.key === 'Enter' && newProfileName.trim()) { - handleAddProfile(); - } - }} - /> - -
-
-
- - {/* API Keys Section */} -
-
- -

API Keys

-
- -
-
- -

- Keys set here are used as defaults. Individual projects can override these in their settings. -

-
-
- -
-
- -

- Get your token by running claude setup-token -

-
- - setSettings({ ...settings, globalClaudeOAuthToken: e.target.value || undefined }) - } - className="pr-10 font-mono text-sm" - /> - -
-
- -
- -

- Required for Graphiti memory backend (embeddings) -

-
- - setSettings({ ...settings, globalOpenAIApiKey: e.target.value || undefined }) - } - className="pr-10 font-mono text-sm" - /> - -
-
-
-
-
- ); - - case 'updates': - return ( -
-
-

Updates

-

Manage Auto Claude framework updates

-
- -
- {/* App Version Display */} -
-
-
-

App Version

-

- {version || 'Loading...'} -

-
- -
-
- - {/* Framework Version Display */} -
-
-
-

Framework Version

-
-
- {isCheckingSourceUpdate ? ( -
- - Checking for updates... -
- ) : sourceUpdateCheck ? ( - <> -
-
-

- {sourceUpdateCheck.currentVersion} -

- {sourceUpdateCheck.latestVersion && sourceUpdateCheck.updateAvailable && ( -

- New version available: {sourceUpdateCheck.latestVersion} -

- )} -
- {sourceUpdateCheck.updateAvailable ? ( - - ) : ( - - )} -
- - {sourceUpdateCheck.error && ( -

{sourceUpdateCheck.error}

- )} - - {!sourceUpdateCheck.updateAvailable && !sourceUpdateCheck.error && ( -

- You're running the latest version of the Auto Claude framework. -

- )} - - {sourceUpdateCheck.updateAvailable && ( -
- {sourceUpdateCheck.releaseNotes && ( -
-
-                              {sourceUpdateCheck.releaseNotes}
-                            
-
- )} - - {isDownloadingUpdate ? ( -
-
- - {downloadProgress?.message || 'Downloading...'} -
- {downloadProgress?.percent !== undefined && ( - - )} -
- ) : downloadProgress?.stage === 'complete' ? ( -
- - {downloadProgress.message} -
- ) : downloadProgress?.stage === 'error' ? ( -
- - {downloadProgress.message} -
- ) : ( - - )} -
- )} - - ) : ( -
- - Unable to check for updates -
- )} - -
- -
-
- -
-
- -

- Automatically update Auto Claude in projects when a new version is available -

-
- - setSettings({ ...settings, autoUpdateAutoBuild: checked }) - } - /> -
-
-
- ); - - case 'notifications': - return ( -
-
-

Notifications

-

Configure default notification preferences

-
- -
- {[ - { key: 'onTaskComplete', label: 'On Task Complete', description: 'Notify when a task finishes successfully' }, - { key: 'onTaskFailed', label: 'On Task Failed', description: 'Notify when a task encounters an error' }, - { key: 'onReviewNeeded', label: 'On Review Needed', description: 'Notify when QA requires your review' }, - { key: 'sound', label: 'Sound', description: 'Play sound with notifications' } - ].map((item) => ( -
-
- -

{item.description}

-
- - setSettings({ - ...settings, - notifications: { - ...settings.notifications, - [item.key]: checked - } - }) - } - /> -
- ))} -
-
- ); - } - }; - - return ( - - - - - - Settings - - - Configure application-wide settings and preferences - - - - -
- {/* Navigation sidebar */} - - - {/* Main content */} -
- -
- {renderSection()} -
-
-
-
-
- - - {error && ( -
- {error} -
- )} - - -
-
-
- ); -} +export { AppSettingsDialog } from './settings'; diff --git a/auto-claude-ui/src/renderer/components/Changelog.tsx b/auto-claude-ui/src/renderer/components/Changelog.tsx index d76fac93..8868c70a 100644 --- a/auto-claude-ui/src/renderer/components/Changelog.tsx +++ b/auto-claude-ui/src/renderer/components/Changelog.tsx @@ -1,1720 +1,2 @@ -import { useEffect, useState, useCallback } from 'react'; -import { - FileText, - RefreshCw, - Copy, - Save, - AlertCircle, - CheckCircle, - Sparkles, - ChevronDown, - ChevronUp, - ArrowRight, - ArrowLeft, - Check, - Archive, - Github, - ExternalLink, - PartyPopper, - GitBranch, - GitCommit, - History, - Tag, - Calendar, - Loader2 -} from 'lucide-react'; -import { Button } from './ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Textarea } from './ui/textarea'; -import { Checkbox } from './ui/checkbox'; -import { Badge } from './ui/badge'; -import { Progress } from './ui/progress'; -import { ScrollArea } from './ui/scroll-area'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from './ui/select'; -import { RadioGroup, RadioGroupItem } from './ui/radio-group'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from './ui/tooltip'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger -} from './ui/collapsible'; -import { useProjectStore } from '../stores/project-store'; -import { loadTasks } from '../stores/task-store'; -import { - useChangelogStore, - loadChangelogData, - loadGitData, - loadCommitsPreview, - generateChangelog, - saveChangelog, - copyChangelogToClipboard, - canGenerate as canGenerateSelector -} from '../stores/changelog-store'; -import { - CHANGELOG_FORMAT_LABELS, - CHANGELOG_FORMAT_DESCRIPTIONS, - CHANGELOG_AUDIENCE_LABELS, - CHANGELOG_AUDIENCE_DESCRIPTIONS, - CHANGELOG_STAGE_LABELS, - CHANGELOG_SOURCE_MODE_LABELS, - CHANGELOG_SOURCE_MODE_DESCRIPTIONS -} from '../../shared/constants'; -import type { - ChangelogFormat, - ChangelogAudience, - ChangelogTask, - ChangelogSourceMode, - GitBranchInfo, - GitTagInfo, - GitCommit as GitCommitType -} from '../../shared/types'; -import { cn } from '../lib/utils'; - -type WizardStep = 1 | 2 | 3; - -export function Changelog() { - const selectedProjectId = useProjectStore((state) => state.selectedProjectId); - - // Data state - const doneTasks = useChangelogStore((state) => state.doneTasks); - const selectedTaskIds = useChangelogStore((state) => state.selectedTaskIds); - const existingChangelog = useChangelogStore((state) => state.existingChangelog); - - // Source mode state - const sourceMode = useChangelogStore((state) => state.sourceMode); - - // Git data state - const branches = useChangelogStore((state) => state.branches); - const tags = useChangelogStore((state) => state.tags); - const currentBranch = useChangelogStore((state) => state.currentBranch); - const defaultBranch = useChangelogStore((state) => state.defaultBranch); - const previewCommits = useChangelogStore((state) => state.previewCommits); - const isLoadingGitData = useChangelogStore((state) => state.isLoadingGitData); - const isLoadingCommits = useChangelogStore((state) => state.isLoadingCommits); - - // Git history options state - const gitHistoryType = useChangelogStore((state) => state.gitHistoryType); - const gitHistoryCount = useChangelogStore((state) => state.gitHistoryCount); - const gitHistorySinceDate = useChangelogStore((state) => state.gitHistorySinceDate); - const gitHistoryFromTag = useChangelogStore((state) => state.gitHistoryFromTag); - const gitHistoryToTag = useChangelogStore((state) => state.gitHistoryToTag); - const gitHistorySinceVersion = useChangelogStore((state) => state.gitHistorySinceVersion); - const includeMergeCommits = useChangelogStore((state) => state.includeMergeCommits); - - // Branch diff options state - const baseBranch = useChangelogStore((state) => state.baseBranch); - const compareBranch = useChangelogStore((state) => state.compareBranch); - - // Generation config state - const version = useChangelogStore((state) => state.version); - const date = useChangelogStore((state) => state.date); - const format = useChangelogStore((state) => state.format); - const audience = useChangelogStore((state) => state.audience); - const customInstructions = useChangelogStore((state) => state.customInstructions); - const generationProgress = useChangelogStore((state) => state.generationProgress); - const generatedChangelog = useChangelogStore((state) => state.generatedChangelog); - const isGenerating = useChangelogStore((state) => state.isGenerating); - const error = useChangelogStore((state) => state.error); - - // Task actions - const toggleTaskSelection = useChangelogStore((state) => state.toggleTaskSelection); - const selectAllTasks = useChangelogStore((state) => state.selectAllTasks); - const deselectAllTasks = useChangelogStore((state) => state.deselectAllTasks); - - // Source mode actions - const setSourceMode = useChangelogStore((state) => state.setSourceMode); - - // Git history options actions - const setGitHistoryType = useChangelogStore((state) => state.setGitHistoryType); - const setGitHistoryCount = useChangelogStore((state) => state.setGitHistoryCount); - const setGitHistorySinceDate = useChangelogStore((state) => state.setGitHistorySinceDate); - const setGitHistoryFromTag = useChangelogStore((state) => state.setGitHistoryFromTag); - const setGitHistoryToTag = useChangelogStore((state) => state.setGitHistoryToTag); - const setGitHistorySinceVersion = useChangelogStore((state) => state.setGitHistorySinceVersion); - const setIncludeMergeCommits = useChangelogStore((state) => state.setIncludeMergeCommits); - - // Branch diff options actions - const setBaseBranch = useChangelogStore((state) => state.setBaseBranch); - const setCompareBranch = useChangelogStore((state) => state.setCompareBranch); - - // Generation config actions - const setVersion = useChangelogStore((state) => state.setVersion); - const setDate = useChangelogStore((state) => state.setDate); - const setFormat = useChangelogStore((state) => state.setFormat); - const setAudience = useChangelogStore((state) => state.setAudience); - const setCustomInstructions = useChangelogStore((state) => state.setCustomInstructions); - const updateGeneratedChangelog = useChangelogStore((state) => state.updateGeneratedChangelog); - const setError = useChangelogStore((state) => state.setError); - const setIsGenerating = useChangelogStore((state) => state.setIsGenerating); - const setGenerationProgress = useChangelogStore((state) => state.setGenerationProgress); - const reset = useChangelogStore((state) => state.reset); - - const [step, setStep] = useState(1); - const [showAdvanced, setShowAdvanced] = useState(false); - const [saveSuccess, setSaveSuccess] = useState(false); - const [copySuccess, setCopySuccess] = useState(false); - const [versionReason, setVersionReason] = useState(null); - - // Load data when project changes - useEffect(() => { - if (selectedProjectId) { - loadChangelogData(selectedProjectId); - loadGitData(selectedProjectId); - } - }, [selectedProjectId]); - - // Load commits preview when source mode or options change - const handleLoadCommitsPreview = useCallback(() => { - if (selectedProjectId && (sourceMode === 'git-history' || sourceMode === 'branch-diff')) { - loadCommitsPreview(selectedProjectId); - } - }, [ - selectedProjectId, - sourceMode, - gitHistoryType, - gitHistoryCount, - gitHistorySinceDate, - gitHistoryFromTag, - gitHistoryToTag, - gitHistorySinceVersion, - includeMergeCommits, - baseBranch, - compareBranch - ]); - - // Set up event listeners for generation - useEffect(() => { - const cleanupProgress = window.electronAPI.onChangelogGenerationProgress( - (projectId, progress) => { - if (projectId === selectedProjectId) { - setGenerationProgress(progress); - } - } - ); - - const cleanupComplete = window.electronAPI.onChangelogGenerationComplete( - (projectId, result) => { - if (projectId === selectedProjectId) { - setIsGenerating(false); - if (result.success) { - updateGeneratedChangelog(result.changelog); - setGenerationProgress({ - stage: 'complete', - progress: 100, - message: 'Changelog generated successfully!' - }); - } else { - setError(result.error || 'Generation failed'); - } - } - } - ); - - const cleanupError = window.electronAPI.onChangelogGenerationError( - (projectId, errorMsg) => { - if (projectId === selectedProjectId) { - setIsGenerating(false); - setError(errorMsg); - setGenerationProgress({ - stage: 'error', - progress: 0, - message: errorMsg, - error: errorMsg - }); - } - } - ); - - return () => { - cleanupProgress(); - cleanupComplete(); - cleanupError(); - }; - }, [selectedProjectId]); - - const handleGenerate = () => { - if (selectedProjectId) { - generateChangelog(selectedProjectId); - } - }; - - const handleSave = async () => { - if (selectedProjectId) { - const success = await saveChangelog(selectedProjectId, 'prepend'); - if (success) { - setSaveSuccess(true); - // Move to step 3 (Release & Archive) after save - setTimeout(() => { - setSaveSuccess(false); - setStep(3); - }, 1000); - } - } - }; - - const handleCopy = () => { - const success = copyChangelogToClipboard(); - if (success) { - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 2000); - } - }; - - const handleContinue = async () => { - // Suggest version based on selected tasks before moving to step 2 - if (selectedProjectId && selectedTaskIds.length > 0) { - try { - const result = await window.electronAPI.suggestChangelogVersion( - selectedProjectId, - selectedTaskIds - ); - if (result.success && result.data) { - setVersion(result.data.version); - setVersionReason(result.data.reason); - } - } catch { - // Ignore errors - just keep the current version - setVersionReason(null); - } - } - setStep(2); - }; - - const handleBack = () => { - setStep(1); - }; - - const canGenerate = canGenerateSelector(); - const canSave = generatedChangelog.length > 0 && !isGenerating; - - // Determine if we can continue based on source mode - const canContinue = (() => { - switch (sourceMode) { - case 'tasks': - return selectedTaskIds.length > 0; - case 'git-history': - return previewCommits.length > 0; - case 'branch-diff': - return baseBranch !== '' && compareBranch !== '' && baseBranch !== compareBranch && previewCommits.length > 0; - default: - return false; - } - })(); - - if (!selectedProjectId) { - return ( -
-
- -

No Project Selected

-

- Select a project from the sidebar to generate changelogs. -

-
-
- ); - } - - return ( - -
- {/* Header */} -
-
-
-

Changelog Generator

-

- {step === 1 - ? 'Step 1: Select completed tasks to include' - : step === 2 - ? 'Step 2: Configure and generate changelog' - : 'Step 3: Release and archive tasks'} -

-
-
-
- {/* Step indicators */} -
- -
- -
- -
- -
-
- - {/* Content */} - {step === 1 && ( - - )} - {step === 2 && ( - - )} - {step === 3 && selectedProjectId && ( - { - reset(); - setStep(1); - // Reload tasks to reflect archive status in Kanban - await loadTasks(selectedProjectId); - // Then reload changelog data with fresh tasks - loadChangelogData(selectedProjectId); - }} - /> - )} -
- - ); -} - -interface StepIndicatorProps { - step: WizardStep; - currentStep: WizardStep; - label: string; -} - -function StepIndicator({ step, currentStep, label }: StepIndicatorProps) { - const isActive = step === currentStep; - const isComplete = step < currentStep; - - return ( -
-
- {isComplete ? : step} -
- - {label} - -
- ); -} - -interface Step1SourceSelectionProps { - // Source mode - sourceMode: ChangelogSourceMode; - onSourceModeChange: (mode: ChangelogSourceMode) => void; - // Task selection - doneTasks: ChangelogTask[]; - selectedTaskIds: string[]; - onToggle: (taskId: string) => void; - onSelectAll: () => void; - onDeselectAll: () => void; - // Git data - branches: GitBranchInfo[]; - tags: GitTagInfo[]; - currentBranch: string; - defaultBranch: string; - previewCommits: GitCommitType[]; - isLoadingGitData: boolean; - isLoadingCommits: boolean; - // Git history options - gitHistoryType: 'recent' | 'since-date' | 'tag-range' | 'since-version'; - gitHistoryCount: number; - gitHistorySinceDate: string; - gitHistoryFromTag: string; - gitHistoryToTag: string; - gitHistorySinceVersion: string; - includeMergeCommits: boolean; - onGitHistoryTypeChange: (type: 'recent' | 'since-date' | 'tag-range' | 'since-version') => void; - onGitHistoryCountChange: (count: number) => void; - onGitHistorySinceDateChange: (date: string) => void; - onGitHistoryFromTagChange: (tag: string) => void; - onGitHistoryToTagChange: (tag: string) => void; - onGitHistorySinceVersionChange: (version: string) => void; - onIncludeMergeCommitsChange: (include: boolean) => void; - // Branch diff options - baseBranch: string; - compareBranch: string; - onBaseBranchChange: (branch: string) => void; - onCompareBranchChange: (branch: string) => void; - // Actions - onLoadCommitsPreview: () => void; - onContinue: () => void; - canContinue: boolean; -} - -function Step1SourceSelection({ - sourceMode, - onSourceModeChange, - doneTasks, - selectedTaskIds, - onToggle, - onSelectAll, - onDeselectAll, - branches, - tags, - currentBranch, - defaultBranch, - previewCommits, - isLoadingGitData, - isLoadingCommits, - gitHistoryType, - gitHistoryCount, - gitHistorySinceDate, - gitHistoryFromTag, - gitHistoryToTag, - gitHistorySinceVersion, - includeMergeCommits, - onGitHistoryTypeChange, - onGitHistoryCountChange, - onGitHistorySinceDateChange, - onGitHistoryFromTagChange, - onGitHistoryToTagChange, - onGitHistorySinceVersionChange, - onIncludeMergeCommitsChange, - baseBranch, - compareBranch, - onBaseBranchChange, - onCompareBranchChange, - onLoadCommitsPreview, - onContinue, - canContinue -}: Step1SourceSelectionProps) { - const localBranches = branches.filter((b) => !b.isRemote); - - // Get summary text for footer badge - const getSummaryCount = () => { - switch (sourceMode) { - case 'tasks': - return selectedTaskIds.length; - case 'git-history': - case 'branch-diff': - return previewCommits.length; - default: - return 0; - } - }; - - const getSummaryLabel = () => { - switch (sourceMode) { - case 'tasks': - return 'task'; - case 'git-history': - case 'branch-diff': - return 'commit'; - default: - return 'item'; - } - }; - - return ( -
- {/* Left Panel - Source Mode Selection */} -
-
- {/* Source Mode Selection */} -
- - onSourceModeChange(value as ChangelogSourceMode)} - className="space-y-2" - > - - - - - - -
- - {/* Git History Options */} - {sourceMode === 'git-history' && ( - - - Git History Options - - - {/* History Type */} -
- - -
- - {/* Type-specific options */} - {gitHistoryType === 'recent' && ( -
- - onGitHistoryCountChange(parseInt(e.target.value) || 25)} - /> -
- )} - - {gitHistoryType === 'since-date' && ( -
- - onGitHistorySinceDateChange(e.target.value)} - /> -
- )} - - {gitHistoryType === 'tag-range' && ( - <> -
- - -
-
- - -
- - )} - - {gitHistoryType === 'since-version' && ( -
- - -

- All commits since this version will be included -

-
- )} - - {/* Include merge commits */} -
- onIncludeMergeCommitsChange(checked as boolean)} - /> - -
- - {/* Load Preview Button */} - -
-
- )} - - {/* Branch Diff Options */} - {sourceMode === 'branch-diff' && ( - - - Branch Comparison - - -
- - -

- The branch you're merging into -

-
- -
- - -

- The branch with your changes -

-
- - {baseBranch && compareBranch && baseBranch === compareBranch && ( -
- - Branches must be different -
- )} - - {/* Load Preview Button */} - -
-
- )} -
-
- - {/* Right Panel - Content Area */} -
- {/* Tasks Mode - Task Selection */} - {sourceMode === 'tasks' && ( - <> - {/* Task selection header */} -
-
- - {selectedTaskIds.length} of {doneTasks.length} tasks selected - -
- - -
-
-
- - {/* Task grid */} - - {doneTasks.length === 0 ? ( -
-
- -

No Completed Tasks

-

- Complete tasks in the Kanban board and mark them as "Done" to include them in your changelog. -

-
-
- ) : ( -
- {doneTasks.map((task) => ( - onToggle(task.id)} - /> - ))} -
- )} -
- - )} - - {/* Git History / Branch Diff Mode - Commit Preview */} - {(sourceMode === 'git-history' || sourceMode === 'branch-diff') && ( - <> - {/* Commit preview header */} -
-
- - {previewCommits.length} commit{previewCommits.length !== 1 ? 's' : ''} found - - {isLoadingCommits && ( - - )} -
-
- - {/* Commit list */} - - {isLoadingCommits ? ( -
-
- -

Loading commits...

-
-
- ) : previewCommits.length === 0 ? ( -
-
- -

No Commits Found

-

- {sourceMode === 'git-history' - ? 'Configure the history options and click "Load Commits" to preview.' - : 'Select both branches and click "Load Commits" to see the changes.'} -

-
-
- ) : ( -
- {previewCommits.map((commit) => ( - - ))} -
- )} -
- - )} - - {/* Footer with Continue button */} -
- -
-
-
- ); -} - -interface CommitCardProps { - commit: GitCommitType; -} - -function CommitCard({ commit }: CommitCardProps) { - const commitDate = new Date(commit.date).toLocaleDateString(); - - return ( -
-
- -
-
-
-

{commit.subject}

- {commit.hash} -
-
- {commit.author} - {commitDate} - {commit.filesChanged !== undefined && ( - - {commit.filesChanged} file{commit.filesChanged !== 1 ? 's' : ''} - - )} -
-
-
- ); -} - -interface TaskCardProps { - task: ChangelogTask; - isSelected: boolean; - onToggle: () => void; -} - -function TaskCard({ task, isSelected, onToggle }: TaskCardProps) { - const completedDate = new Date(task.completedAt).toLocaleDateString(); - - return ( - - ); -} - -interface Step2Props { - sourceMode: ChangelogSourceMode; - selectedTaskIds: string[]; - doneTasks: ChangelogTask[]; - previewCommits: GitCommitType[]; - existingChangelog: { lastVersion?: string } | null; - version: string; - versionReason: string | null; - date: string; - format: ChangelogFormat; - audience: ChangelogAudience; - customInstructions: string; - generationProgress: { stage: string; progress: number; message?: string; error?: string } | null; - generatedChangelog: string; - isGenerating: boolean; - error: string | null; - showAdvanced: boolean; - saveSuccess: boolean; - copySuccess: boolean; - canGenerate: boolean; - canSave: boolean; - onBack: () => void; - onVersionChange: (v: string) => void; - onDateChange: (d: string) => void; - onFormatChange: (f: ChangelogFormat) => void; - onAudienceChange: (a: ChangelogAudience) => void; - onCustomInstructionsChange: (i: string) => void; - onShowAdvancedChange: (show: boolean) => void; - onGenerate: () => void; - onSave: () => void; - onCopy: () => void; - onChangelogEdit: (content: string) => void; -} - -function Step2ConfigureGenerate({ - sourceMode, - selectedTaskIds, - doneTasks, - previewCommits, - existingChangelog, - version, - versionReason, - date, - format, - audience, - customInstructions, - generationProgress, - generatedChangelog, - isGenerating, - error, - showAdvanced, - saveSuccess, - copySuccess, - canGenerate, - canSave, - onBack, - onVersionChange, - onDateChange, - onFormatChange, - onAudienceChange, - onCustomInstructionsChange, - onShowAdvancedChange, - onGenerate, - onSave, - onCopy, - onChangelogEdit -}: Step2Props) { - const selectedTasks = doneTasks.filter((t) => selectedTaskIds.includes(t.id)); - - // Get summary info based on source mode - const getSummaryInfo = () => { - switch (sourceMode) { - case 'tasks': - return { - count: selectedTaskIds.length, - label: 'task', - details: selectedTasks.slice(0, 3).map((t) => t.title).join(', ') + - (selectedTasks.length > 3 ? ` +${selectedTasks.length - 3} more` : '') - }; - case 'git-history': - case 'branch-diff': - return { - count: previewCommits.length, - label: 'commit', - details: previewCommits.slice(0, 3).map((c) => c.subject.substring(0, 40)).join(', ') + - (previewCommits.length > 3 ? ` +${previewCommits.length - 3} more` : '') - }; - default: - return { count: 0, label: 'item', details: '' }; - } - }; - - const summaryInfo = getSummaryInfo(); - - return ( -
- {/* Left Panel - Configuration */} -
-
- {/* Back button and task summary */} -
- -
-
- {sourceMode === 'tasks' ? ( - - ) : ( - - )} - Including {summaryInfo.count} {summaryInfo.label}{summaryInfo.count !== 1 ? 's' : ''} -
-
- {summaryInfo.details} -
-
-
- - {/* Version & Date */} - - - Release Info - - -
- - onVersionChange(e.target.value)} - placeholder="1.0.0" - /> -
-
- - onDateChange(e.target.value)} - /> -
- {(existingChangelog?.lastVersion || versionReason) && ( -
- {existingChangelog?.lastVersion && ( -

Previous: {existingChangelog.lastVersion}

- )} - {versionReason && ( -

- {versionReason === 'breaking' - ? 'Major version bump (breaking changes detected)' - : versionReason === 'feature' - ? 'Minor version bump (new features detected)' - : 'Patch version bump (fixes/improvements)'} -

- )} -
- )} -
-
- - {/* Format & Audience */} - - - Output Style - - -
- - -
- -
- - -
-
-
- - {/* Advanced Options */} - - - - - - - -
- -