From 11fcdf42f3652555dc79f12661e19c97551cea51 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Tue, 16 Dec 2025 21:37:12 +0100 Subject: [PATCH] Big backend refactor for upcoming features --- CLAUDE.md | 13 +- .../src/renderer/components/GitHubIssues.tsx | 2 +- .../renderer/components/ProjectSettings.tsx | 2 +- auto-claude-ui/src/renderer/hooks/index.ts | 2 +- .../src/renderer/hooks/useClaudeAuth.ts | 2 +- .../renderer/hooks/useEnvironmentConfig.ts | 2 +- .../src/renderer/hooks/useGitHubConnection.ts | 2 +- .../renderer/hooks/useInfrastructureStatus.ts | 2 +- .../src/renderer/hooks/useLinearConnection.ts | 2 +- .../src/renderer/hooks/useProjectSettings.ts | 2 +- .../renderer/lib/mocks/claude-profile-mock.ts | 11 +- auto-claude/__init__.py | 9 + auto-claude/agent.py | 65 +- auto-claude/agents/__init__.py | 2 +- auto-claude/agents/auto_claude_tools.py | 62 + auto-claude/agents/coder.py | 2 +- .../agents/{memory.py => memory_manager.py} | 4 + auto-claude/agents/session.py | 2 +- .../tools_pkg}/__init__.py | 0 .../tools_pkg}/models.py | 0 .../tools_pkg}/permissions.py | 0 .../tools_pkg}/registry.py | 0 .../tools_pkg}/tools/__init__.py | 0 .../tools_pkg}/tools/memory.py | 0 .../tools_pkg}/tools/progress.py | 0 .../tools_pkg}/tools/qa.py | 0 .../tools_pkg}/tools/subtask.py | 0 auto-claude/analysis/__init__.py | 36 + auto-claude/analysis/analyzer.py | 100 + auto-claude/analysis/analyzers/__init__.py | 92 + auto-claude/{ => analysis}/analyzers/base.py | 0 .../analyzers/context/__init__.py | 0 .../analyzers/context/api_docs_detector.py | 0 .../analyzers/context/auth_detector.py | 0 .../analyzers/context/env_detector.py | 0 .../analyzers/context/jobs_detector.py | 0 .../analyzers/context/migrations_detector.py | 0 .../analyzers/context/monitoring_detector.py | 0 .../analyzers/context/services_detector.py | 0 .../analyzers/context_analyzer.py | 0 .../analyzers/database_detector.py | 0 .../analyzers/framework_analyzer.py | 0 .../{ => analysis}/analyzers/port_detector.py | 0 .../analyzers/project_analyzer_module.py | 0 .../analyzers/route_detector.py | 0 .../analyzers/service_analyzer.py | 0 auto-claude/analysis/ci_discovery.py | 587 ++++ auto-claude/analysis/insight_extractor.py | 584 ++++ auto-claude/analysis/project_analyzer.py | 106 + auto-claude/analysis/risk_classifier.py | 589 ++++ auto-claude/analysis/security_scanner.py | 597 ++++ auto-claude/analysis/test_discovery.py | 688 +++++ auto-claude/analyzer.py | 102 +- auto-claude/analyzers/__init__.py | 94 +- auto-claude/auto_claude_tools.py | 52 +- auto-claude/ci_discovery.py | 607 +---- auto-claude/client.py | 329 +-- auto-claude/{context.py => context/main.py} | 0 auto-claude/core/__init__.py | 36 + auto-claude/core/agent.py | 63 + auto-claude/core/client.py | 315 +++ auto-claude/core/debug.py | 349 +++ auto-claude/core/progress.py | 467 ++++ .../workspace.py} | 881 ++---- auto-claude/{ => core}/workspace/README.md | 0 auto-claude/{ => core}/workspace/__init__.py | 1 + auto-claude/{ => core}/workspace/display.py | 0 .../{ => core}/workspace/finalization.py | 0 auto-claude/{ => core}/workspace/git_utils.py | 0 auto-claude/{ => core}/workspace/models.py | 0 auto-claude/{ => core}/workspace/setup.py | 0 auto-claude/core/worktree.py | 561 ++++ auto-claude/critique.py | 371 +-- auto-claude/debug.py | 351 +-- auto-claude/graphiti_config.py | 531 +--- auto-claude/graphiti_providers.py | 72 +- auto-claude/graphiti_providers.py.bak | 705 ----- auto-claude/ideation_runner_old.py.bak | 1122 -------- auto-claude/implementation_plan.py | 171 +- auto-claude/implementation_plan/main.py | 168 ++ auto-claude/insight_extractor.py | 613 +---- auto-claude/integrations/__init__.py | 11 + auto-claude/integrations/graphiti/__init__.py | 35 + auto-claude/integrations/graphiti/config.py | 547 ++++ .../graphiti/memory.py} | 0 .../integrations/graphiti/providers.py | 70 + .../graphiti/providers_pkg}/__init__.py | 0 .../graphiti/providers_pkg}/cross_encoder.py | 0 .../embedder_providers/__init__.py | 0 .../azure_openai_embedder.py | 0 .../embedder_providers/ollama_embedder.py | 0 .../embedder_providers/openai_embedder.py | 0 .../embedder_providers/voyage_embedder.py | 0 .../graphiti/providers_pkg}/exceptions.py | 0 .../graphiti/providers_pkg}/factory.py | 0 .../providers_pkg}/llm_providers/__init__.py | 0 .../llm_providers/anthropic_llm.py | 0 .../llm_providers/azure_openai_llm.py | 0 .../llm_providers/ollama_llm.py | 0 .../llm_providers/openai_llm.py | 0 .../graphiti/providers_pkg}/models.py | 0 .../graphiti/providers_pkg}/utils.py | 0 .../graphiti/providers_pkg}/validators.py | 0 .../graphiti/queries_pkg}/__init__.py | 0 .../graphiti/queries_pkg}/client.py | 0 .../graphiti/queries_pkg}/graphiti.py | 0 .../graphiti/queries_pkg}/queries.py | 32 +- .../graphiti/queries_pkg}/schema.py | 0 .../graphiti/queries_pkg}/search.py | 0 .../graphiti}/test_graphiti_memory.py | 0 auto-claude/integrations/linear/__init__.py | 42 + auto-claude/integrations/linear/config.py | 342 +++ .../integrations/linear/integration.py | 553 ++++ auto-claude/integrations/linear/updater.py | 442 +++ auto-claude/linear_config.py | 344 +-- auto-claude/linear_integration.py | 555 +--- auto-claude/linear_updater.py | 444 +-- auto-claude/{memory.py => memory/main.py} | 0 auto-claude/merge/file_evolution/storage.py | 5 +- .../{planner.py => planner_lib/main.py} | 0 .../{prediction.py => prediction/main.py} | 0 auto-claude/prediction_old.py | 710 ----- auto-claude/progress.py | 469 +--- auto-claude/prompt_generator.py | 380 +-- auto-claude/prompts.py | 265 +- auto-claude/prompts_pkg/__init__.py | 39 + auto-claude/prompts_pkg/prompt_generator.py | 378 +++ auto-claude/prompts_pkg/prompts.py | 263 ++ auto-claude/qa/qa_loop.py | 97 + auto-claude/qa_loop.py | 79 +- auto-claude/recovery.py | 622 +---- auto-claude/{review.py => review/main.py} | 0 auto-claude/risk_classifier.py | 619 +---- auto-claude/runners/__init__.py | 21 + .../{ => runners}/ai_analyzer/EXAMPLES.md | 0 .../{ => runners}/ai_analyzer/README.md | 0 .../{ => runners}/ai_analyzer/REFACTORING.md | 0 .../{ => runners}/ai_analyzer/__init__.py | 0 .../{ => runners}/ai_analyzer/analyzers.py | 0 .../ai_analyzer/cache_manager.py | 0 .../ai_analyzer/claude_client.py | 0 .../ai_analyzer/cost_estimator.py | 0 .../{ => runners}/ai_analyzer/models.py | 0 .../ai_analyzer/result_parser.py | 0 .../{ => runners}/ai_analyzer/runner.py | 0 .../ai_analyzer/summary_printer.py | 0 .../{ => runners}/ai_analyzer_runner.py | 0 auto-claude/{ => runners}/ideation_runner.py | 0 auto-claude/{ => runners}/insights_runner.py | 0 auto-claude/{ => runners}/roadmap/__init__.py | 0 .../roadmap/competitor_analyzer.py | 0 auto-claude/{ => runners}/roadmap/executor.py | 0 .../roadmap/graph_integration.py | 0 auto-claude/{ => runners}/roadmap/models.py | 0 .../{ => runners}/roadmap/orchestrator.py | 0 auto-claude/{ => runners}/roadmap/phases.py | 0 .../{ => runners}/roadmap/project_index.json | 0 auto-claude/{ => runners}/roadmap_runner.py | 0 auto-claude/{ => runners}/spec_runner.py | 0 auto-claude/scan_secrets.py | 563 +--- auto-claude/security.py | 96 +- auto-claude/security/main.py | 94 + auto-claude/security/scan_secrets.py | 561 ++++ auto-claude/security_scanner.py | 599 +--- auto-claude/service_orchestrator.py | 628 +---- auto-claude/services/__init__.py | 16 + .../context.py} | 0 auto-claude/services/orchestrator.py | 610 +++++ auto-claude/services/recovery.py | 606 ++++ auto-claude/spec/critique.py | 369 +++ auto-claude/spec/phases/planning_phases.py | 2 +- auto-claude/spec/pipeline/__init__.py | 2 + auto-claude/spec/pipeline/orchestrator.py | 40 +- .../validate_pkg}/MIGRATION.md | 0 .../validate_pkg}/README.md | 0 auto-claude/spec/validate_pkg/__init__.py | 19 + auto-claude/spec/validate_pkg/auto_fix.py | 80 + .../validate_pkg}/models.py | 0 .../validate_pkg}/schemas.py | 0 .../spec/validate_pkg/spec_validator.py | 80 + .../validate_pkg}/validators/__init__.py | 0 .../validators/context_validator.py | 0 .../implementation_plan_validator.py | 0 .../validators/prereqs_validator.py | 0 .../validators/spec_document_validator.py | 0 auto-claude/{ => spec}/validate_spec.py | 2 +- auto-claude/spec/validation_strategy.py | 1033 +++++++ auto-claude/task_logger.py.backup | 818 ------ .../{task_logger.py => task_logger/main.py} | 0 auto-claude/test_discovery.py | 702 +---- auto-claude/{ui.py => ui/main.py} | 0 auto-claude/{ => ui}/statusline.py | 0 auto-claude/validate_spec.py.backup | 633 ----- auto-claude/validate_spec/__init__.py | 48 +- auto-claude/validate_spec/auto_fix.py | 100 +- auto-claude/validate_spec/spec_validator.py | 100 +- auto-claude/validation_strategy.py | 1035 +------ auto-claude/workspace.py | 2428 +---------------- auto-claude/worktree.py | 591 +--- tests/conftest.py | 164 +- tests/test_agent_architecture.py | 17 +- tests/test_fixtures.py | 112 + tests/test_graphiti.py | 25 +- tests/test_merge_file_tracker.py | 4 +- tests/test_merge_orchestrator.py | 31 +- tests/test_merge_parallel.py | 4 +- tests/test_merge_semantic_analyzer.py | 4 +- tests/test_qa_loop.py | 28 +- tests/test_spec_pipeline.py | 2 +- 209 files changed, 12675 insertions(+), 18463 deletions(-) create mode 100644 auto-claude/__init__.py create mode 100644 auto-claude/agents/auto_claude_tools.py rename auto-claude/agents/{memory.py => memory_manager.py} (99%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/__init__.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/models.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/permissions.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/registry.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/tools/__init__.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/tools/memory.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/tools/progress.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/tools/qa.py (100%) rename auto-claude/{auto_claude_tools => agents/tools_pkg}/tools/subtask.py (100%) create mode 100644 auto-claude/analysis/__init__.py create mode 100644 auto-claude/analysis/analyzer.py create mode 100644 auto-claude/analysis/analyzers/__init__.py rename auto-claude/{ => analysis}/analyzers/base.py (100%) rename auto-claude/{ => analysis}/analyzers/context/__init__.py (100%) rename auto-claude/{ => analysis}/analyzers/context/api_docs_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/auth_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/env_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/jobs_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/migrations_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/monitoring_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context/services_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/context_analyzer.py (100%) rename auto-claude/{ => analysis}/analyzers/database_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/framework_analyzer.py (100%) rename auto-claude/{ => analysis}/analyzers/port_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/project_analyzer_module.py (100%) rename auto-claude/{ => analysis}/analyzers/route_detector.py (100%) rename auto-claude/{ => analysis}/analyzers/service_analyzer.py (100%) create mode 100644 auto-claude/analysis/ci_discovery.py create mode 100644 auto-claude/analysis/insight_extractor.py create mode 100644 auto-claude/analysis/project_analyzer.py create mode 100644 auto-claude/analysis/risk_classifier.py create mode 100644 auto-claude/analysis/security_scanner.py create mode 100644 auto-claude/analysis/test_discovery.py rename auto-claude/{context.py => context/main.py} (100%) create mode 100644 auto-claude/core/__init__.py create mode 100644 auto-claude/core/agent.py create mode 100644 auto-claude/core/client.py create mode 100644 auto-claude/core/debug.py create mode 100644 auto-claude/core/progress.py rename auto-claude/{workspace_original.py => core/workspace.py} (78%) rename auto-claude/{ => core}/workspace/README.md (100%) rename auto-claude/{ => core}/workspace/__init__.py (98%) rename auto-claude/{ => core}/workspace/display.py (100%) rename auto-claude/{ => core}/workspace/finalization.py (100%) rename auto-claude/{ => core}/workspace/git_utils.py (100%) rename auto-claude/{ => core}/workspace/models.py (100%) rename auto-claude/{ => core}/workspace/setup.py (100%) create mode 100644 auto-claude/core/worktree.py delete mode 100644 auto-claude/graphiti_providers.py.bak delete mode 100644 auto-claude/ideation_runner_old.py.bak create mode 100644 auto-claude/implementation_plan/main.py create mode 100644 auto-claude/integrations/__init__.py create mode 100644 auto-claude/integrations/graphiti/__init__.py create mode 100644 auto-claude/integrations/graphiti/config.py rename auto-claude/{graphiti_memory.py => integrations/graphiti/memory.py} (100%) create mode 100644 auto-claude/integrations/graphiti/providers.py rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/__init__.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/cross_encoder.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/embedder_providers/__init__.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/embedder_providers/azure_openai_embedder.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/embedder_providers/ollama_embedder.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/embedder_providers/openai_embedder.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/embedder_providers/voyage_embedder.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/exceptions.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/factory.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/llm_providers/__init__.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/llm_providers/anthropic_llm.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/llm_providers/azure_openai_llm.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/llm_providers/ollama_llm.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/llm_providers/openai_llm.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/models.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/utils.py (100%) rename auto-claude/{graphiti_providers => integrations/graphiti/providers_pkg}/validators.py (100%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/__init__.py (100%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/client.py (100%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/graphiti.py (100%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/queries.py (90%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/schema.py (100%) rename auto-claude/{graphiti => integrations/graphiti/queries_pkg}/search.py (100%) rename auto-claude/{ => integrations/graphiti}/test_graphiti_memory.py (100%) create mode 100644 auto-claude/integrations/linear/__init__.py create mode 100644 auto-claude/integrations/linear/config.py create mode 100644 auto-claude/integrations/linear/integration.py create mode 100644 auto-claude/integrations/linear/updater.py rename auto-claude/{memory.py => memory/main.py} (100%) rename auto-claude/{planner.py => planner_lib/main.py} (100%) rename auto-claude/{prediction.py => prediction/main.py} (100%) delete mode 100644 auto-claude/prediction_old.py create mode 100644 auto-claude/prompts_pkg/__init__.py create mode 100644 auto-claude/prompts_pkg/prompt_generator.py create mode 100644 auto-claude/prompts_pkg/prompts.py create mode 100644 auto-claude/qa/qa_loop.py rename auto-claude/{review.py => review/main.py} (100%) create mode 100644 auto-claude/runners/__init__.py rename auto-claude/{ => runners}/ai_analyzer/EXAMPLES.md (100%) rename auto-claude/{ => runners}/ai_analyzer/README.md (100%) rename auto-claude/{ => runners}/ai_analyzer/REFACTORING.md (100%) rename auto-claude/{ => runners}/ai_analyzer/__init__.py (100%) rename auto-claude/{ => runners}/ai_analyzer/analyzers.py (100%) rename auto-claude/{ => runners}/ai_analyzer/cache_manager.py (100%) rename auto-claude/{ => runners}/ai_analyzer/claude_client.py (100%) rename auto-claude/{ => runners}/ai_analyzer/cost_estimator.py (100%) rename auto-claude/{ => runners}/ai_analyzer/models.py (100%) rename auto-claude/{ => runners}/ai_analyzer/result_parser.py (100%) rename auto-claude/{ => runners}/ai_analyzer/runner.py (100%) rename auto-claude/{ => runners}/ai_analyzer/summary_printer.py (100%) rename auto-claude/{ => runners}/ai_analyzer_runner.py (100%) rename auto-claude/{ => runners}/ideation_runner.py (100%) rename auto-claude/{ => runners}/insights_runner.py (100%) rename auto-claude/{ => runners}/roadmap/__init__.py (100%) rename auto-claude/{ => runners}/roadmap/competitor_analyzer.py (100%) rename auto-claude/{ => runners}/roadmap/executor.py (100%) rename auto-claude/{ => runners}/roadmap/graph_integration.py (100%) rename auto-claude/{ => runners}/roadmap/models.py (100%) rename auto-claude/{ => runners}/roadmap/orchestrator.py (100%) rename auto-claude/{ => runners}/roadmap/phases.py (100%) rename auto-claude/{ => runners}/roadmap/project_index.json (100%) rename auto-claude/{ => runners}/roadmap_runner.py (100%) rename auto-claude/{ => runners}/spec_runner.py (100%) create mode 100644 auto-claude/security/main.py create mode 100644 auto-claude/security/scan_secrets.py create mode 100644 auto-claude/services/__init__.py rename auto-claude/{service_context.py => services/context.py} (100%) create mode 100644 auto-claude/services/orchestrator.py create mode 100644 auto-claude/services/recovery.py create mode 100644 auto-claude/spec/critique.py rename auto-claude/{validate_spec => spec/validate_pkg}/MIGRATION.md (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/README.md (100%) create mode 100644 auto-claude/spec/validate_pkg/__init__.py create mode 100644 auto-claude/spec/validate_pkg/auto_fix.py rename auto-claude/{validate_spec => spec/validate_pkg}/models.py (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/schemas.py (100%) create mode 100644 auto-claude/spec/validate_pkg/spec_validator.py rename auto-claude/{validate_spec => spec/validate_pkg}/validators/__init__.py (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/validators/context_validator.py (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/validators/implementation_plan_validator.py (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/validators/prereqs_validator.py (100%) rename auto-claude/{validate_spec => spec/validate_pkg}/validators/spec_document_validator.py (100%) rename auto-claude/{ => spec}/validate_spec.py (98%) create mode 100644 auto-claude/spec/validation_strategy.py delete mode 100644 auto-claude/task_logger.py.backup rename auto-claude/{task_logger.py => task_logger/main.py} (100%) rename auto-claude/{ui.py => ui/main.py} (100%) rename auto-claude/{ => ui}/statusline.py (100%) delete mode 100644 auto-claude/validate_spec.py.backup create mode 100644 tests/test_fixtures.py diff --git a/CLAUDE.md b/CLAUDE.md index ba831a27..e16d6cb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,17 +60,20 @@ python auto-claude/run.py --spec 001 --qa-status ### Testing ```bash -# Run all tests -pytest tests/ -v +# Install test dependencies (required first time) +cd auto-claude && uv pip install -r ../tests/requirements-test.txt + +# Run all tests (use virtual environment pytest) +auto-claude/.venv/bin/pytest tests/ -v # Run single test file -pytest tests/test_security.py -v +auto-claude/.venv/bin/pytest tests/test_security.py -v # Run specific test -pytest tests/test_security.py::test_bash_command_validation -v +auto-claude/.venv/bin/pytest tests/test_security.py::test_bash_command_validation -v # Skip slow tests -pytest tests/ -m "not slow" +auto-claude/.venv/bin/pytest tests/ -m "not slow" ``` ### Spec Validation diff --git a/auto-claude-ui/src/renderer/components/GitHubIssues.tsx b/auto-claude-ui/src/renderer/components/GitHubIssues.tsx index ea3bd466..1a5193fc 100644 --- a/auto-claude-ui/src/renderer/components/GitHubIssues.tsx +++ b/auto-claude-ui/src/renderer/components/GitHubIssues.tsx @@ -75,7 +75,7 @@ export function GitHubIssues({ onOpenSettings }: GitHubIssuesProps) {
{/* Header */} setSettings({ ...settings, ...updates })} infrastructureStatus={infrastructureStatus} isCheckingInfrastructure={isCheckingInfrastructure} isStartingFalkorDB={isStartingFalkorDB} diff --git a/auto-claude-ui/src/renderer/hooks/index.ts b/auto-claude-ui/src/renderer/hooks/index.ts index 0182ee69..b2ffad81 100644 --- a/auto-claude-ui/src/renderer/hooks/index.ts +++ b/auto-claude-ui/src/renderer/hooks/index.ts @@ -5,5 +5,5 @@ export { useClaudeAuth } from './useClaudeAuth'; export { useLinearConnection } from './useLinearConnection'; export { useGitHubConnection } from './useGitHubConnection'; export { useInfrastructureStatus } from './useInfrastructureStatus'; -export { useIpc } from './useIpc'; +export { useIpcListeners } from './useIpc'; export { useVirtualizedTree } from './useVirtualizedTree'; diff --git a/auto-claude-ui/src/renderer/hooks/useClaudeAuth.ts b/auto-claude-ui/src/renderer/hooks/useClaudeAuth.ts index 05f93551..3ab94031 100644 --- a/auto-claude-ui/src/renderer/hooks/useClaudeAuth.ts +++ b/auto-claude-ui/src/renderer/hooks/useClaudeAuth.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { ProjectEnvConfig } from '../../../shared/types'; +import type { ProjectEnvConfig } from '../../shared/types'; type AuthStatus = 'checking' | 'authenticated' | 'not_authenticated' | 'error'; diff --git a/auto-claude-ui/src/renderer/hooks/useEnvironmentConfig.ts b/auto-claude-ui/src/renderer/hooks/useEnvironmentConfig.ts index 6526c810..a25cccc4 100644 --- a/auto-claude-ui/src/renderer/hooks/useEnvironmentConfig.ts +++ b/auto-claude-ui/src/renderer/hooks/useEnvironmentConfig.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { ProjectEnvConfig } from '../../../shared/types'; +import type { ProjectEnvConfig } from '../../shared/types'; export function useEnvironmentConfig(projectId: string, autoBuildPath: string | null, open: boolean) { const [envConfig, setEnvConfig] = useState(null); diff --git a/auto-claude-ui/src/renderer/hooks/useGitHubConnection.ts b/auto-claude-ui/src/renderer/hooks/useGitHubConnection.ts index 9a872814..d7d5e791 100644 --- a/auto-claude-ui/src/renderer/hooks/useGitHubConnection.ts +++ b/auto-claude-ui/src/renderer/hooks/useGitHubConnection.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { GitHubSyncStatus } from '../../../shared/types'; +import type { GitHubSyncStatus } from '../../shared/types'; export function useGitHubConnection( projectId: string, diff --git a/auto-claude-ui/src/renderer/hooks/useInfrastructureStatus.ts b/auto-claude-ui/src/renderer/hooks/useInfrastructureStatus.ts index 7b4c5bd9..be724229 100644 --- a/auto-claude-ui/src/renderer/hooks/useInfrastructureStatus.ts +++ b/auto-claude-ui/src/renderer/hooks/useInfrastructureStatus.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { InfrastructureStatus } from '../../../shared/types'; +import type { InfrastructureStatus } from '../../shared/types'; export function useInfrastructureStatus( graphitiEnabled: boolean | undefined, diff --git a/auto-claude-ui/src/renderer/hooks/useLinearConnection.ts b/auto-claude-ui/src/renderer/hooks/useLinearConnection.ts index b89ccdcf..84579e49 100644 --- a/auto-claude-ui/src/renderer/hooks/useLinearConnection.ts +++ b/auto-claude-ui/src/renderer/hooks/useLinearConnection.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { LinearSyncStatus } from '../../../shared/types'; +import type { LinearSyncStatus } from '../../shared/types'; export function useLinearConnection( projectId: string, diff --git a/auto-claude-ui/src/renderer/hooks/useProjectSettings.ts b/auto-claude-ui/src/renderer/hooks/useProjectSettings.ts index 053cbbcb..7447bb2c 100644 --- a/auto-claude-ui/src/renderer/hooks/useProjectSettings.ts +++ b/auto-claude-ui/src/renderer/hooks/useProjectSettings.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import type { Project, ProjectSettings, AutoBuildVersionInfo, ProjectEnvConfig } from '../../../shared/types'; +import type { Project, ProjectSettings, AutoBuildVersionInfo, ProjectEnvConfig } from '../../shared/types'; import { checkProjectVersion } from '../stores/project-store'; export function useProjectSettings(project: Project, open: boolean) { diff --git a/auto-claude-ui/src/renderer/lib/mocks/claude-profile-mock.ts b/auto-claude-ui/src/renderer/lib/mocks/claude-profile-mock.ts index 72650329..2ae8432f 100644 --- a/auto-claude-ui/src/renderer/lib/mocks/claude-profile-mock.ts +++ b/auto-claude-ui/src/renderer/lib/mocks/claude-profile-mock.ts @@ -11,9 +11,16 @@ export const claudeProfileMock = { } }), - saveClaudeProfile: async (profile: unknown) => ({ + saveClaudeProfile: async (profile: { id: string; name: string; oauthToken?: string; email?: string; isDefault?: boolean; createdAt?: Date }) => ({ success: true, - data: profile + data: { + id: profile.id, + name: profile.name, + oauthToken: profile.oauthToken, + email: profile.email, + isDefault: profile.isDefault ?? false, + createdAt: profile.createdAt ?? new Date(), + } }), deleteClaudeProfile: async () => ({ success: true }), diff --git a/auto-claude/__init__.py b/auto-claude/__init__.py new file mode 100644 index 00000000..3a292c89 --- /dev/null +++ b/auto-claude/__init__.py @@ -0,0 +1,9 @@ +""" +Auto Claude - Autonomous Coding Framework +========================================== + +Multi-agent autonomous coding framework that builds software through +coordinated AI agent sessions. +""" + +__version__ = "0.1.0" diff --git a/auto-claude/agent.py b/auto-claude/agent.py index 8b2cc8d5..cb1ad480 100644 --- a/auto-claude/agent.py +++ b/auto-claude/agent.py @@ -1,63 +1,2 @@ -""" -Agent Session Logic -=================== - -Core agent interaction functions for running autonomous coding sessions. -Uses subtask-based implementation plans with minimal, focused prompts. - -Architecture: -- Orchestrator (Python) handles all bookkeeping: memory, commits, progress -- Agent focuses ONLY on implementing code -- Post-session processing updates memory automatically (100% reliable) - -Enhanced with status file updates for ccstatusline integration. -Enhanced with Graphiti memory for cross-session context retrieval. - -NOTE: This module is now a facade that imports from agents/ submodules. -All logic has been refactored into focused modules for better maintainability. -""" - -# Re-export everything from the agents module to maintain backwards compatibility -from agents import ( - # Constants - AUTO_CONTINUE_DELAY_SECONDS, - HUMAN_INTERVENTION_FILE, - # Memory functions - debug_memory_system_status, - find_phase_for_subtask, - find_subtask_in_plan, - get_commit_count, - get_graphiti_context, - # Utility functions - get_latest_commit, - load_implementation_plan, - post_session_processing, - # Session management - run_agent_session, - # Main API - run_autonomous_agent, - run_followup_planner, - save_session_memory, - save_session_to_graphiti, - sync_plan_to_source, -) - -# Ensure all exports are available at module level -__all__ = [ - "run_autonomous_agent", - "run_followup_planner", - "debug_memory_system_status", - "get_graphiti_context", - "save_session_memory", - "save_session_to_graphiti", - "run_agent_session", - "post_session_processing", - "get_latest_commit", - "get_commit_count", - "load_implementation_plan", - "find_subtask_in_plan", - "find_phase_for_subtask", - "sync_plan_to_source", - "AUTO_CONTINUE_DELAY_SECONDS", - "HUMAN_INTERVENTION_FILE", -] +"""Backward compatibility shim - import from core.agent instead.""" +from core.agent import * diff --git a/auto-claude/agents/__init__.py b/auto-claude/agents/__init__.py index 0c288347..977fcb13 100644 --- a/auto-claude/agents/__init__.py +++ b/auto-claude/agents/__init__.py @@ -21,7 +21,7 @@ from .base import ( from .coder import run_autonomous_agent # Memory functions -from .memory import ( +from .memory_manager import ( debug_memory_system_status, get_graphiti_context, save_session_memory, diff --git a/auto-claude/agents/auto_claude_tools.py b/auto-claude/agents/auto_claude_tools.py new file mode 100644 index 00000000..ad6dc0a5 --- /dev/null +++ b/auto-claude/agents/auto_claude_tools.py @@ -0,0 +1,62 @@ +""" +Custom MCP Tools for Auto-Claude Agents +======================================== + +DEPRECATED: This module is now a compatibility shim. +Please import from the tools_pkg package instead: + + from agents.tools_pkg import create_auto_claude_mcp_server, get_allowed_tools + +This file remains for backward compatibility with existing imports. +All functionality has been moved to the tools_pkg package for better +organization and maintainability. +""" + +# Import everything from the package to maintain backward compatibility +# Use try/except to handle both relative and absolute imports +try: + from .tools_pkg import ( + ELECTRON_TOOLS, + TOOL_GET_BUILD_PROGRESS, + TOOL_GET_SESSION_CONTEXT, + TOOL_RECORD_DISCOVERY, + TOOL_RECORD_GOTCHA, + TOOL_UPDATE_QA_STATUS, + TOOL_UPDATE_SUBTASK_STATUS, + create_auto_claude_mcp_server, + get_allowed_tools, + is_electron_mcp_enabled, + is_tools_available, + ) +except ImportError: + # Fallback for direct execution - import from tools_pkg directly + from tools_pkg import ( + ELECTRON_TOOLS, + TOOL_GET_BUILD_PROGRESS, + TOOL_GET_SESSION_CONTEXT, + TOOL_RECORD_DISCOVERY, + TOOL_RECORD_GOTCHA, + TOOL_UPDATE_QA_STATUS, + TOOL_UPDATE_SUBTASK_STATUS, + create_auto_claude_mcp_server, + get_allowed_tools, + is_electron_mcp_enabled, + is_tools_available, + ) + +__all__ = [ + # Main API + "create_auto_claude_mcp_server", + "get_allowed_tools", + "is_tools_available", + # Tool name constants + "TOOL_UPDATE_SUBTASK_STATUS", + "TOOL_GET_BUILD_PROGRESS", + "TOOL_RECORD_DISCOVERY", + "TOOL_RECORD_GOTCHA", + "TOOL_GET_SESSION_CONTEXT", + "TOOL_UPDATE_QA_STATUS", + # Electron MCP + "ELECTRON_TOOLS", + "is_electron_mcp_enabled", +] diff --git a/auto-claude/agents/coder.py b/auto-claude/agents/coder.py index 182ea818..2af5233f 100644 --- a/auto-claude/agents/coder.py +++ b/auto-claude/agents/coder.py @@ -53,7 +53,7 @@ from ui import ( ) from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE -from .memory import debug_memory_system_status, get_graphiti_context +from .memory_manager import debug_memory_system_status, get_graphiti_context from .session import post_session_processing, run_agent_session from .utils import ( find_phase_for_subtask, diff --git a/auto-claude/agents/memory.py b/auto-claude/agents/memory_manager.py similarity index 99% rename from auto-claude/agents/memory.py rename to auto-claude/agents/memory_manager.py index ff82462a..4fd2f502 100644 --- a/auto-claude/agents/memory.py +++ b/auto-claude/agents/memory_manager.py @@ -8,6 +8,7 @@ Handles session memory storage using dual-layer approach: """ import logging +import sys from pathlib import Path from debug import ( @@ -20,6 +21,9 @@ from debug import ( is_debug_enabled, ) from graphiti_config import get_graphiti_status, is_graphiti_enabled + +# Import from parent memory package +# Now safe since this module is named memory_manager (not memory) from memory import save_session_insights as save_file_based_memory logger = logging.getLogger(__name__) diff --git a/auto-claude/agents/session.py b/auto-claude/agents/session.py index 1b9a9d4b..84bdfd08 100644 --- a/auto-claude/agents/session.py +++ b/auto-claude/agents/session.py @@ -32,7 +32,7 @@ from ui import ( print_status, ) -from .memory import save_session_memory +from .memory_manager import save_session_memory from .utils import ( find_subtask_in_plan, get_commit_count, diff --git a/auto-claude/auto_claude_tools/__init__.py b/auto-claude/agents/tools_pkg/__init__.py similarity index 100% rename from auto-claude/auto_claude_tools/__init__.py rename to auto-claude/agents/tools_pkg/__init__.py diff --git a/auto-claude/auto_claude_tools/models.py b/auto-claude/agents/tools_pkg/models.py similarity index 100% rename from auto-claude/auto_claude_tools/models.py rename to auto-claude/agents/tools_pkg/models.py diff --git a/auto-claude/auto_claude_tools/permissions.py b/auto-claude/agents/tools_pkg/permissions.py similarity index 100% rename from auto-claude/auto_claude_tools/permissions.py rename to auto-claude/agents/tools_pkg/permissions.py diff --git a/auto-claude/auto_claude_tools/registry.py b/auto-claude/agents/tools_pkg/registry.py similarity index 100% rename from auto-claude/auto_claude_tools/registry.py rename to auto-claude/agents/tools_pkg/registry.py diff --git a/auto-claude/auto_claude_tools/tools/__init__.py b/auto-claude/agents/tools_pkg/tools/__init__.py similarity index 100% rename from auto-claude/auto_claude_tools/tools/__init__.py rename to auto-claude/agents/tools_pkg/tools/__init__.py diff --git a/auto-claude/auto_claude_tools/tools/memory.py b/auto-claude/agents/tools_pkg/tools/memory.py similarity index 100% rename from auto-claude/auto_claude_tools/tools/memory.py rename to auto-claude/agents/tools_pkg/tools/memory.py diff --git a/auto-claude/auto_claude_tools/tools/progress.py b/auto-claude/agents/tools_pkg/tools/progress.py similarity index 100% rename from auto-claude/auto_claude_tools/tools/progress.py rename to auto-claude/agents/tools_pkg/tools/progress.py diff --git a/auto-claude/auto_claude_tools/tools/qa.py b/auto-claude/agents/tools_pkg/tools/qa.py similarity index 100% rename from auto-claude/auto_claude_tools/tools/qa.py rename to auto-claude/agents/tools_pkg/tools/qa.py diff --git a/auto-claude/auto_claude_tools/tools/subtask.py b/auto-claude/agents/tools_pkg/tools/subtask.py similarity index 100% rename from auto-claude/auto_claude_tools/tools/subtask.py rename to auto-claude/agents/tools_pkg/tools/subtask.py diff --git a/auto-claude/analysis/__init__.py b/auto-claude/analysis/__init__.py new file mode 100644 index 00000000..9f67ff3e --- /dev/null +++ b/auto-claude/analysis/__init__.py @@ -0,0 +1,36 @@ +""" +Analysis Module +=============== + +Code analysis and project scanning tools. +""" + +# Import from analyzers subpackage (these are the modular analyzers) +from .analyzers import ( + ProjectAnalyzer as ModularProjectAnalyzer, + ServiceAnalyzer, + analyze_project, + analyze_service, +) + +# Import from analysis module root (these are other analysis tools) +from .project_analyzer import ProjectAnalyzer +from .risk_classifier import RiskClassifier +from .security_scanner import SecurityScanner +from .ci_discovery import CIDiscovery +from .test_discovery import TestDiscovery + +# insight_extractor is a module with functions, not a class, so don't import it here +# Import it directly when needed: from analysis import insight_extractor + +__all__ = [ + "ProjectAnalyzer", + "ModularProjectAnalyzer", + "ServiceAnalyzer", + "analyze_project", + "analyze_service", + "RiskClassifier", + "SecurityScanner", + "CIDiscovery", + "TestDiscovery", +] diff --git a/auto-claude/analysis/analyzer.py b/auto-claude/analysis/analyzer.py new file mode 100644 index 00000000..46f07a23 --- /dev/null +++ b/auto-claude/analysis/analyzer.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +Codebase Analyzer +================= + +Automatically detects project structure, frameworks, and services. +Supports monorepos with multiple services. + +Usage: + # Index entire project (creates project_index.json) + python auto-claude/analyzer.py --index + + # Analyze specific service + python auto-claude/analyzer.py --service backend + + # Output to specific file + python auto-claude/analyzer.py --index --output path/to/output.json + +The analyzer will: +1. Detect if this is a monorepo or single project +2. Find all services/packages and analyze each separately +3. Map interdependencies between services +4. Identify infrastructure (Docker, CI/CD) +5. Document conventions (linting, testing) + +This module now serves as a facade to the modular analyzer system in the analyzers/ package. +All actual implementation is in focused submodules for better maintainability. +""" + +import json +from pathlib import Path + +# Import from the new modular structure +from .analyzers import ( + ProjectAnalyzer, + ServiceAnalyzer, + analyze_project, + analyze_service, +) + +# Re-export for backward compatibility +__all__ = [ + "ServiceAnalyzer", + "ProjectAnalyzer", + "analyze_project", + "analyze_service", +] + + +def main(): + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Analyze project structure, frameworks, and services" + ) + parser.add_argument( + "--project-dir", + type=Path, + default=Path.cwd(), + help="Project directory to analyze (default: current directory)", + ) + parser.add_argument( + "--index", + action="store_true", + help="Create full project index (default behavior)", + ) + parser.add_argument( + "--service", + type=str, + default=None, + help="Analyze a specific service only", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="Output file for JSON results", + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Only output JSON, no status messages", + ) + + args = parser.parse_args() + + # Determine what to analyze + if args.service: + results = analyze_service(args.project_dir, args.service, args.output) + else: + results = analyze_project(args.project_dir, args.output) + + # Print results + if not args.quiet or not args.output: + print(json.dumps(results, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/auto-claude/analysis/analyzers/__init__.py b/auto-claude/analysis/analyzers/__init__.py new file mode 100644 index 00000000..b425b119 --- /dev/null +++ b/auto-claude/analysis/analyzers/__init__.py @@ -0,0 +1,92 @@ +""" +Analyzers Package +================= + +Modular analyzer system for detecting project structure, frameworks, and services. + +Main exports: +- ServiceAnalyzer: Analyzes a single service/package +- ProjectAnalyzer: Analyzes entire projects (single or monorepo) +- analyze_project: Convenience function for project analysis +- analyze_service: Convenience function for service analysis +""" + +from pathlib import Path +from typing import Any + +from .project_analyzer_module import ProjectAnalyzer +from .service_analyzer import ServiceAnalyzer + +# Re-export main classes +__all__ = [ + "ServiceAnalyzer", + "ProjectAnalyzer", + "analyze_project", + "analyze_service", +] + + +def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict: + """ + Analyze a project and optionally save results. + + Args: + project_dir: Path to the project root + output_file: Optional path to save JSON output + + Returns: + Project index as a dictionary + """ + import json + + analyzer = ProjectAnalyzer(project_dir) + results = analyzer.analyze() + + if output_file: + output_file.parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + print(f"Project index saved to: {output_file}") + + return results + + +def analyze_service( + project_dir: Path, service_name: str, output_file: Path | None = None +) -> dict: + """ + Analyze a specific service within a project. + + Args: + project_dir: Path to the project root + service_name: Name of the service to analyze + output_file: Optional path to save JSON output + + Returns: + Service analysis as a dictionary + """ + import json + + # Find the service + service_path = project_dir / service_name + if not service_path.exists(): + # Check common locations + for parent in ["packages", "apps", "services"]: + candidate = project_dir / parent / service_name + if candidate.exists(): + service_path = candidate + break + + if not service_path.exists(): + raise ValueError(f"Service '{service_name}' not found in {project_dir}") + + analyzer = ServiceAnalyzer(service_path, service_name) + results = analyzer.analyze() + + if output_file: + output_file.parent.mkdir(parents=True, exist_ok=True) + with open(output_file, "w") as f: + json.dump(results, f, indent=2) + print(f"Service analysis saved to: {output_file}") + + return results diff --git a/auto-claude/analyzers/base.py b/auto-claude/analysis/analyzers/base.py similarity index 100% rename from auto-claude/analyzers/base.py rename to auto-claude/analysis/analyzers/base.py diff --git a/auto-claude/analyzers/context/__init__.py b/auto-claude/analysis/analyzers/context/__init__.py similarity index 100% rename from auto-claude/analyzers/context/__init__.py rename to auto-claude/analysis/analyzers/context/__init__.py diff --git a/auto-claude/analyzers/context/api_docs_detector.py b/auto-claude/analysis/analyzers/context/api_docs_detector.py similarity index 100% rename from auto-claude/analyzers/context/api_docs_detector.py rename to auto-claude/analysis/analyzers/context/api_docs_detector.py diff --git a/auto-claude/analyzers/context/auth_detector.py b/auto-claude/analysis/analyzers/context/auth_detector.py similarity index 100% rename from auto-claude/analyzers/context/auth_detector.py rename to auto-claude/analysis/analyzers/context/auth_detector.py diff --git a/auto-claude/analyzers/context/env_detector.py b/auto-claude/analysis/analyzers/context/env_detector.py similarity index 100% rename from auto-claude/analyzers/context/env_detector.py rename to auto-claude/analysis/analyzers/context/env_detector.py diff --git a/auto-claude/analyzers/context/jobs_detector.py b/auto-claude/analysis/analyzers/context/jobs_detector.py similarity index 100% rename from auto-claude/analyzers/context/jobs_detector.py rename to auto-claude/analysis/analyzers/context/jobs_detector.py diff --git a/auto-claude/analyzers/context/migrations_detector.py b/auto-claude/analysis/analyzers/context/migrations_detector.py similarity index 100% rename from auto-claude/analyzers/context/migrations_detector.py rename to auto-claude/analysis/analyzers/context/migrations_detector.py diff --git a/auto-claude/analyzers/context/monitoring_detector.py b/auto-claude/analysis/analyzers/context/monitoring_detector.py similarity index 100% rename from auto-claude/analyzers/context/monitoring_detector.py rename to auto-claude/analysis/analyzers/context/monitoring_detector.py diff --git a/auto-claude/analyzers/context/services_detector.py b/auto-claude/analysis/analyzers/context/services_detector.py similarity index 100% rename from auto-claude/analyzers/context/services_detector.py rename to auto-claude/analysis/analyzers/context/services_detector.py diff --git a/auto-claude/analyzers/context_analyzer.py b/auto-claude/analysis/analyzers/context_analyzer.py similarity index 100% rename from auto-claude/analyzers/context_analyzer.py rename to auto-claude/analysis/analyzers/context_analyzer.py diff --git a/auto-claude/analyzers/database_detector.py b/auto-claude/analysis/analyzers/database_detector.py similarity index 100% rename from auto-claude/analyzers/database_detector.py rename to auto-claude/analysis/analyzers/database_detector.py diff --git a/auto-claude/analyzers/framework_analyzer.py b/auto-claude/analysis/analyzers/framework_analyzer.py similarity index 100% rename from auto-claude/analyzers/framework_analyzer.py rename to auto-claude/analysis/analyzers/framework_analyzer.py diff --git a/auto-claude/analyzers/port_detector.py b/auto-claude/analysis/analyzers/port_detector.py similarity index 100% rename from auto-claude/analyzers/port_detector.py rename to auto-claude/analysis/analyzers/port_detector.py diff --git a/auto-claude/analyzers/project_analyzer_module.py b/auto-claude/analysis/analyzers/project_analyzer_module.py similarity index 100% rename from auto-claude/analyzers/project_analyzer_module.py rename to auto-claude/analysis/analyzers/project_analyzer_module.py diff --git a/auto-claude/analyzers/route_detector.py b/auto-claude/analysis/analyzers/route_detector.py similarity index 100% rename from auto-claude/analyzers/route_detector.py rename to auto-claude/analysis/analyzers/route_detector.py diff --git a/auto-claude/analyzers/service_analyzer.py b/auto-claude/analysis/analyzers/service_analyzer.py similarity index 100% rename from auto-claude/analyzers/service_analyzer.py rename to auto-claude/analysis/analyzers/service_analyzer.py diff --git a/auto-claude/analysis/ci_discovery.py b/auto-claude/analysis/ci_discovery.py new file mode 100644 index 00000000..347546c4 --- /dev/null +++ b/auto-claude/analysis/ci_discovery.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +""" +CI Discovery Module +=================== + +Parses CI/CD configuration files to extract test commands and workflows. +Supports GitHub Actions, GitLab CI, CircleCI, and Jenkins. + +The CI discovery results are used by: +- QA Agent: To understand existing CI test patterns +- Validation Strategy: To match CI commands +- Planner: To align verification with CI + +Usage: + from ci_discovery import CIDiscovery + + discovery = CIDiscovery() + result = discovery.discover(project_dir) + + if result: + print(f"CI System: {result.ci_system}") + print(f"Test Commands: {result.test_commands}") +""" + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# Try to import yaml, fall back gracefully +try: + import yaml + + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class CIWorkflow: + """ + Represents a CI workflow or job. + + Attributes: + name: Name of the workflow/job + trigger: What triggers this workflow (push, pull_request, etc.) + steps: List of step names or commands + test_related: Whether this appears to be test-related + """ + + name: str + trigger: list[str] = field(default_factory=list) + steps: list[str] = field(default_factory=list) + test_related: bool = False + + +@dataclass +class CIConfig: + """ + Result of CI configuration discovery. + + Attributes: + ci_system: Name of CI system (github_actions, gitlab, circleci, jenkins) + config_files: List of CI config files found + test_commands: Extracted test commands by type + coverage_command: Coverage command if found + workflows: List of discovered workflows + environment_variables: Environment variables used + """ + + ci_system: str + config_files: list[str] = field(default_factory=list) + test_commands: dict[str, str] = field(default_factory=dict) + coverage_command: str | None = None + workflows: list[CIWorkflow] = field(default_factory=list) + environment_variables: list[str] = field(default_factory=list) + + +# ============================================================================= +# CI PARSERS +# ============================================================================= + + +class CIDiscovery: + """ + Discovers CI/CD configurations in a project. + + Analyzes: + - GitHub Actions (.github/workflows/*.yml) + - GitLab CI (.gitlab-ci.yml) + - CircleCI (.circleci/config.yml) + - Jenkins (Jenkinsfile) + """ + + def __init__(self) -> None: + """Initialize CI discovery.""" + self._cache: dict[str, CIConfig | None] = {} + + def discover(self, project_dir: Path) -> CIConfig | None: + """ + Discover CI configuration in the project. + + Args: + project_dir: Path to the project root + + Returns: + CIConfig if CI found, None otherwise + """ + project_dir = Path(project_dir) + cache_key = str(project_dir.resolve()) + + if cache_key in self._cache: + return self._cache[cache_key] + + # Try each CI system + result = None + + # GitHub Actions + github_workflows = project_dir / ".github" / "workflows" + if github_workflows.exists(): + result = self._parse_github_actions(github_workflows) + + # GitLab CI + if not result: + gitlab_ci = project_dir / ".gitlab-ci.yml" + if gitlab_ci.exists(): + result = self._parse_gitlab_ci(gitlab_ci) + + # CircleCI + if not result: + circleci = project_dir / ".circleci" / "config.yml" + if circleci.exists(): + result = self._parse_circleci(circleci) + + # Jenkins + if not result: + jenkinsfile = project_dir / "Jenkinsfile" + if jenkinsfile.exists(): + result = self._parse_jenkinsfile(jenkinsfile) + + self._cache[cache_key] = result + return result + + def _parse_github_actions(self, workflows_dir: Path) -> CIConfig: + """Parse GitHub Actions workflow files.""" + result = CIConfig(ci_system="github_actions") + + workflow_files = list(workflows_dir.glob("*.yml")) + list( + workflows_dir.glob("*.yaml") + ) + + for wf_file in workflow_files: + result.config_files.append( + str(wf_file.relative_to(workflows_dir.parent.parent)) + ) + + try: + content = wf_file.read_text() + workflow_data = self._parse_yaml(content) + + if not workflow_data: + continue + + # Get workflow name + wf_name = workflow_data.get("name", wf_file.stem) + + # Get triggers + triggers = [] + on_trigger = workflow_data.get("on", {}) + if isinstance(on_trigger, str): + triggers = [on_trigger] + elif isinstance(on_trigger, list): + triggers = on_trigger + elif isinstance(on_trigger, dict): + triggers = list(on_trigger.keys()) + + # Parse jobs + jobs = workflow_data.get("jobs", {}) + for job_name, job_config in jobs.items(): + if not isinstance(job_config, dict): + continue + + steps = job_config.get("steps", []) + step_commands = [] + test_related = False + + for step in steps: + if not isinstance(step, dict): + continue + + # Get step name or command + step_name = step.get("name", "") + run_cmd = step.get("run", "") + uses = step.get("uses", "") + + if step_name: + step_commands.append(step_name) + if run_cmd: + step_commands.append(run_cmd) + # Extract test commands + self._extract_test_commands(run_cmd, result) + if uses: + step_commands.append(f"uses: {uses}") + + # Check if test-related + test_keywords = ["test", "pytest", "jest", "vitest", "coverage"] + if any(kw in str(step).lower() for kw in test_keywords): + test_related = True + + result.workflows.append( + CIWorkflow( + name=f"{wf_name}/{job_name}", + trigger=triggers, + steps=step_commands, + test_related=test_related, + ) + ) + + # Extract environment variables + env = workflow_data.get("env", {}) + if isinstance(env, dict): + result.environment_variables.extend(env.keys()) + + except Exception: + continue + + return result + + def _parse_gitlab_ci(self, config_file: Path) -> CIConfig: + """Parse GitLab CI configuration.""" + result = CIConfig( + ci_system="gitlab", + config_files=[".gitlab-ci.yml"], + ) + + try: + content = config_file.read_text() + data = self._parse_yaml(content) + + if not data: + return result + + # Parse jobs (top-level keys that aren't special keywords) + special_keys = { + "stages", + "variables", + "image", + "services", + "before_script", + "after_script", + "cache", + "include", + "default", + "workflow", + } + + for key, value in data.items(): + if key.startswith(".") or key in special_keys: + continue + + if not isinstance(value, dict): + continue + + job_config = value + script = job_config.get("script", []) + if isinstance(script, str): + script = [script] + + test_related = any( + kw in str(script).lower() + for kw in ["test", "pytest", "jest", "vitest", "coverage"] + ) + + result.workflows.append( + CIWorkflow( + name=key, + trigger=job_config.get("only", []) + or job_config.get("rules", []), + steps=script, + test_related=test_related, + ) + ) + + # Extract test commands + for cmd in script: + if isinstance(cmd, str): + self._extract_test_commands(cmd, result) + + # Extract variables + variables = data.get("variables", {}) + if isinstance(variables, dict): + result.environment_variables.extend(variables.keys()) + + except Exception: + pass + + return result + + def _parse_circleci(self, config_file: Path) -> CIConfig: + """Parse CircleCI configuration.""" + result = CIConfig( + ci_system="circleci", + config_files=[".circleci/config.yml"], + ) + + try: + content = config_file.read_text() + data = self._parse_yaml(content) + + if not data: + return result + + # Parse jobs + jobs = data.get("jobs", {}) + for job_name, job_config in jobs.items(): + if not isinstance(job_config, dict): + continue + + steps = job_config.get("steps", []) + step_commands = [] + test_related = False + + for step in steps: + if isinstance(step, str): + step_commands.append(step) + elif isinstance(step, dict): + if "run" in step: + run = step["run"] + if isinstance(run, str): + step_commands.append(run) + self._extract_test_commands(run, result) + elif isinstance(run, dict): + cmd = run.get("command", "") + step_commands.append(cmd) + self._extract_test_commands(cmd, result) + + if any( + kw in str(step).lower() + for kw in ["test", "pytest", "jest", "coverage"] + ): + test_related = True + + result.workflows.append( + CIWorkflow( + name=job_name, + trigger=[], + steps=step_commands, + test_related=test_related, + ) + ) + + except Exception: + pass + + return result + + def _parse_jenkinsfile(self, jenkinsfile: Path) -> CIConfig: + """Parse Jenkinsfile (basic extraction).""" + result = CIConfig( + ci_system="jenkins", + config_files=["Jenkinsfile"], + ) + + try: + content = jenkinsfile.read_text() + + # Extract sh commands using regex + sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]') + matches = sh_pattern.findall(content) + + steps = [] + test_related = False + + for cmd in matches: + steps.append(cmd) + self._extract_test_commands(cmd, result) + + if any( + kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"] + ): + test_related = True + + # Extract stage names + stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)') + stages = stage_pattern.findall(content) + + for stage in stages: + result.workflows.append( + CIWorkflow( + name=stage, + trigger=[], + steps=steps if "test" in stage.lower() else [], + test_related="test" in stage.lower(), + ) + ) + + except Exception: + pass + + return result + + def _parse_yaml(self, content: str) -> dict | None: + """Parse YAML content, with fallback to basic parsing if yaml not available.""" + if HAS_YAML: + try: + return yaml.safe_load(content) + except Exception: + return None + + # Basic fallback for simple YAML (very limited) + # This won't work for complex structures + return None + + def _extract_test_commands(self, cmd: str, result: CIConfig) -> None: + """Extract test commands from a command string.""" + cmd_lower = cmd.lower() + + # Python pytest + if "pytest" in cmd_lower: + if "pytest" not in result.test_commands: + result.test_commands["unit"] = cmd.strip() + if "--cov" in cmd_lower: + result.coverage_command = cmd.strip() + + # Node.js test commands + if ( + "npm test" in cmd_lower + or "yarn test" in cmd_lower + or "pnpm test" in cmd_lower + ): + if "unit" not in result.test_commands: + result.test_commands["unit"] = cmd.strip() + + # Jest/Vitest + if "jest" in cmd_lower or "vitest" in cmd_lower: + if "unit" not in result.test_commands: + result.test_commands["unit"] = cmd.strip() + if "--coverage" in cmd_lower: + result.coverage_command = cmd.strip() + + # E2E testing + if "playwright" in cmd_lower: + result.test_commands["e2e"] = cmd.strip() + if "cypress" in cmd_lower: + result.test_commands["e2e"] = cmd.strip() + + # Integration tests + if "integration" in cmd_lower: + result.test_commands["integration"] = cmd.strip() + + # Go tests + if "go test" in cmd_lower: + if "unit" not in result.test_commands: + result.test_commands["unit"] = cmd.strip() + + # Rust tests + if "cargo test" in cmd_lower: + if "unit" not in result.test_commands: + result.test_commands["unit"] = cmd.strip() + + def to_dict(self, result: CIConfig) -> dict[str, Any]: + """Convert result to dictionary for JSON serialization.""" + return { + "ci_system": result.ci_system, + "config_files": result.config_files, + "test_commands": result.test_commands, + "coverage_command": result.coverage_command, + "workflows": [ + { + "name": w.name, + "trigger": w.trigger, + "steps": w.steps, + "test_related": w.test_related, + } + for w in result.workflows + ], + "environment_variables": result.environment_variables, + } + + def clear_cache(self) -> None: + """Clear the internal cache.""" + self._cache.clear() + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def discover_ci(project_dir: Path) -> CIConfig | None: + """ + Convenience function to discover CI configuration. + + Args: + project_dir: Path to project root + + Returns: + CIConfig if found, None otherwise + """ + discovery = CIDiscovery() + return discovery.discover(project_dir) + + +def get_ci_test_commands(project_dir: Path) -> dict[str, str]: + """ + Get test commands from CI configuration. + + Args: + project_dir: Path to project root + + Returns: + Dictionary of test type to command + """ + discovery = CIDiscovery() + result = discovery.discover(project_dir) + if result: + return result.test_commands + return {} + + +def get_ci_system(project_dir: Path) -> str | None: + """ + Get the CI system name if configured. + + Args: + project_dir: Path to project root + + Returns: + CI system name or None + """ + discovery = CIDiscovery() + result = discovery.discover(project_dir) + if result: + return result.ci_system + return None + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Discover CI configuration") + parser.add_argument("project_dir", type=Path, help="Path to project root") + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + discovery = CIDiscovery() + result = discovery.discover(args.project_dir) + + if not result: + print("No CI configuration found") + return + + if args.json: + print(json.dumps(discovery.to_dict(result), indent=2)) + else: + print(f"CI System: {result.ci_system}") + print(f"Config Files: {', '.join(result.config_files)}") + print("\nTest Commands:") + for test_type, cmd in result.test_commands.items(): + print(f" {test_type}: {cmd}") + if result.coverage_command: + print(f"\nCoverage Command: {result.coverage_command}") + print(f"\nWorkflows ({len(result.workflows)}):") + for w in result.workflows: + marker = "[TEST]" if w.test_related else "" + print(f" - {w.name} {marker}") + if w.trigger: + print(f" Triggers: {', '.join(str(t) for t in w.trigger)}") + if result.environment_variables: + print(f"\nEnvironment Variables: {', '.join(result.environment_variables)}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/analysis/insight_extractor.py b/auto-claude/analysis/insight_extractor.py new file mode 100644 index 00000000..281fdb87 --- /dev/null +++ b/auto-claude/analysis/insight_extractor.py @@ -0,0 +1,584 @@ +""" +Insight Extractor +================= + +Automatically extracts structured insights from completed coding sessions. +Runs after each session to capture rich, actionable knowledge for Graphiti memory. + +Uses the Claude Agent SDK (same as the rest of the system) for extraction. +Falls back to generic insights if extraction fails (never blocks the build). +""" + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# Check for Claude SDK availability +try: + from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + SDK_AVAILABLE = True +except ImportError: + SDK_AVAILABLE = False + ClaudeAgentOptions = None + ClaudeSDKClient = None + +# Default model for insight extraction (fast and cheap) +DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest" + +# Maximum diff size to send to the LLM (avoid context limits) +MAX_DIFF_CHARS = 15000 + +# Maximum attempt history entries to include +MAX_ATTEMPTS_TO_INCLUDE = 3 + + +def is_extraction_enabled() -> bool: + """Check if insight extraction is enabled.""" + # Extraction requires Claude SDK and OAuth token + if not SDK_AVAILABLE: + return False + if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): + return False + enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower() + return enabled_str in ("true", "1", "yes") + + +def get_extraction_model() -> str: + """Get the model to use for insight extraction.""" + return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL) + + +# ============================================================================= +# Git Helpers +# ============================================================================= + + +def get_session_diff( + project_dir: Path, + commit_before: str | None, + commit_after: str | None, +) -> str: + """ + Get the git diff between two commits. + + Args: + project_dir: Project root directory + commit_before: Commit hash before session (or None) + commit_after: Commit hash after session (or None) + + Returns: + Diff text (truncated if too large) + """ + if not commit_before or not commit_after: + return "(No commits to diff)" + + if commit_before == commit_after: + return "(No changes - same commit)" + + try: + result = subprocess.run( + ["git", "diff", commit_before, commit_after], + cwd=project_dir, + capture_output=True, + text=True, + timeout=30, + ) + diff = result.stdout + + if len(diff) > MAX_DIFF_CHARS: + # Truncate and add note + diff = ( + diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)" + ) + + return diff if diff else "(Empty diff)" + + except subprocess.TimeoutExpired: + logger.warning("Git diff timed out") + return "(Git diff timed out)" + except Exception as e: + logger.warning(f"Failed to get git diff: {e}") + return f"(Failed to get diff: {e})" + + +def get_changed_files( + project_dir: Path, + commit_before: str | None, + commit_after: str | None, +) -> list[str]: + """ + Get list of files changed between two commits. + + Args: + project_dir: Project root directory + commit_before: Commit hash before session + commit_after: Commit hash after session + + Returns: + List of changed file paths + """ + if not commit_before or not commit_after or commit_before == commit_after: + return [] + + try: + result = subprocess.run( + ["git", "diff", "--name-only", commit_before, commit_after], + cwd=project_dir, + capture_output=True, + text=True, + timeout=10, + ) + files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()] + return files + + except Exception as e: + logger.warning(f"Failed to get changed files: {e}") + return [] + + +def get_commit_messages( + project_dir: Path, + commit_before: str | None, + commit_after: str | None, +) -> str: + """Get commit messages between two commits.""" + if not commit_before or not commit_after or commit_before == commit_after: + return "(No commits)" + + try: + result = subprocess.run( + ["git", "log", "--oneline", f"{commit_before}..{commit_after}"], + cwd=project_dir, + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() if result.stdout.strip() else "(No commits)" + + except Exception as e: + logger.warning(f"Failed to get commit messages: {e}") + return f"(Failed: {e})" + + +# ============================================================================= +# Input Gathering +# ============================================================================= + + +def gather_extraction_inputs( + spec_dir: Path, + project_dir: Path, + subtask_id: str, + session_num: int, + commit_before: str | None, + commit_after: str | None, + success: bool, + recovery_manager: Any, +) -> dict: + """ + Gather all inputs needed for insight extraction. + + Args: + spec_dir: Spec directory + project_dir: Project root + subtask_id: The subtask that was worked on + session_num: Session number + commit_before: Commit before session + commit_after: Commit after session + success: Whether session succeeded + recovery_manager: Recovery manager with attempt history + + Returns: + Dict with all inputs for the extractor + """ + # Get subtask description from implementation plan + subtask_description = _get_subtask_description(spec_dir, subtask_id) + + # Get git diff + diff = get_session_diff(project_dir, commit_before, commit_after) + + # Get changed files + changed_files = get_changed_files(project_dir, commit_before, commit_after) + + # Get commit messages + commit_messages = get_commit_messages(project_dir, commit_before, commit_after) + + # Get attempt history + attempt_history = _get_attempt_history(recovery_manager, subtask_id) + + return { + "subtask_id": subtask_id, + "subtask_description": subtask_description, + "session_num": session_num, + "success": success, + "diff": diff, + "changed_files": changed_files, + "commit_messages": commit_messages, + "attempt_history": attempt_history, + } + + +def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str: + """Get subtask description from implementation plan.""" + plan_file = spec_dir / "implementation_plan.json" + if not plan_file.exists(): + return f"Subtask: {subtask_id}" + + try: + with open(plan_file) as f: + plan = json.load(f) + + # Search through phases for the subtask + for phase in plan.get("phases", []): + for subtask in phase.get("subtasks", []): + if subtask.get("id") == subtask_id: + return subtask.get("description", f"Subtask: {subtask_id}") + + return f"Subtask: {subtask_id}" + + except Exception as e: + logger.warning(f"Failed to load subtask description: {e}") + return f"Subtask: {subtask_id}" + + +def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]: + """Get previous attempt history for this subtask.""" + if not recovery_manager: + return [] + + try: + history = recovery_manager.get_subtask_history(subtask_id) + attempts = history.get("attempts", []) + + # Limit to recent attempts + return attempts[-MAX_ATTEMPTS_TO_INCLUDE:] + + except Exception as e: + logger.warning(f"Failed to get attempt history: {e}") + return [] + + +# ============================================================================= +# LLM Extraction +# ============================================================================= + + +def _build_extraction_prompt(inputs: dict) -> str: + """Build the prompt for insight extraction.""" + prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md" + + if prompt_file.exists(): + base_prompt = prompt_file.read_text() + else: + # Fallback if prompt file missing + base_prompt = """Extract structured insights from this coding session. +Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations""" + + # Build session context + session_context = f""" +--- + +## SESSION DATA + +### Subtask +- **ID**: {inputs["subtask_id"]} +- **Description**: {inputs["subtask_description"]} +- **Session Number**: {inputs["session_num"]} +- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"} + +### Files Changed +{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"} + +### Commit Messages +{inputs["commit_messages"]} + +### Git Diff +```diff +{inputs["diff"]} +``` + +### Previous Attempts +{_format_attempt_history(inputs["attempt_history"])} + +--- + +Now analyze this session and output ONLY the JSON object. +""" + + return base_prompt + session_context + + +def _format_attempt_history(attempts: list[dict]) -> str: + """Format attempt history for the prompt.""" + if not attempts: + return "(First attempt - no previous history)" + + lines = [] + for i, attempt in enumerate(attempts, 1): + success = "SUCCESS" if attempt.get("success") else "FAILED" + approach = attempt.get("approach", "Unknown approach") + error = attempt.get("error", "") + lines.append(f"**Attempt {i}** ({success}): {approach}") + if error: + lines.append(f" Error: {error}") + + return "\n".join(lines) + + +async def run_insight_extraction(inputs: dict, project_dir: Path | None = None) -> dict | None: + """ + Run the insight extraction using Claude Agent SDK. + + Args: + inputs: Gathered session inputs + project_dir: Project directory for SDK context (optional) + + Returns: + Extracted insights dict or None if failed + """ + if not SDK_AVAILABLE: + logger.warning("Claude SDK not available, skipping insight extraction") + return None + + oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + if not oauth_token: + logger.warning("CLAUDE_CODE_OAUTH_TOKEN not set, skipping insight extraction") + return None + + model = get_extraction_model() + prompt = _build_extraction_prompt(inputs) + + # Use current directory if project_dir not specified + cwd = str(project_dir.resolve()) if project_dir else os.getcwd() + + try: + # Create a minimal SDK client for insight extraction + # No tools needed - just text generation + client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model=model, + system_prompt=( + "You are an expert code analyst. You extract structured insights from coding sessions. " + "Always respond with valid JSON only, no markdown formatting or explanations." + ), + allowed_tools=[], # No tools needed for extraction + max_turns=1, # Single turn extraction + cwd=cwd, + ) + ) + + # Use async context manager + async with client: + await client.query(prompt) + + # Collect the response + response_text = "" + async for msg in client.receive_response(): + msg_type = type(msg).__name__ + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + if hasattr(block, "text"): + response_text += block.text + + # Parse JSON from response + return parse_insights(response_text) + + except Exception as e: + logger.warning(f"Insight extraction failed: {e}") + return None + + +def parse_insights(response_text: str) -> dict | None: + """ + Parse the LLM response into structured insights. + + Args: + response_text: Raw LLM response + + Returns: + Parsed insights dict or None if parsing failed + """ + # Try to extract JSON from the response + text = response_text.strip() + + # Handle markdown code blocks + if text.startswith("```"): + # Remove code block markers + lines = text.split("\n") + # Remove first line (```json or ```) + if lines[0].startswith("```"): + lines = lines[1:] + # Remove last line if it's `` + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + + try: + insights = json.loads(text) + + # Validate structure + if not isinstance(insights, dict): + logger.warning("Insights is not a dict") + return None + + # Ensure required keys exist with defaults + insights.setdefault("file_insights", []) + insights.setdefault("patterns_discovered", []) + insights.setdefault("gotchas_discovered", []) + insights.setdefault("approach_outcome", {}) + insights.setdefault("recommendations", []) + + return insights + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse insights JSON: {e}") + logger.debug(f"Response text was: {text[:500]}") + return None + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + + +async def extract_session_insights( + spec_dir: Path, + project_dir: Path, + subtask_id: str, + session_num: int, + commit_before: str | None, + commit_after: str | None, + success: bool, + recovery_manager: Any, +) -> dict: + """ + Extract insights from a completed coding session. + + This is the main entry point called from post_session_processing(). + Falls back to generic insights if extraction fails. + + Args: + spec_dir: Spec directory + project_dir: Project root + subtask_id: Subtask that was worked on + session_num: Session number + commit_before: Commit before session + commit_after: Commit after session + success: Whether session succeeded + recovery_manager: Recovery manager with attempt history + + Returns: + Insights dict (rich if extraction succeeded, generic if failed) + """ + # Check if extraction is enabled + if not is_extraction_enabled(): + logger.info("Insight extraction disabled") + return _get_generic_insights(subtask_id, success) + + # Check for no changes + if commit_before == commit_after: + logger.info("No changes to extract insights from") + return _get_generic_insights(subtask_id, success) + + try: + # Gather inputs + inputs = gather_extraction_inputs( + spec_dir=spec_dir, + project_dir=project_dir, + subtask_id=subtask_id, + session_num=session_num, + commit_before=commit_before, + commit_after=commit_after, + success=success, + recovery_manager=recovery_manager, + ) + + # Run extraction + extracted = await run_insight_extraction(inputs, project_dir=project_dir) + + if extracted: + # Add metadata + extracted["subtask_id"] = subtask_id + extracted["session_num"] = session_num + extracted["success"] = success + extracted["changed_files"] = inputs["changed_files"] + + logger.info( + f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, " + f"{len(extracted.get('patterns_discovered', []))} patterns, " + f"{len(extracted.get('gotchas_discovered', []))} gotchas" + ) + return extracted + else: + logger.warning("Extraction returned no results, using generic insights") + return _get_generic_insights(subtask_id, success) + + except Exception as e: + logger.warning(f"Insight extraction failed: {e}, using generic insights") + return _get_generic_insights(subtask_id, success) + + +def _get_generic_insights(subtask_id: str, success: bool) -> dict: + """Return generic insights when extraction fails or is disabled.""" + return { + "file_insights": [], + "patterns_discovered": [], + "gotchas_discovered": [], + "approach_outcome": { + "success": success, + "approach_used": f"Implemented subtask: {subtask_id}", + "why_it_worked": None, + "why_it_failed": None, + "alternatives_tried": [], + }, + "recommendations": [], + "subtask_id": subtask_id, + "success": success, + "changed_files": [], + } + + +# ============================================================================= +# CLI for Testing +# ============================================================================= + +if __name__ == "__main__": + import argparse + import asyncio + + parser = argparse.ArgumentParser(description="Test insight extraction") + parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory") + parser.add_argument( + "--project-dir", type=Path, required=True, help="Project directory" + ) + parser.add_argument( + "--commit-before", type=str, required=True, help="Commit before session" + ) + parser.add_argument( + "--commit-after", type=str, required=True, help="Commit after session" + ) + parser.add_argument( + "--subtask-id", type=str, default="test-subtask", help="Subtask ID" + ) + + args = parser.parse_args() + + async def main(): + insights = await extract_session_insights( + spec_dir=args.spec_dir, + project_dir=args.project_dir, + subtask_id=args.subtask_id, + session_num=1, + commit_before=args.commit_before, + commit_after=args.commit_after, + success=True, + recovery_manager=None, + ) + print(json.dumps(insights, indent=2)) + + asyncio.run(main()) diff --git a/auto-claude/analysis/project_analyzer.py b/auto-claude/analysis/project_analyzer.py new file mode 100644 index 00000000..74484684 --- /dev/null +++ b/auto-claude/analysis/project_analyzer.py @@ -0,0 +1,106 @@ +""" +Smart Project Analyzer for Dynamic Security Profiles +===================================================== + +FACADE MODULE: This module re-exports all functionality from the +auto-claude/project/ package for backward compatibility. + +The implementation has been refactored into focused modules: +- project/command_registry.py - Command registries +- project/models.py - Data structures +- project/config_parser.py - Config file parsing +- project/stack_detector.py - Stack detection +- project/framework_detector.py - Framework detection +- project/structure_analyzer.py - Project structure analysis +- project/analyzer.py - Main orchestration + +This file maintains the original API so existing imports continue to work. + +This system: +1. Detects languages, frameworks, databases, and infrastructure +2. Parses package.json scripts, Makefile targets, pyproject.toml scripts +3. Builds a tailored security profile for the specific project +4. Caches the profile for subsequent runs +5. Can re-analyze when project structure changes + +The goal: Allow an AI developer to run any command that's legitimately +needed for the detected tech stack, while blocking dangerous operations. +""" + +# Re-export all public API from the project module +from project import ( + # Command registries + BASE_COMMANDS, + VALIDATED_COMMANDS, + CustomScripts, + # Main classes + ProjectAnalyzer, + SecurityProfile, + TechnologyStack, + # Utility functions + get_or_create_profile, + is_command_allowed, + needs_validation, +) + +# Also re-export command registries for backward compatibility +from project.command_registry import ( + CLOUD_COMMANDS, + CODE_QUALITY_COMMANDS, + DATABASE_COMMANDS, + FRAMEWORK_COMMANDS, + INFRASTRUCTURE_COMMANDS, + LANGUAGE_COMMANDS, + PACKAGE_MANAGER_COMMANDS, + VERSION_MANAGER_COMMANDS, +) + +__all__ = [ + # Main classes + "ProjectAnalyzer", + "SecurityProfile", + "TechnologyStack", + "CustomScripts", + # Utility functions + "get_or_create_profile", + "is_command_allowed", + "needs_validation", + # Base command sets + "BASE_COMMANDS", + "VALIDATED_COMMANDS", + # Technology-specific command sets + "LANGUAGE_COMMANDS", + "PACKAGE_MANAGER_COMMANDS", + "FRAMEWORK_COMMANDS", + "DATABASE_COMMANDS", + "INFRASTRUCTURE_COMMANDS", + "CLOUD_COMMANDS", + "CODE_QUALITY_COMMANDS", + "VERSION_MANAGER_COMMANDS", +] + + +# ============================================================================= +# CLI for testing +# ============================================================================= + +if __name__ == "__main__": + import sys + from pathlib import Path + + if len(sys.argv) < 2: + print("Usage: python project_analyzer.py [--force]") + sys.exit(1) + + project_dir = Path(sys.argv[1]) + force = "--force" in sys.argv + + if not project_dir.exists(): + print(f"Error: {project_dir} does not exist") + sys.exit(1) + + profile = get_or_create_profile(project_dir, force_reanalyze=force) + + print("\nAllowed commands:") + for cmd in sorted(profile.get_all_allowed_commands()): + print(f" {cmd}") diff --git a/auto-claude/analysis/risk_classifier.py b/auto-claude/analysis/risk_classifier.py new file mode 100644 index 00000000..37488c08 --- /dev/null +++ b/auto-claude/analysis/risk_classifier.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +""" +Risk Classifier Module +====================== + +Reads the AI-generated complexity_assessment.json and provides programmatic +access to risk classification and validation recommendations. + +This module serves as the bridge between the AI complexity assessor prompt +and the rest of the validation system. + +Usage: + from risk_classifier import RiskClassifier + + classifier = RiskClassifier() + assessment = classifier.load_assessment(spec_dir) + + if classifier.should_skip_validation(spec_dir): + print("Validation can be skipped for this task") + + test_types = classifier.get_required_test_types(spec_dir) +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class ScopeAnalysis: + """Analysis of task scope.""" + + estimated_files: int = 0 + estimated_services: int = 0 + is_cross_cutting: bool = False + notes: str = "" + + +@dataclass +class IntegrationAnalysis: + """Analysis of external integrations.""" + + external_services: list[str] = field(default_factory=list) + new_dependencies: list[str] = field(default_factory=list) + research_needed: bool = False + notes: str = "" + + +@dataclass +class InfrastructureAnalysis: + """Analysis of infrastructure requirements.""" + + docker_changes: bool = False + database_changes: bool = False + config_changes: bool = False + notes: str = "" + + +@dataclass +class KnowledgeAnalysis: + """Analysis of knowledge requirements.""" + + patterns_exist: bool = True + research_required: bool = False + unfamiliar_tech: list[str] = field(default_factory=list) + notes: str = "" + + +@dataclass +class RiskAnalysis: + """Analysis of task risk.""" + + level: str = "low" # low, medium, high + concerns: list[str] = field(default_factory=list) + notes: str = "" + + +@dataclass +class ComplexityAnalysis: + """Full complexity analysis from the AI assessor.""" + + scope: ScopeAnalysis = field(default_factory=ScopeAnalysis) + integrations: IntegrationAnalysis = field(default_factory=IntegrationAnalysis) + infrastructure: InfrastructureAnalysis = field( + default_factory=InfrastructureAnalysis + ) + knowledge: KnowledgeAnalysis = field(default_factory=KnowledgeAnalysis) + risk: RiskAnalysis = field(default_factory=RiskAnalysis) + + +@dataclass +class ValidationRecommendations: + """Validation recommendations from the AI assessor.""" + + risk_level: str = "medium" # trivial, low, medium, high, critical + skip_validation: bool = False + minimal_mode: bool = False + test_types_required: list[str] = field(default_factory=lambda: ["unit"]) + security_scan_required: bool = False + staging_deployment_required: bool = False + reasoning: str = "" + + +@dataclass +class AssessmentFlags: + """Flags indicating special requirements.""" + + needs_research: bool = False + needs_self_critique: bool = False + needs_infrastructure_setup: bool = False + + +@dataclass +class RiskAssessment: + """Complete risk assessment from complexity_assessment.json.""" + + complexity: str # simple, standard, complex + workflow_type: str # feature, refactor, investigation, migration, simple + confidence: float + reasoning: str + analysis: ComplexityAnalysis + recommended_phases: list[str] + flags: AssessmentFlags + validation: ValidationRecommendations + created_at: str | None = None + + @property + def risk_level(self) -> str: + """Get the risk level from validation recommendations.""" + return self.validation.risk_level + + +# ============================================================================= +# RISK CLASSIFIER +# ============================================================================= + + +class RiskClassifier: + """ + Reads AI-generated complexity_assessment.json and provides risk classification. + + The complexity_assessment.json is generated by the AI complexity assessor + agent using the complexity_assessor.md prompt. This module parses that output + and provides programmatic access to the risk classification. + """ + + def __init__(self) -> None: + """Initialize the risk classifier.""" + self._cache: dict[str, RiskAssessment] = {} + + def load_assessment(self, spec_dir: Path) -> RiskAssessment | None: + """ + Load complexity_assessment.json from spec directory. + + Args: + spec_dir: Path to the spec directory containing complexity_assessment.json + + Returns: + RiskAssessment object if file exists and is valid, None otherwise + """ + spec_dir = Path(spec_dir) + cache_key = str(spec_dir.resolve()) + + # Return cached result if available + if cache_key in self._cache: + return self._cache[cache_key] + + assessment_file = spec_dir / "complexity_assessment.json" + if not assessment_file.exists(): + return None + + try: + with open(assessment_file, encoding="utf-8") as f: + data = json.load(f) + + assessment = self._parse_assessment(data) + self._cache[cache_key] = assessment + return assessment + + except (json.JSONDecodeError, KeyError, TypeError) as e: + # Log error but don't crash - return None to allow fallback behavior + print(f"Warning: Failed to parse complexity_assessment.json: {e}") + return None + + def _parse_assessment(self, data: dict[str, Any]) -> RiskAssessment: + """Parse raw JSON data into a RiskAssessment object.""" + # Parse analysis sections + analysis_data = data.get("analysis", {}) + analysis = ComplexityAnalysis( + scope=self._parse_scope(analysis_data.get("scope", {})), + integrations=self._parse_integrations( + analysis_data.get("integrations", {}) + ), + infrastructure=self._parse_infrastructure( + analysis_data.get("infrastructure", {}) + ), + knowledge=self._parse_knowledge(analysis_data.get("knowledge", {})), + risk=self._parse_risk(analysis_data.get("risk", {})), + ) + + # Parse flags + flags_data = data.get("flags", {}) + flags = AssessmentFlags( + needs_research=flags_data.get("needs_research", False), + needs_self_critique=flags_data.get("needs_self_critique", False), + needs_infrastructure_setup=flags_data.get( + "needs_infrastructure_setup", False + ), + ) + + # Parse validation recommendations + validation_data = data.get("validation_recommendations", {}) + validation = self._parse_validation_recommendations(validation_data, analysis) + + return RiskAssessment( + complexity=data.get("complexity", "standard"), + workflow_type=data.get("workflow_type", "feature"), + confidence=float(data.get("confidence", 0.5)), + reasoning=data.get("reasoning", ""), + analysis=analysis, + recommended_phases=data.get("recommended_phases", []), + flags=flags, + validation=validation, + created_at=data.get("created_at"), + ) + + def _parse_scope(self, data: dict[str, Any]) -> ScopeAnalysis: + """Parse scope analysis section.""" + return ScopeAnalysis( + estimated_files=int(data.get("estimated_files", 0)), + estimated_services=int(data.get("estimated_services", 0)), + is_cross_cutting=bool(data.get("is_cross_cutting", False)), + notes=str(data.get("notes", "")), + ) + + def _parse_integrations(self, data: dict[str, Any]) -> IntegrationAnalysis: + """Parse integrations analysis section.""" + return IntegrationAnalysis( + external_services=list(data.get("external_services", [])), + new_dependencies=list(data.get("new_dependencies", [])), + research_needed=bool(data.get("research_needed", False)), + notes=str(data.get("notes", "")), + ) + + def _parse_infrastructure(self, data: dict[str, Any]) -> InfrastructureAnalysis: + """Parse infrastructure analysis section.""" + return InfrastructureAnalysis( + docker_changes=bool(data.get("docker_changes", False)), + database_changes=bool(data.get("database_changes", False)), + config_changes=bool(data.get("config_changes", False)), + notes=str(data.get("notes", "")), + ) + + def _parse_knowledge(self, data: dict[str, Any]) -> KnowledgeAnalysis: + """Parse knowledge analysis section.""" + return KnowledgeAnalysis( + patterns_exist=bool(data.get("patterns_exist", True)), + research_required=bool(data.get("research_required", False)), + unfamiliar_tech=list(data.get("unfamiliar_tech", [])), + notes=str(data.get("notes", "")), + ) + + def _parse_risk(self, data: dict[str, Any]) -> RiskAnalysis: + """Parse risk analysis section.""" + return RiskAnalysis( + level=str(data.get("level", "low")), + concerns=list(data.get("concerns", [])), + notes=str(data.get("notes", "")), + ) + + def _parse_validation_recommendations( + self, data: dict[str, Any], analysis: ComplexityAnalysis + ) -> ValidationRecommendations: + """ + Parse validation recommendations section. + + If validation_recommendations is not present in the JSON (older assessments), + infer appropriate values from the analysis. + """ + if data: + # New format with explicit validation recommendations + return ValidationRecommendations( + risk_level=str(data.get("risk_level", "medium")), + skip_validation=bool(data.get("skip_validation", False)), + minimal_mode=bool(data.get("minimal_mode", False)), + test_types_required=list(data.get("test_types_required", ["unit"])), + security_scan_required=bool(data.get("security_scan_required", False)), + staging_deployment_required=bool( + data.get("staging_deployment_required", False) + ), + reasoning=str(data.get("reasoning", "")), + ) + else: + # Infer from analysis (backward compatibility) + return self._infer_validation_recommendations(analysis) + + def _infer_validation_recommendations( + self, analysis: ComplexityAnalysis + ) -> ValidationRecommendations: + """ + Infer validation recommendations from analysis when not explicitly provided. + + This provides backward compatibility with older complexity assessments + that don't have the validation_recommendations section. + """ + risk_level = analysis.risk.level + + # Map old risk levels to new ones + risk_mapping = { + "low": "low", + "medium": "medium", + "high": "high", + } + normalized_risk = risk_mapping.get(risk_level, "medium") + + # Infer test types based on risk + test_types_map = { + "low": ["unit"], + "medium": ["unit", "integration"], + "high": ["unit", "integration", "e2e"], + } + test_types = test_types_map.get(normalized_risk, ["unit", "integration"]) + + # Security scan for high risk or security-related concerns + security_keywords = [ + "security", + "auth", + "password", + "credential", + "token", + "api key", + ] + has_security_concerns = any( + kw in str(analysis.risk.concerns).lower() for kw in security_keywords + ) + security_scan_required = normalized_risk == "high" or has_security_concerns + + # Staging for database or infrastructure changes + staging_required = ( + analysis.infrastructure.database_changes + and normalized_risk in ["medium", "high"] + ) + + # Minimal mode for simple changes + minimal_mode = ( + analysis.scope.estimated_files <= 2 + and analysis.scope.estimated_services <= 1 + and not analysis.integrations.external_services + ) + + return ValidationRecommendations( + risk_level=normalized_risk, + skip_validation=False, # Never skip by inference + minimal_mode=minimal_mode, + test_types_required=test_types, + security_scan_required=security_scan_required, + staging_deployment_required=staging_required, + reasoning="Inferred from complexity analysis (no explicit recommendations found)", + ) + + def should_skip_validation(self, spec_dir: Path) -> bool: + """ + Quick check if validation can be skipped entirely. + + Args: + spec_dir: Path to the spec directory + + Returns: + True if validation can be skipped (trivial changes), False otherwise + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return False # When in doubt, don't skip + + return assessment.validation.skip_validation + + def should_use_minimal_mode(self, spec_dir: Path) -> bool: + """ + Check if minimal validation mode should be used. + + Args: + spec_dir: Path to the spec directory + + Returns: + True if minimal mode is recommended, False otherwise + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return False + + return assessment.validation.minimal_mode + + def get_required_test_types(self, spec_dir: Path) -> list[str]: + """ + Get list of required test types based on risk. + + Args: + spec_dir: Path to the spec directory + + Returns: + List of test types (e.g., ["unit", "integration", "e2e"]) + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return ["unit"] # Default to unit tests + + return assessment.validation.test_types_required + + def requires_security_scan(self, spec_dir: Path) -> bool: + """ + Check if security scanning is required. + + Args: + spec_dir: Path to the spec directory + + Returns: + True if security scan is required, False otherwise + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return False + + return assessment.validation.security_scan_required + + def requires_staging_deployment(self, spec_dir: Path) -> bool: + """ + Check if staging deployment is required. + + Args: + spec_dir: Path to the spec directory + + Returns: + True if staging deployment is required, False otherwise + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return False + + return assessment.validation.staging_deployment_required + + def get_risk_level(self, spec_dir: Path) -> str: + """ + Get the risk level for the task. + + Args: + spec_dir: Path to the spec directory + + Returns: + Risk level string (trivial, low, medium, high, critical) + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return "medium" # Default to medium when unknown + + return assessment.validation.risk_level + + def get_complexity(self, spec_dir: Path) -> str: + """ + Get the complexity level for the task. + + Args: + spec_dir: Path to the spec directory + + Returns: + Complexity level string (simple, standard, complex) + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return "standard" # Default to standard when unknown + + return assessment.complexity + + def get_validation_summary(self, spec_dir: Path) -> dict[str, Any]: + """ + Get a summary of validation requirements. + + Args: + spec_dir: Path to the spec directory + + Returns: + Dictionary with validation summary + """ + assessment = self.load_assessment(spec_dir) + if not assessment: + return { + "risk_level": "unknown", + "complexity": "unknown", + "skip_validation": False, + "minimal_mode": False, + "test_types": ["unit"], + "security_scan": False, + "staging_deployment": False, + "confidence": 0.0, + } + + return { + "risk_level": assessment.validation.risk_level, + "complexity": assessment.complexity, + "skip_validation": assessment.validation.skip_validation, + "minimal_mode": assessment.validation.minimal_mode, + "test_types": assessment.validation.test_types_required, + "security_scan": assessment.validation.security_scan_required, + "staging_deployment": assessment.validation.staging_deployment_required, + "confidence": assessment.confidence, + "reasoning": assessment.validation.reasoning, + } + + def clear_cache(self) -> None: + """Clear the internal cache of loaded assessments.""" + self._cache.clear() + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def load_risk_assessment(spec_dir: Path) -> RiskAssessment | None: + """ + Convenience function to load a risk assessment. + + Args: + spec_dir: Path to the spec directory + + Returns: + RiskAssessment object or None + """ + classifier = RiskClassifier() + return classifier.load_assessment(spec_dir) + + +def get_validation_requirements(spec_dir: Path) -> dict[str, Any]: + """ + Convenience function to get validation requirements. + + Args: + spec_dir: Path to the spec directory + + Returns: + Dictionary with validation requirements + """ + classifier = RiskClassifier() + return classifier.get_validation_summary(spec_dir) + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Load and display risk assessment") + parser.add_argument( + "spec_dir", + type=Path, + help="Path to spec directory with complexity_assessment.json", + ) + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + classifier = RiskClassifier() + summary = classifier.get_validation_summary(args.spec_dir) + + if args.json: + print(json.dumps(summary, indent=2)) + else: + print(f"Risk Level: {summary['risk_level']}") + print(f"Complexity: {summary['complexity']}") + print(f"Skip Validation: {summary['skip_validation']}") + print(f"Minimal Mode: {summary['minimal_mode']}") + print(f"Test Types: {', '.join(summary['test_types'])}") + print(f"Security Scan: {summary['security_scan']}") + print(f"Staging Deployment: {summary['staging_deployment']}") + print(f"Confidence: {summary['confidence']:.2f}") + if summary.get("reasoning"): + print(f"Reasoning: {summary['reasoning']}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/analysis/security_scanner.py b/auto-claude/analysis/security_scanner.py new file mode 100644 index 00000000..3b1b8b42 --- /dev/null +++ b/auto-claude/analysis/security_scanner.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +""" +Security Scanner Module +======================= + +Consolidates security scanning including secrets detection and SAST tools. +This module integrates the existing scan_secrets.py and provides a unified +interface for all security scanning. + +The security scanner is used by: +- QA Agent: To verify no secrets are committed +- Validation Strategy: To run security scans for high-risk changes + +Usage: + from analysis.security_scanner import SecurityScanner + + scanner = SecurityScanner() + results = scanner.scan(project_dir, spec_dir) + + if results.has_critical_issues: + print("Security issues found - blocking QA approval") +""" + +import json +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# Import the existing secrets scanner +try: + from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files + + HAS_SECRETS_SCANNER = True +except ImportError: + HAS_SECRETS_SCANNER = False + SecretMatch = None + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class SecurityVulnerability: + """ + Represents a security vulnerability found during scanning. + + Attributes: + severity: Severity level (critical, high, medium, low, info) + source: Which scanner found this (secrets, bandit, npm_audit, etc.) + title: Short title of the vulnerability + description: Detailed description + file: File where vulnerability was found (if applicable) + line: Line number (if applicable) + cwe: CWE identifier if available + """ + + severity: str # critical, high, medium, low, info + source: str # secrets, bandit, npm_audit, semgrep, etc. + title: str + description: str + file: str | None = None + line: int | None = None + cwe: str | None = None + + +@dataclass +class SecurityScanResult: + """ + Result of a security scan. + + Attributes: + secrets: List of detected secrets + vulnerabilities: List of security vulnerabilities + scan_errors: List of errors during scanning + has_critical_issues: Whether any critical issues were found + should_block_qa: Whether these results should block QA approval + """ + + secrets: list[dict[str, Any]] = field(default_factory=list) + vulnerabilities: list[SecurityVulnerability] = field(default_factory=list) + scan_errors: list[str] = field(default_factory=list) + has_critical_issues: bool = False + should_block_qa: bool = False + + +# ============================================================================= +# SECURITY SCANNER +# ============================================================================= + + +class SecurityScanner: + """ + Consolidates all security scanning operations. + + Integrates: + - scan_secrets.py for secrets detection + - Bandit for Python SAST (if available) + - npm audit for JavaScript vulnerabilities (if applicable) + """ + + def __init__(self) -> None: + """Initialize the security scanner.""" + self._bandit_available: bool | None = None + self._npm_available: bool | None = None + + def scan( + self, + project_dir: Path, + spec_dir: Path | None = None, + changed_files: list[str] | None = None, + run_secrets: bool = True, + run_sast: bool = True, + run_dependency_audit: bool = True, + ) -> SecurityScanResult: + """ + Run all applicable security scans. + + Args: + project_dir: Path to the project root + spec_dir: Path to the spec directory (for storing results) + changed_files: Optional list of files to scan (if None, scans all) + run_secrets: Whether to run secrets scanning + run_sast: Whether to run SAST tools + run_dependency_audit: Whether to run dependency audits + + Returns: + SecurityScanResult with all findings + """ + project_dir = Path(project_dir) + result = SecurityScanResult() + + # Run secrets scan + if run_secrets: + self._run_secrets_scan(project_dir, changed_files, result) + + # Run SAST based on project type + if run_sast: + self._run_sast_scans(project_dir, result) + + # Run dependency audits + if run_dependency_audit: + self._run_dependency_audits(project_dir, result) + + # Determine if should block QA + result.has_critical_issues = ( + any(v.severity in ["critical", "high"] for v in result.vulnerabilities) + or len(result.secrets) > 0 + ) + + # Any secrets always block, critical vulnerabilities block + result.should_block_qa = len(result.secrets) > 0 or any( + v.severity == "critical" for v in result.vulnerabilities + ) + + # Save results if spec_dir provided + if spec_dir: + self._save_results(spec_dir, result) + + return result + + def _run_secrets_scan( + self, + project_dir: Path, + changed_files: list[str] | None, + result: SecurityScanResult, + ) -> None: + """Run secrets scanning using scan_secrets.py.""" + if not HAS_SECRETS_SCANNER: + result.scan_errors.append("scan_secrets module not available") + return + + try: + # Get files to scan + if changed_files: + files_to_scan = changed_files + else: + files_to_scan = get_all_tracked_files() + + # Run scan + matches = scan_files(files_to_scan, project_dir) + + # Convert matches to result format + for match in matches: + result.secrets.append( + { + "file": match.file_path, + "line": match.line_number, + "pattern": match.pattern_name, + "matched_text": self._redact_secret(match.matched_text), + } + ) + + # Also add as vulnerability + result.vulnerabilities.append( + SecurityVulnerability( + severity="critical", + source="secrets", + title=f"Potential secret: {match.pattern_name}", + description=f"Found potential {match.pattern_name} in file", + file=match.file_path, + line=match.line_number, + ) + ) + + except Exception as e: + result.scan_errors.append(f"Secrets scan error: {str(e)}") + + def _run_sast_scans(self, project_dir: Path, result: SecurityScanResult) -> None: + """Run SAST tools based on project type.""" + # Python SAST with Bandit + if self._is_python_project(project_dir): + self._run_bandit(project_dir, result) + + # JavaScript/Node.js - npm audit + # (handled in dependency audits for Node projects) + + def _run_bandit(self, project_dir: Path, result: SecurityScanResult) -> None: + """Run Bandit security scanner for Python projects.""" + if not self._check_bandit_available(): + return + + try: + # Find Python source directories + src_dirs = [] + for candidate in ["src", "app", project_dir.name, "."]: + candidate_path = project_dir / candidate + if ( + candidate_path.exists() + and (candidate_path / "__init__.py").exists() + ): + src_dirs.append(str(candidate_path)) + + if not src_dirs: + # Try to find any Python files + py_files = list(project_dir.glob("**/*.py")) + if not py_files: + return + src_dirs = ["."] + + # Run bandit + cmd = [ + "bandit", + "-r", + *src_dirs, + "-f", + "json", + "--exit-zero", # Don't fail on findings + ] + + proc = subprocess.run( + cmd, + cwd=project_dir, + capture_output=True, + text=True, + timeout=120, + ) + + if proc.stdout: + try: + bandit_output = json.loads(proc.stdout) + for finding in bandit_output.get("results", []): + severity = finding.get("issue_severity", "MEDIUM").lower() + if severity == "high": + severity = "high" + elif severity == "medium": + severity = "medium" + else: + severity = "low" + + result.vulnerabilities.append( + SecurityVulnerability( + severity=severity, + source="bandit", + title=finding.get("issue_text", "Unknown issue"), + description=finding.get("issue_text", ""), + file=finding.get("filename"), + line=finding.get("line_number"), + cwe=finding.get("issue_cwe", {}).get("id"), + ) + ) + except json.JSONDecodeError: + result.scan_errors.append("Failed to parse Bandit output") + + except subprocess.TimeoutExpired: + result.scan_errors.append("Bandit scan timed out") + except FileNotFoundError: + result.scan_errors.append("Bandit not found") + except Exception as e: + result.scan_errors.append(f"Bandit error: {str(e)}") + + def _run_dependency_audits( + self, project_dir: Path, result: SecurityScanResult + ) -> None: + """Run dependency vulnerability audits.""" + # npm audit for JavaScript projects + if (project_dir / "package.json").exists(): + self._run_npm_audit(project_dir, result) + + # pip-audit for Python projects (if available) + if self._is_python_project(project_dir): + self._run_pip_audit(project_dir, result) + + def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None: + """Run npm audit for JavaScript projects.""" + try: + cmd = ["npm", "audit", "--json"] + + proc = subprocess.run( + cmd, + cwd=project_dir, + capture_output=True, + text=True, + timeout=120, + ) + + if proc.stdout: + try: + audit_output = json.loads(proc.stdout) + + # npm audit v2+ format + vulnerabilities = audit_output.get("vulnerabilities", {}) + for pkg_name, vuln_info in vulnerabilities.items(): + severity = vuln_info.get("severity", "moderate") + if severity == "critical": + severity = "critical" + elif severity == "high": + severity = "high" + elif severity == "moderate": + severity = "medium" + else: + severity = "low" + + result.vulnerabilities.append( + SecurityVulnerability( + severity=severity, + source="npm_audit", + title=f"Vulnerable dependency: {pkg_name}", + description=vuln_info.get("via", [{}])[0].get( + "title", "" + ) + if isinstance(vuln_info.get("via"), list) + and vuln_info.get("via") + else str(vuln_info.get("via", "")), + file="package.json", + ) + ) + except json.JSONDecodeError: + pass # npm audit may return invalid JSON on no findings + + except subprocess.TimeoutExpired: + result.scan_errors.append("npm audit timed out") + except FileNotFoundError: + pass # npm not available + except Exception as e: + result.scan_errors.append(f"npm audit error: {str(e)}") + + def _run_pip_audit(self, project_dir: Path, result: SecurityScanResult) -> None: + """Run pip-audit for Python projects (if available).""" + try: + cmd = ["pip-audit", "--format", "json"] + + proc = subprocess.run( + cmd, + cwd=project_dir, + capture_output=True, + text=True, + timeout=120, + ) + + if proc.stdout: + try: + audit_output = json.loads(proc.stdout) + for vuln in audit_output: + severity = "high" if vuln.get("fix_versions") else "medium" + + result.vulnerabilities.append( + SecurityVulnerability( + severity=severity, + source="pip_audit", + title=f"Vulnerable package: {vuln.get('name')}", + description=vuln.get("description", ""), + cwe=vuln.get("aliases", [""])[0] + if vuln.get("aliases") + else None, + ) + ) + except json.JSONDecodeError: + pass + + except FileNotFoundError: + pass # pip-audit not available + except subprocess.TimeoutExpired: + pass + except Exception: + pass + + def _is_python_project(self, project_dir: Path) -> bool: + """Check if this is a Python project.""" + indicators = [ + project_dir / "pyproject.toml", + project_dir / "requirements.txt", + project_dir / "setup.py", + project_dir / "setup.cfg", + ] + return any(p.exists() for p in indicators) + + def _check_bandit_available(self) -> bool: + """Check if Bandit is available.""" + if self._bandit_available is None: + try: + subprocess.run( + ["bandit", "--version"], + capture_output=True, + timeout=5, + ) + self._bandit_available = True + except (FileNotFoundError, subprocess.TimeoutExpired): + self._bandit_available = False + return self._bandit_available + + def _redact_secret(self, text: str) -> str: + """Redact a secret for safe logging.""" + if len(text) <= 8: + return "*" * len(text) + return text[:4] + "*" * (len(text) - 8) + text[-4:] + + def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None: + """Save scan results to spec directory.""" + spec_dir = Path(spec_dir) + spec_dir.mkdir(parents=True, exist_ok=True) + + output_file = spec_dir / "security_scan_results.json" + output_data = self.to_dict(result) + + with open(output_file, "w", encoding="utf-8") as f: + json.dump(output_data, f, indent=2) + + def to_dict(self, result: SecurityScanResult) -> dict[str, Any]: + """Convert result to dictionary for JSON serialization.""" + return { + "secrets": result.secrets, + "vulnerabilities": [ + { + "severity": v.severity, + "source": v.source, + "title": v.title, + "description": v.description, + "file": v.file, + "line": v.line, + "cwe": v.cwe, + } + for v in result.vulnerabilities + ], + "scan_errors": result.scan_errors, + "has_critical_issues": result.has_critical_issues, + "should_block_qa": result.should_block_qa, + "summary": { + "total_secrets": len(result.secrets), + "total_vulnerabilities": len(result.vulnerabilities), + "critical_count": sum( + 1 for v in result.vulnerabilities if v.severity == "critical" + ), + "high_count": sum( + 1 for v in result.vulnerabilities if v.severity == "high" + ), + "medium_count": sum( + 1 for v in result.vulnerabilities if v.severity == "medium" + ), + "low_count": sum( + 1 for v in result.vulnerabilities if v.severity == "low" + ), + }, + } + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def scan_for_security_issues( + project_dir: Path, + spec_dir: Path | None = None, + changed_files: list[str] | None = None, +) -> SecurityScanResult: + """ + Convenience function to run security scan. + + Args: + project_dir: Path to project root + spec_dir: Optional spec directory to save results + changed_files: Optional list of files to scan + + Returns: + SecurityScanResult with all findings + """ + scanner = SecurityScanner() + return scanner.scan(project_dir, spec_dir, changed_files) + + +def has_security_issues(project_dir: Path) -> bool: + """ + Quick check if project has security issues. + + Args: + project_dir: Path to project root + + Returns: + True if any critical/high issues found + """ + scanner = SecurityScanner() + result = scanner.scan(project_dir, run_sast=False, run_dependency_audit=False) + return result.has_critical_issues + + +def scan_secrets_only( + project_dir: Path, + changed_files: list[str] | None = None, +) -> list[dict[str, Any]]: + """ + Scan only for secrets (quick scan). + + Args: + project_dir: Path to project root + changed_files: Optional list of files to scan + + Returns: + List of detected secrets + """ + scanner = SecurityScanner() + result = scanner.scan( + project_dir, + changed_files=changed_files, + run_sast=False, + run_dependency_audit=False, + ) + return result.secrets + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Run security scans") + parser.add_argument("project_dir", type=Path, help="Path to project root") + parser.add_argument("--spec-dir", type=Path, help="Path to spec directory") + parser.add_argument( + "--secrets-only", action="store_true", help="Only scan for secrets" + ) + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + scanner = SecurityScanner() + result = scanner.scan( + args.project_dir, + spec_dir=args.spec_dir, + run_sast=not args.secrets_only, + run_dependency_audit=not args.secrets_only, + ) + + if args.json: + print(json.dumps(scanner.to_dict(result), indent=2)) + else: + print(f"Secrets Found: {len(result.secrets)}") + print(f"Vulnerabilities: {len(result.vulnerabilities)}") + print(f"Has Critical Issues: {result.has_critical_issues}") + print(f"Should Block QA: {result.should_block_qa}") + + if result.secrets: + print("\nSecrets Detected:") + for secret in result.secrets: + print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}") + + if result.vulnerabilities: + print(f"\nVulnerabilities ({len(result.vulnerabilities)}):") + for v in result.vulnerabilities: + print(f" [{v.severity.upper()}] {v.title}") + if v.file: + print(f" File: {v.file}:{v.line or ''}") + + if result.scan_errors: + print(f"\nScan Errors ({len(result.scan_errors)}):") + for error in result.scan_errors: + print(f" - {error}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/analysis/test_discovery.py b/auto-claude/analysis/test_discovery.py new file mode 100644 index 00000000..9cd1c689 --- /dev/null +++ b/auto-claude/analysis/test_discovery.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +""" +Test Discovery Module +===================== + +Detects test frameworks, test commands, and test directories in a project. +This module analyzes project configuration files to discover how tests +should be run. + +The test discovery results are used by: +- QA Agent: To determine what test commands to run +- Test Creator: To know what framework to use when creating tests +- Planner: To include correct test commands in verification strategy + +Usage: + from test_discovery import TestDiscovery + + discovery = TestDiscovery() + result = discovery.discover(project_dir) + + print(f"Test frameworks: {result['frameworks']}") + print(f"Test command: {result['test_command']}") +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class TestFramework: + """ + Represents a detected test framework. + + Attributes: + name: Name of the framework (e.g., "pytest", "jest", "vitest") + type: Type of testing (unit, integration, e2e, all) + command: Command to run tests + config_file: Configuration file if found + version: Version if detected + coverage_command: Command for coverage if available + """ + + __test__ = False # Prevent pytest from collecting this as a test class + + name: str + type: str # unit, integration, e2e, all + command: str + config_file: str | None = None + version: str | None = None + coverage_command: str | None = None + + +@dataclass +class TestDiscoveryResult: + """ + Result of test framework discovery. + + Attributes: + frameworks: List of detected test frameworks + test_command: Primary test command to run + test_directories: Discovered test directories + package_manager: Detected package manager + has_tests: Whether any test files were found + coverage_command: Command for coverage if available + """ + + __test__ = False # Prevent pytest from collecting this as a test class + + frameworks: list[TestFramework] = field(default_factory=list) + test_command: str = "" + test_directories: list[str] = field(default_factory=list) + package_manager: str = "" + has_tests: bool = False + coverage_command: str | None = None + + +# ============================================================================= +# FRAMEWORK DETECTORS +# ============================================================================= + + +# Pattern-based framework detection +FRAMEWORK_PATTERNS = { + # JavaScript/TypeScript + "jest": { + "config_files": [ + "jest.config.js", + "jest.config.ts", + "jest.config.mjs", + "jest.config.cjs", + ], + "package_key": "jest", + "type": "unit", + "command": "npx jest", + "coverage_command": "npx jest --coverage", + }, + "vitest": { + "config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"], + "package_key": "vitest", + "type": "unit", + "command": "npx vitest run", + "coverage_command": "npx vitest run --coverage", + }, + "mocha": { + "config_files": [ + ".mocharc.js", + ".mocharc.json", + ".mocharc.yaml", + ".mocharc.yml", + ], + "package_key": "mocha", + "type": "unit", + "command": "npx mocha", + "coverage_command": "npx nyc mocha", + }, + "playwright": { + "config_files": ["playwright.config.js", "playwright.config.ts"], + "package_key": "@playwright/test", + "type": "e2e", + "command": "npx playwright test", + "coverage_command": None, + }, + "cypress": { + "config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"], + "package_key": "cypress", + "type": "e2e", + "command": "npx cypress run", + "coverage_command": None, + }, + # Python + "pytest": { + "config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"], + "pyproject_key": "pytest", + "requirements_key": "pytest", + "type": "all", + "command": "pytest", + "coverage_command": "pytest --cov", + }, + "unittest": { + "config_files": [], + "type": "unit", + "command": "python -m unittest discover", + "coverage_command": "coverage run -m unittest discover", + }, + # Rust + "cargo_test": { + "config_files": ["Cargo.toml"], + "type": "all", + "command": "cargo test", + "coverage_command": "cargo tarpaulin", + }, + # Go + "go_test": { + "config_files": ["go.mod"], + "type": "all", + "command": "go test ./...", + "coverage_command": "go test -cover ./...", + }, + # Ruby + "rspec": { + "config_files": [".rspec", "spec/spec_helper.rb"], + "gemfile_key": "rspec", + "type": "all", + "command": "bundle exec rspec", + "coverage_command": "bundle exec rspec --format documentation", + }, + "minitest": { + "config_files": [], + "gemfile_key": "minitest", + "type": "unit", + "command": "bundle exec rake test", + "coverage_command": None, + }, +} + + +# ============================================================================= +# TEST DISCOVERY +# ============================================================================= + + +class TestDiscovery: + """ + Discovers test frameworks and configurations in a project. + + Analyzes: + - Package files (package.json, pyproject.toml, Cargo.toml, etc.) + - Configuration files (jest.config.js, pytest.ini, etc.) + - Directory structure (tests/, spec/, __tests__/) + """ + + __test__ = False # Prevent pytest from collecting this as a test class + + def __init__(self) -> None: + """Initialize the test discovery.""" + self._cache: dict[str, TestDiscoveryResult] = {} + + def discover(self, project_dir: Path) -> TestDiscoveryResult: + """ + Discover test frameworks and configuration in the project. + + Args: + project_dir: Path to the project root + + Returns: + TestDiscoveryResult with detected frameworks and commands + """ + project_dir = Path(project_dir) + cache_key = str(project_dir.resolve()) + + if cache_key in self._cache: + return self._cache[cache_key] + + result = TestDiscoveryResult() + + # Detect package manager + result.package_manager = self._detect_package_manager(project_dir) + + # Discover frameworks based on project type + if (project_dir / "package.json").exists(): + self._discover_js_frameworks(project_dir, result) + + # Check for Python project indicators + python_indicators = [ + project_dir / "pyproject.toml", + project_dir / "requirements.txt", + project_dir / "setup.py", + project_dir / "pytest.ini", + project_dir / "conftest.py", + project_dir / "tests" / "conftest.py", + ] + if any(p.exists() for p in python_indicators): + self._discover_python_frameworks(project_dir, result) + + if (project_dir / "Cargo.toml").exists(): + self._discover_rust_frameworks(project_dir, result) + if (project_dir / "go.mod").exists(): + self._discover_go_frameworks(project_dir, result) + if (project_dir / "Gemfile").exists(): + self._discover_ruby_frameworks(project_dir, result) + + # Find test directories + result.test_directories = self._find_test_directories(project_dir) + + # Check if tests exist + result.has_tests = self._has_test_files(project_dir, result.test_directories) + + # Set primary test command + if result.frameworks: + result.test_command = result.frameworks[0].command + + # Set coverage command from first framework that has one + if not result.coverage_command: + for framework in result.frameworks: + if framework.coverage_command: + result.coverage_command = framework.coverage_command + break + + self._cache[cache_key] = result + return result + + def _detect_package_manager(self, project_dir: Path) -> str: + """Detect the package manager used by the project.""" + if (project_dir / "pnpm-lock.yaml").exists(): + return "pnpm" + if (project_dir / "yarn.lock").exists(): + return "yarn" + if (project_dir / "package-lock.json").exists(): + return "npm" + if (project_dir / "bun.lockb").exists(): + return "bun" + if (project_dir / "uv.lock").exists(): + return "uv" + if (project_dir / "poetry.lock").exists(): + return "poetry" + if (project_dir / "Pipfile.lock").exists(): + return "pipenv" + if (project_dir / "Cargo.lock").exists(): + return "cargo" + if (project_dir / "go.sum").exists(): + return "go" + if (project_dir / "Gemfile.lock").exists(): + return "bundler" + return "" + + def _discover_js_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover JavaScript/TypeScript test frameworks.""" + package_json = project_dir / "package.json" + if not package_json.exists(): + return + + try: + with open(package_json, encoding="utf-8") as f: + pkg = json.load(f) + except (OSError, json.JSONDecodeError): + return + + deps = pkg.get("dependencies", {}) + dev_deps = pkg.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + scripts = pkg.get("scripts", {}) + + # Check for test frameworks in dependencies + for name, pattern in FRAMEWORK_PATTERNS.items(): + if "package_key" not in pattern: + continue + + if pattern["package_key"] in all_deps: + # Check for config file + config_file = None + for cf in pattern.get("config_files", []): + if (project_dir / cf).exists(): + config_file = cf + break + + # Get version + version = all_deps.get(pattern["package_key"], "") + if version.startswith("^") or version.startswith("~"): + version = version[1:] + + # Determine command - prefer npm scripts if available + command = pattern["command"] + if "test" in scripts and pattern["package_key"] in scripts.get( + "test", "" + ): + command = f"{result.package_manager or 'npm'} test" + + result.frameworks.append( + TestFramework( + name=name, + type=pattern["type"], + command=command, + config_file=config_file, + version=version, + coverage_command=pattern.get("coverage_command"), + ) + ) + + # Check npm scripts for test commands + if not result.frameworks and "test" in scripts: + test_script = scripts["test"] + if ( + test_script + and test_script != 'echo "Error: no test specified" && exit 1' + ): + # Try to infer framework from script + framework_name = "npm_test" + framework_type = "unit" + + if "jest" in test_script: + framework_name = "jest" + elif "vitest" in test_script: + framework_name = "vitest" + elif "mocha" in test_script: + framework_name = "mocha" + elif "playwright" in test_script: + framework_name = "playwright" + framework_type = "e2e" + elif "cypress" in test_script: + framework_name = "cypress" + framework_type = "e2e" + + result.frameworks.append( + TestFramework( + name=framework_name, + type=framework_type, + command=f"{result.package_manager or 'npm'} test", + config_file=None, + ) + ) + + def _discover_python_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover Python test frameworks.""" + # Check for pytest.ini first (explicit pytest config) + if (project_dir / "pytest.ini").exists(): + if not any(f.name == "pytest" for f in result.frameworks): + result.frameworks.append( + TestFramework( + name="pytest", + type="all", + command="pytest", + config_file="pytest.ini", + ) + ) + + # Check pyproject.toml + pyproject = project_dir / "pyproject.toml" + if pyproject.exists(): + content = pyproject.read_text() + + # Check for pytest + if "pytest" in content: + if not any(f.name == "pytest" for f in result.frameworks): + config_file = ( + "pyproject.toml" if "[tool.pytest" in content else None + ) + result.frameworks.append( + TestFramework( + name="pytest", + type="all", + command="pytest", + config_file=config_file, + ) + ) + + # Check requirements.txt + requirements = project_dir / "requirements.txt" + if requirements.exists(): + content = requirements.read_text().lower() + if "pytest" in content and not any( + f.name == "pytest" for f in result.frameworks + ): + result.frameworks.append( + TestFramework( + name="pytest", + type="all", + command="pytest", + config_file=None, + ) + ) + + # Check for conftest.py (pytest marker) + conftest_root = project_dir / "conftest.py" + conftest_tests = project_dir / "tests" / "conftest.py" + if conftest_root.exists() or conftest_tests.exists(): + if not any(f.name == "pytest" for f in result.frameworks): + result.frameworks.append( + TestFramework( + name="pytest", + type="all", + command="pytest", + config_file="conftest.py", + ) + ) + + # Fall back to unittest if test files exist but no framework detected + if not result.frameworks: + test_dirs = self._find_test_directories(project_dir) + if test_dirs: + result.frameworks.append( + TestFramework( + name="unittest", + type="unit", + command="python -m unittest discover", + config_file=None, + ) + ) + + def _discover_rust_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover Rust test frameworks.""" + cargo_toml = project_dir / "Cargo.toml" + if cargo_toml.exists(): + result.frameworks.append( + TestFramework( + name="cargo_test", + type="all", + command="cargo test", + config_file="Cargo.toml", + ) + ) + + def _discover_go_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover Go test frameworks.""" + go_mod = project_dir / "go.mod" + if go_mod.exists(): + result.frameworks.append( + TestFramework( + name="go_test", + type="all", + command="go test ./...", + config_file="go.mod", + ) + ) + + def _discover_ruby_frameworks( + self, project_dir: Path, result: TestDiscoveryResult + ) -> None: + """Discover Ruby test frameworks.""" + gemfile = project_dir / "Gemfile" + if not gemfile.exists(): + return + + content = gemfile.read_text().lower() + + if "rspec" in content or (project_dir / ".rspec").exists(): + result.frameworks.append( + TestFramework( + name="rspec", + type="all", + command="bundle exec rspec", + config_file=".rspec" if (project_dir / ".rspec").exists() else None, + ) + ) + elif "minitest" in content: + result.frameworks.append( + TestFramework( + name="minitest", + type="unit", + command="bundle exec rake test", + config_file=None, + ) + ) + + def _find_test_directories(self, project_dir: Path) -> list[str]: + """Find test directories in the project.""" + test_dir_patterns = [ + "tests", + "test", + "spec", + "__tests__", + "specs", + "test_*", + ] + + found_dirs = [] + for pattern in test_dir_patterns: + if pattern.endswith("*"): + # Glob pattern + for d in project_dir.glob(pattern): + if d.is_dir(): + found_dirs.append(str(d.relative_to(project_dir))) + else: + # Exact name + test_dir = project_dir / pattern + if test_dir.is_dir(): + found_dirs.append(pattern) + + return found_dirs + + def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool: + """Check if any test files exist.""" + test_file_patterns = [ + "**/test_*.py", + "**/*_test.py", + "**/*.test.js", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/test_*.go", + "**/*_test.go", + "**/*_test.rs", + "**/spec/**/*_spec.rb", + ] + + # Check in test directories + for test_dir in test_directories: + test_path = project_dir / test_dir + if test_path.exists(): + for pattern in test_file_patterns: + if list(test_path.glob(pattern.replace("**/", ""))): + return True + + # Check project-wide + for pattern in test_file_patterns: + if list(project_dir.glob(pattern)): + return True + + return False + + def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]: + """Convert result to dictionary for JSON serialization.""" + return { + "frameworks": [ + { + "name": f.name, + "type": f.type, + "command": f.command, + "config_file": f.config_file, + "version": f.version, + "coverage_command": f.coverage_command, + } + for f in result.frameworks + ], + "test_command": result.test_command, + "test_directories": result.test_directories, + "package_manager": result.package_manager, + "has_tests": result.has_tests, + "coverage_command": result.coverage_command, + } + + def clear_cache(self) -> None: + """Clear the internal cache.""" + self._cache.clear() + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def discover_tests(project_dir: Path) -> TestDiscoveryResult: + """ + Convenience function to discover tests in a project. + + Args: + project_dir: Path to project root + + Returns: + TestDiscoveryResult with detected frameworks + """ + discovery = TestDiscovery() + return discovery.discover(project_dir) + + +def get_test_command(project_dir: Path) -> str: + """ + Get the primary test command for a project. + + Args: + project_dir: Path to project root + + Returns: + Test command string, or empty string if not found + """ + discovery = TestDiscovery() + result = discovery.discover(project_dir) + return result.test_command + + +def get_test_frameworks(project_dir: Path) -> list[str]: + """ + Get list of test framework names in a project. + + Args: + project_dir: Path to project root + + Returns: + List of framework names + """ + discovery = TestDiscovery() + result = discovery.discover(project_dir) + return [f.name for f in result.frameworks] + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Discover test frameworks") + parser.add_argument("project_dir", type=Path, help="Path to project root") + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + discovery = TestDiscovery() + result = discovery.discover(args.project_dir) + + if args.json: + print(json.dumps(discovery.to_dict(result), indent=2)) + else: + print(f"Package Manager: {result.package_manager or 'unknown'}") + print(f"Has Tests: {result.has_tests}") + print(f"Test Command: {result.test_command or 'none'}") + print(f"Test Directories: {', '.join(result.test_directories) or 'none'}") + print(f"Coverage Command: {result.coverage_command or 'none'}") + print(f"\nFrameworks ({len(result.frameworks)}):") + for f in result.frameworks: + print(f" - {f.name} ({f.type})") + print(f" Command: {f.command}") + if f.config_file: + print(f" Config: {f.config_file}") + if f.version: + print(f" Version: {f.version}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/analyzer.py b/auto-claude/analyzer.py index 3513fa14..99b7ddb8 100644 --- a/auto-claude/analyzer.py +++ b/auto-claude/analyzer.py @@ -1,100 +1,2 @@ -#!/usr/bin/env python3 -""" -Codebase Analyzer -================= - -Automatically detects project structure, frameworks, and services. -Supports monorepos with multiple services. - -Usage: - # Index entire project (creates project_index.json) - python auto-claude/analyzer.py --index - - # Analyze specific service - python auto-claude/analyzer.py --service backend - - # Output to specific file - python auto-claude/analyzer.py --index --output path/to/output.json - -The analyzer will: -1. Detect if this is a monorepo or single project -2. Find all services/packages and analyze each separately -3. Map interdependencies between services -4. Identify infrastructure (Docker, CI/CD) -5. Document conventions (linting, testing) - -This module now serves as a facade to the modular analyzer system in the analyzers/ package. -All actual implementation is in focused submodules for better maintainability. -""" - -import json -from pathlib import Path - -# Import from the new modular structure -from analyzers import ( - ProjectAnalyzer, - ServiceAnalyzer, - analyze_project, - analyze_service, -) - -# Re-export for backward compatibility -__all__ = [ - "ServiceAnalyzer", - "ProjectAnalyzer", - "analyze_project", - "analyze_service", -] - - -def main(): - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser( - description="Analyze project structure, frameworks, and services" - ) - parser.add_argument( - "--project-dir", - type=Path, - default=Path.cwd(), - help="Project directory to analyze (default: current directory)", - ) - parser.add_argument( - "--index", - action="store_true", - help="Create full project index (default behavior)", - ) - parser.add_argument( - "--service", - type=str, - default=None, - help="Analyze a specific service only", - ) - parser.add_argument( - "--output", - type=Path, - default=None, - help="Output file for JSON results", - ) - parser.add_argument( - "--quiet", - action="store_true", - help="Only output JSON, no status messages", - ) - - args = parser.parse_args() - - # Determine what to analyze - if args.service: - results = analyze_service(args.project_dir, args.service, args.output) - else: - results = analyze_project(args.project_dir, args.output) - - # Print results - if not args.quiet or not args.output: - print(json.dumps(results, indent=2)) - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from analysis.analyzer instead.""" +from analysis.analyzer import * diff --git a/auto-claude/analyzers/__init__.py b/auto-claude/analyzers/__init__.py index b425b119..72fc2eff 100644 --- a/auto-claude/analyzers/__init__.py +++ b/auto-claude/analyzers/__init__.py @@ -1,92 +1,2 @@ -""" -Analyzers Package -================= - -Modular analyzer system for detecting project structure, frameworks, and services. - -Main exports: -- ServiceAnalyzer: Analyzes a single service/package -- ProjectAnalyzer: Analyzes entire projects (single or monorepo) -- analyze_project: Convenience function for project analysis -- analyze_service: Convenience function for service analysis -""" - -from pathlib import Path -from typing import Any - -from .project_analyzer_module import ProjectAnalyzer -from .service_analyzer import ServiceAnalyzer - -# Re-export main classes -__all__ = [ - "ServiceAnalyzer", - "ProjectAnalyzer", - "analyze_project", - "analyze_service", -] - - -def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict: - """ - Analyze a project and optionally save results. - - Args: - project_dir: Path to the project root - output_file: Optional path to save JSON output - - Returns: - Project index as a dictionary - """ - import json - - analyzer = ProjectAnalyzer(project_dir) - results = analyzer.analyze() - - if output_file: - output_file.parent.mkdir(parents=True, exist_ok=True) - with open(output_file, "w") as f: - json.dump(results, f, indent=2) - print(f"Project index saved to: {output_file}") - - return results - - -def analyze_service( - project_dir: Path, service_name: str, output_file: Path | None = None -) -> dict: - """ - Analyze a specific service within a project. - - Args: - project_dir: Path to the project root - service_name: Name of the service to analyze - output_file: Optional path to save JSON output - - Returns: - Service analysis as a dictionary - """ - import json - - # Find the service - service_path = project_dir / service_name - if not service_path.exists(): - # Check common locations - for parent in ["packages", "apps", "services"]: - candidate = project_dir / parent / service_name - if candidate.exists(): - service_path = candidate - break - - if not service_path.exists(): - raise ValueError(f"Service '{service_name}' not found in {project_dir}") - - analyzer = ServiceAnalyzer(service_path, service_name) - results = analyzer.analyze() - - if output_file: - output_file.parent.mkdir(parents=True, exist_ok=True) - with open(output_file, "w") as f: - json.dump(results, f, indent=2) - print(f"Service analysis saved to: {output_file}") - - return results +"""Backward compatibility shim - import from analysis.analyzers instead.""" +from analysis.analyzers import * diff --git a/auto-claude/auto_claude_tools.py b/auto-claude/auto_claude_tools.py index cd4e18bb..5c7cee4e 100644 --- a/auto-claude/auto_claude_tools.py +++ b/auto-claude/auto_claude_tools.py @@ -1,45 +1,13 @@ -""" -Custom MCP Tools for Auto-Claude Agents -======================================== +"""Backward compatibility shim - import from agents.tools_pkg instead.""" -DEPRECATED: This module is now a compatibility shim. -Please import from the auto_claude_tools package instead: +# Direct import to avoid triggering agents.__init__ circular dependencies +import sys +from pathlib import Path - from auto_claude_tools import create_auto_claude_mcp_server, get_allowed_tools +# Add agents directory to path if needed +agents_dir = Path(__file__).parent / "agents" +if str(agents_dir) not in sys.path: + sys.path.insert(0, str(agents_dir)) -This file remains for backward compatibility with existing imports. -All functionality has been moved to the auto_claude_tools package for better -organization and maintainability. -""" - -# Import everything from the package to maintain backward compatibility -from auto_claude_tools import ( - ELECTRON_TOOLS, - TOOL_GET_BUILD_PROGRESS, - TOOL_GET_SESSION_CONTEXT, - TOOL_RECORD_DISCOVERY, - TOOL_RECORD_GOTCHA, - TOOL_UPDATE_QA_STATUS, - TOOL_UPDATE_SUBTASK_STATUS, - create_auto_claude_mcp_server, - get_allowed_tools, - is_electron_mcp_enabled, - is_tools_available, -) - -__all__ = [ - # Main API - "create_auto_claude_mcp_server", - "get_allowed_tools", - "is_tools_available", - # Tool name constants - "TOOL_UPDATE_SUBTASK_STATUS", - "TOOL_GET_BUILD_PROGRESS", - "TOOL_RECORD_DISCOVERY", - "TOOL_RECORD_GOTCHA", - "TOOL_GET_SESSION_CONTEXT", - "TOOL_UPDATE_QA_STATUS", - # Electron MCP - "ELECTRON_TOOLS", - "is_electron_mcp_enabled", -] +# Import directly from tools_pkg to avoid agents.__init__ circular imports +from tools_pkg import * # noqa: F403, E402 diff --git a/auto-claude/ci_discovery.py b/auto-claude/ci_discovery.py index 347546c4..a034aa69 100644 --- a/auto-claude/ci_discovery.py +++ b/auto-claude/ci_discovery.py @@ -1,587 +1,20 @@ -#!/usr/bin/env python3 -""" -CI Discovery Module -=================== - -Parses CI/CD configuration files to extract test commands and workflows. -Supports GitHub Actions, GitLab CI, CircleCI, and Jenkins. - -The CI discovery results are used by: -- QA Agent: To understand existing CI test patterns -- Validation Strategy: To match CI commands -- Planner: To align verification with CI - -Usage: - from ci_discovery import CIDiscovery - - discovery = CIDiscovery() - result = discovery.discover(project_dir) - - if result: - print(f"CI System: {result.ci_system}") - print(f"Test Commands: {result.test_commands}") -""" - -import json -import re -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# Try to import yaml, fall back gracefully -try: - import yaml - - HAS_YAML = True -except ImportError: - HAS_YAML = False - - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class CIWorkflow: - """ - Represents a CI workflow or job. - - Attributes: - name: Name of the workflow/job - trigger: What triggers this workflow (push, pull_request, etc.) - steps: List of step names or commands - test_related: Whether this appears to be test-related - """ - - name: str - trigger: list[str] = field(default_factory=list) - steps: list[str] = field(default_factory=list) - test_related: bool = False - - -@dataclass -class CIConfig: - """ - Result of CI configuration discovery. - - Attributes: - ci_system: Name of CI system (github_actions, gitlab, circleci, jenkins) - config_files: List of CI config files found - test_commands: Extracted test commands by type - coverage_command: Coverage command if found - workflows: List of discovered workflows - environment_variables: Environment variables used - """ - - ci_system: str - config_files: list[str] = field(default_factory=list) - test_commands: dict[str, str] = field(default_factory=dict) - coverage_command: str | None = None - workflows: list[CIWorkflow] = field(default_factory=list) - environment_variables: list[str] = field(default_factory=list) - - -# ============================================================================= -# CI PARSERS -# ============================================================================= - - -class CIDiscovery: - """ - Discovers CI/CD configurations in a project. - - Analyzes: - - GitHub Actions (.github/workflows/*.yml) - - GitLab CI (.gitlab-ci.yml) - - CircleCI (.circleci/config.yml) - - Jenkins (Jenkinsfile) - """ - - def __init__(self) -> None: - """Initialize CI discovery.""" - self._cache: dict[str, CIConfig | None] = {} - - def discover(self, project_dir: Path) -> CIConfig | None: - """ - Discover CI configuration in the project. - - Args: - project_dir: Path to the project root - - Returns: - CIConfig if CI found, None otherwise - """ - project_dir = Path(project_dir) - cache_key = str(project_dir.resolve()) - - if cache_key in self._cache: - return self._cache[cache_key] - - # Try each CI system - result = None - - # GitHub Actions - github_workflows = project_dir / ".github" / "workflows" - if github_workflows.exists(): - result = self._parse_github_actions(github_workflows) - - # GitLab CI - if not result: - gitlab_ci = project_dir / ".gitlab-ci.yml" - if gitlab_ci.exists(): - result = self._parse_gitlab_ci(gitlab_ci) - - # CircleCI - if not result: - circleci = project_dir / ".circleci" / "config.yml" - if circleci.exists(): - result = self._parse_circleci(circleci) - - # Jenkins - if not result: - jenkinsfile = project_dir / "Jenkinsfile" - if jenkinsfile.exists(): - result = self._parse_jenkinsfile(jenkinsfile) - - self._cache[cache_key] = result - return result - - def _parse_github_actions(self, workflows_dir: Path) -> CIConfig: - """Parse GitHub Actions workflow files.""" - result = CIConfig(ci_system="github_actions") - - workflow_files = list(workflows_dir.glob("*.yml")) + list( - workflows_dir.glob("*.yaml") - ) - - for wf_file in workflow_files: - result.config_files.append( - str(wf_file.relative_to(workflows_dir.parent.parent)) - ) - - try: - content = wf_file.read_text() - workflow_data = self._parse_yaml(content) - - if not workflow_data: - continue - - # Get workflow name - wf_name = workflow_data.get("name", wf_file.stem) - - # Get triggers - triggers = [] - on_trigger = workflow_data.get("on", {}) - if isinstance(on_trigger, str): - triggers = [on_trigger] - elif isinstance(on_trigger, list): - triggers = on_trigger - elif isinstance(on_trigger, dict): - triggers = list(on_trigger.keys()) - - # Parse jobs - jobs = workflow_data.get("jobs", {}) - for job_name, job_config in jobs.items(): - if not isinstance(job_config, dict): - continue - - steps = job_config.get("steps", []) - step_commands = [] - test_related = False - - for step in steps: - if not isinstance(step, dict): - continue - - # Get step name or command - step_name = step.get("name", "") - run_cmd = step.get("run", "") - uses = step.get("uses", "") - - if step_name: - step_commands.append(step_name) - if run_cmd: - step_commands.append(run_cmd) - # Extract test commands - self._extract_test_commands(run_cmd, result) - if uses: - step_commands.append(f"uses: {uses}") - - # Check if test-related - test_keywords = ["test", "pytest", "jest", "vitest", "coverage"] - if any(kw in str(step).lower() for kw in test_keywords): - test_related = True - - result.workflows.append( - CIWorkflow( - name=f"{wf_name}/{job_name}", - trigger=triggers, - steps=step_commands, - test_related=test_related, - ) - ) - - # Extract environment variables - env = workflow_data.get("env", {}) - if isinstance(env, dict): - result.environment_variables.extend(env.keys()) - - except Exception: - continue - - return result - - def _parse_gitlab_ci(self, config_file: Path) -> CIConfig: - """Parse GitLab CI configuration.""" - result = CIConfig( - ci_system="gitlab", - config_files=[".gitlab-ci.yml"], - ) - - try: - content = config_file.read_text() - data = self._parse_yaml(content) - - if not data: - return result - - # Parse jobs (top-level keys that aren't special keywords) - special_keys = { - "stages", - "variables", - "image", - "services", - "before_script", - "after_script", - "cache", - "include", - "default", - "workflow", - } - - for key, value in data.items(): - if key.startswith(".") or key in special_keys: - continue - - if not isinstance(value, dict): - continue - - job_config = value - script = job_config.get("script", []) - if isinstance(script, str): - script = [script] - - test_related = any( - kw in str(script).lower() - for kw in ["test", "pytest", "jest", "vitest", "coverage"] - ) - - result.workflows.append( - CIWorkflow( - name=key, - trigger=job_config.get("only", []) - or job_config.get("rules", []), - steps=script, - test_related=test_related, - ) - ) - - # Extract test commands - for cmd in script: - if isinstance(cmd, str): - self._extract_test_commands(cmd, result) - - # Extract variables - variables = data.get("variables", {}) - if isinstance(variables, dict): - result.environment_variables.extend(variables.keys()) - - except Exception: - pass - - return result - - def _parse_circleci(self, config_file: Path) -> CIConfig: - """Parse CircleCI configuration.""" - result = CIConfig( - ci_system="circleci", - config_files=[".circleci/config.yml"], - ) - - try: - content = config_file.read_text() - data = self._parse_yaml(content) - - if not data: - return result - - # Parse jobs - jobs = data.get("jobs", {}) - for job_name, job_config in jobs.items(): - if not isinstance(job_config, dict): - continue - - steps = job_config.get("steps", []) - step_commands = [] - test_related = False - - for step in steps: - if isinstance(step, str): - step_commands.append(step) - elif isinstance(step, dict): - if "run" in step: - run = step["run"] - if isinstance(run, str): - step_commands.append(run) - self._extract_test_commands(run, result) - elif isinstance(run, dict): - cmd = run.get("command", "") - step_commands.append(cmd) - self._extract_test_commands(cmd, result) - - if any( - kw in str(step).lower() - for kw in ["test", "pytest", "jest", "coverage"] - ): - test_related = True - - result.workflows.append( - CIWorkflow( - name=job_name, - trigger=[], - steps=step_commands, - test_related=test_related, - ) - ) - - except Exception: - pass - - return result - - def _parse_jenkinsfile(self, jenkinsfile: Path) -> CIConfig: - """Parse Jenkinsfile (basic extraction).""" - result = CIConfig( - ci_system="jenkins", - config_files=["Jenkinsfile"], - ) - - try: - content = jenkinsfile.read_text() - - # Extract sh commands using regex - sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]') - matches = sh_pattern.findall(content) - - steps = [] - test_related = False - - for cmd in matches: - steps.append(cmd) - self._extract_test_commands(cmd, result) - - if any( - kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"] - ): - test_related = True - - # Extract stage names - stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)') - stages = stage_pattern.findall(content) - - for stage in stages: - result.workflows.append( - CIWorkflow( - name=stage, - trigger=[], - steps=steps if "test" in stage.lower() else [], - test_related="test" in stage.lower(), - ) - ) - - except Exception: - pass - - return result - - def _parse_yaml(self, content: str) -> dict | None: - """Parse YAML content, with fallback to basic parsing if yaml not available.""" - if HAS_YAML: - try: - return yaml.safe_load(content) - except Exception: - return None - - # Basic fallback for simple YAML (very limited) - # This won't work for complex structures - return None - - def _extract_test_commands(self, cmd: str, result: CIConfig) -> None: - """Extract test commands from a command string.""" - cmd_lower = cmd.lower() - - # Python pytest - if "pytest" in cmd_lower: - if "pytest" not in result.test_commands: - result.test_commands["unit"] = cmd.strip() - if "--cov" in cmd_lower: - result.coverage_command = cmd.strip() - - # Node.js test commands - if ( - "npm test" in cmd_lower - or "yarn test" in cmd_lower - or "pnpm test" in cmd_lower - ): - if "unit" not in result.test_commands: - result.test_commands["unit"] = cmd.strip() - - # Jest/Vitest - if "jest" in cmd_lower or "vitest" in cmd_lower: - if "unit" not in result.test_commands: - result.test_commands["unit"] = cmd.strip() - if "--coverage" in cmd_lower: - result.coverage_command = cmd.strip() - - # E2E testing - if "playwright" in cmd_lower: - result.test_commands["e2e"] = cmd.strip() - if "cypress" in cmd_lower: - result.test_commands["e2e"] = cmd.strip() - - # Integration tests - if "integration" in cmd_lower: - result.test_commands["integration"] = cmd.strip() - - # Go tests - if "go test" in cmd_lower: - if "unit" not in result.test_commands: - result.test_commands["unit"] = cmd.strip() - - # Rust tests - if "cargo test" in cmd_lower: - if "unit" not in result.test_commands: - result.test_commands["unit"] = cmd.strip() - - def to_dict(self, result: CIConfig) -> dict[str, Any]: - """Convert result to dictionary for JSON serialization.""" - return { - "ci_system": result.ci_system, - "config_files": result.config_files, - "test_commands": result.test_commands, - "coverage_command": result.coverage_command, - "workflows": [ - { - "name": w.name, - "trigger": w.trigger, - "steps": w.steps, - "test_related": w.test_related, - } - for w in result.workflows - ], - "environment_variables": result.environment_variables, - } - - def clear_cache(self) -> None: - """Clear the internal cache.""" - self._cache.clear() - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def discover_ci(project_dir: Path) -> CIConfig | None: - """ - Convenience function to discover CI configuration. - - Args: - project_dir: Path to project root - - Returns: - CIConfig if found, None otherwise - """ - discovery = CIDiscovery() - return discovery.discover(project_dir) - - -def get_ci_test_commands(project_dir: Path) -> dict[str, str]: - """ - Get test commands from CI configuration. - - Args: - project_dir: Path to project root - - Returns: - Dictionary of test type to command - """ - discovery = CIDiscovery() - result = discovery.discover(project_dir) - if result: - return result.test_commands - return {} - - -def get_ci_system(project_dir: Path) -> str | None: - """ - Get the CI system name if configured. - - Args: - project_dir: Path to project root - - Returns: - CI system name or None - """ - discovery = CIDiscovery() - result = discovery.discover(project_dir) - if result: - return result.ci_system - return None - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Discover CI configuration") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - discovery = CIDiscovery() - result = discovery.discover(args.project_dir) - - if not result: - print("No CI configuration found") - return - - if args.json: - print(json.dumps(discovery.to_dict(result), indent=2)) - else: - print(f"CI System: {result.ci_system}") - print(f"Config Files: {', '.join(result.config_files)}") - print("\nTest Commands:") - for test_type, cmd in result.test_commands.items(): - print(f" {test_type}: {cmd}") - if result.coverage_command: - print(f"\nCoverage Command: {result.coverage_command}") - print(f"\nWorkflows ({len(result.workflows)}):") - for w in result.workflows: - marker = "[TEST]" if w.test_related else "" - print(f" - {w.name} {marker}") - if w.trigger: - print(f" Triggers: {', '.join(str(t) for t in w.trigger)}") - if result.environment_variables: - print(f"\nEnvironment Variables: {', '.join(result.environment_variables)}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from analysis.ci_discovery instead.""" +from analysis.ci_discovery import ( + CIConfig, + CIWorkflow, + CIDiscovery, + discover_ci, + get_ci_test_commands, + get_ci_system, + HAS_YAML, +) + +__all__ = [ + "CIConfig", + "CIWorkflow", + "CIDiscovery", + "discover_ci", + "get_ci_test_commands", + "get_ci_system", + "HAS_YAML", +] diff --git a/auto-claude/client.py b/auto-claude/client.py index 8a628f2a..0416d7dd 100644 --- a/auto-claude/client.py +++ b/auto-claude/client.py @@ -1,315 +1,24 @@ -""" -Claude SDK Client Configuration -=============================== - -Functions for creating and configuring the Claude Agent SDK client. -""" - -import json +"""Backward compatibility shim - import from core.client instead.""" +import sys import os -from pathlib import Path -from auto_claude_tools import ( - create_auto_claude_mcp_server, - is_tools_available, -) -from auto_claude_tools import ( - get_allowed_tools as get_agent_allowed_tools, -) -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient -from claude_agent_sdk.types import HookMatcher -from linear_updater import is_linear_enabled -from security import bash_security_hook +# Add auto-claude to path if not present +_auto_claude_dir = os.path.dirname(os.path.abspath(__file__)) +if _auto_claude_dir not in sys.path: + sys.path.insert(0, _auto_claude_dir) +# Use lazy imports to avoid circular dependency +def __getattr__(name): + """Lazy import to avoid circular imports with auto_claude_tools.""" + from core import client as _client + return getattr(_client, name) -def is_graphiti_mcp_enabled() -> bool: - """ - Check if Graphiti MCP server integration is enabled. - - Requires GRAPHITI_MCP_URL to be set (e.g., http://localhost:8000/mcp/) - This is separate from GRAPHITI_ENABLED which controls the Python library integration. - """ - return bool(os.environ.get("GRAPHITI_MCP_URL")) - - -def get_graphiti_mcp_url() -> str: - """Get the Graphiti MCP server URL.""" - return os.environ.get("GRAPHITI_MCP_URL", "http://localhost:8000/mcp/") - - -def is_electron_mcp_enabled() -> bool: - """ - Check if Electron MCP server integration is enabled. - - Requires ELECTRON_MCP_ENABLED to be set to 'true'. - When enabled, QA agents can use Puppeteer MCP tools to connect to Electron apps - via Chrome DevTools Protocol on the configured debug port. - """ - return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true" - - -def get_electron_debug_port() -> int: - """Get the Electron remote debugging port (default: 9222).""" - return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222")) - - -# Puppeteer MCP tools for browser automation -PUPPETEER_TOOLS = [ - "mcp__puppeteer__puppeteer_connect_active_tab", - "mcp__puppeteer__puppeteer_navigate", - "mcp__puppeteer__puppeteer_screenshot", - "mcp__puppeteer__puppeteer_click", - "mcp__puppeteer__puppeteer_fill", - "mcp__puppeteer__puppeteer_select", - "mcp__puppeteer__puppeteer_hover", - "mcp__puppeteer__puppeteer_evaluate", +# For wildcard imports, define __all__ +__all__ = [ + "create_claude_client", + "ClaudeClient", + "is_graphiti_mcp_enabled", + "get_graphiti_mcp_url", + "is_electron_mcp_enabled", + "get_electron_debug_port", ] - -# Linear MCP tools for project management (when LINEAR_API_KEY is set) -LINEAR_TOOLS = [ - "mcp__linear-server__list_teams", - "mcp__linear-server__get_team", - "mcp__linear-server__list_projects", - "mcp__linear-server__get_project", - "mcp__linear-server__create_project", - "mcp__linear-server__update_project", - "mcp__linear-server__list_issues", - "mcp__linear-server__get_issue", - "mcp__linear-server__create_issue", - "mcp__linear-server__update_issue", - "mcp__linear-server__list_comments", - "mcp__linear-server__create_comment", - "mcp__linear-server__list_issue_statuses", - "mcp__linear-server__list_issue_labels", - "mcp__linear-server__list_users", - "mcp__linear-server__get_user", -] - -# Context7 MCP tools for documentation lookup (always enabled) -CONTEXT7_TOOLS = [ - "mcp__context7__resolve-library-id", - "mcp__context7__get-library-docs", -] - -# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set) -# See: https://docs.falkordb.com/agentic-memory/graphiti-mcp-server.html -GRAPHITI_MCP_TOOLS = [ - "mcp__graphiti-memory__search_nodes", # Search entity summaries - "mcp__graphiti-memory__search_facts", # Search relationships between entities - "mcp__graphiti-memory__add_episode", # Add data to knowledge graph - "mcp__graphiti-memory__get_episodes", # Retrieve recent episodes - "mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship -] - -# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set) -# Uses puppeteer-mcp-server to connect to Electron apps via Chrome DevTools Protocol. -# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT). -# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner. -ELECTRON_TOOLS = [ - "mcp__electron__electron_connect", # Connect to Electron app via DevTools - "mcp__electron__electron_screenshot", # Take screenshot of Electron window - "mcp__electron__electron_click", # Click element in Electron app - "mcp__electron__electron_fill", # Fill input field in Electron app - "mcp__electron__electron_evaluate", # Execute JS in Electron renderer - "mcp__electron__electron_get_window_info", # Get window state/bounds - "mcp__electron__electron_get_console", # Get console logs from renderer -] - -# Built-in tools -BUILTIN_TOOLS = [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", -] - - -def create_client( - project_dir: Path, - spec_dir: Path, - model: str, - agent_type: str = "coder", -) -> ClaudeSDKClient: - """ - Create a Claude Agent SDK client with multi-layered security. - - Args: - project_dir: Root directory for the project (working directory) - spec_dir: Directory containing the spec (for settings file) - model: Claude model to use - agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer' - This determines which custom auto-claude tools are available. - - Returns: - Configured ClaudeSDKClient - - Security layers (defense in depth): - 1. Sandbox - OS-level bash command isolation prevents filesystem escape - 2. Permissions - File operations restricted to project_dir only - 3. Security hooks - Bash commands validated against an allowlist - (see security.py for ALLOWED_COMMANDS) - 4. Tool filtering - Each agent type only sees relevant tools (prevents misuse) - """ - oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not oauth_token: - raise ValueError( - "CLAUDE_CODE_OAUTH_TOKEN environment variable not set.\n" - "Get your token by running: claude setup-token" - ) - - # Check if Linear integration is enabled - linear_enabled = is_linear_enabled() - linear_api_key = os.environ.get("LINEAR_API_KEY", "") - - # Check if custom auto-claude tools are available - auto_claude_tools_enabled = is_tools_available() - - # Build the list of allowed tools - # Start with agent-specific tools (includes base tools + auto-claude tools) - if auto_claude_tools_enabled: - allowed_tools_list = get_agent_allowed_tools(agent_type) - else: - allowed_tools_list = [*BUILTIN_TOOLS] - - # Check if Graphiti MCP is enabled - graphiti_mcp_enabled = is_graphiti_mcp_enabled() - - # Check if Electron MCP is enabled (for QA agents testing Electron apps) - electron_mcp_enabled = is_electron_mcp_enabled() - - # Add external MCP tools - allowed_tools_list.extend(PUPPETEER_TOOLS) - allowed_tools_list.extend(CONTEXT7_TOOLS) - if linear_enabled: - allowed_tools_list.extend(LINEAR_TOOLS) - if graphiti_mcp_enabled: - allowed_tools_list.extend(GRAPHITI_MCP_TOOLS) - # Add Electron MCP tools only for QA agents (qa_reviewer, qa_fixer) when enabled - # This prevents context bloat for coder/planner agents who don't need desktop automation - if electron_mcp_enabled and agent_type in ("qa_reviewer", "qa_fixer"): - allowed_tools_list.extend(ELECTRON_TOOLS) - - # Create comprehensive security settings - # Note: Using relative paths ("./**") restricts access to project directory - # since cwd is set to project_dir - security_settings = { - "sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True}, - "permissions": { - "defaultMode": "acceptEdits", # Auto-approve edits within allowed directories - "allow": [ - # Allow all file operations within the project directory - "Read(./**)", - "Write(./**)", - "Edit(./**)", - "Glob(./**)", - "Grep(./**)", - # Bash permission granted here, but actual commands are validated - # by the bash_security_hook (see security.py for allowed commands) - "Bash(*)", - # Allow Puppeteer MCP tools for browser automation - *PUPPETEER_TOOLS, - # Allow Context7 MCP tools for documentation lookup - *CONTEXT7_TOOLS, - # Allow Linear MCP tools for project management (if enabled) - *(LINEAR_TOOLS if linear_enabled else []), - # Allow Graphiti MCP tools for knowledge graph memory (if enabled) - *(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []), - # Allow Electron MCP tools for QA agents only (if enabled) - *(ELECTRON_TOOLS if electron_mcp_enabled and agent_type in ("qa_reviewer", "qa_fixer") else []), - ], - }, - } - - # Write settings to a file in the project directory - settings_file = project_dir / ".claude_settings.json" - with open(settings_file, "w") as f: - json.dump(security_settings, f, indent=2) - - print(f"Security settings: {settings_file}") - print(" - Sandbox enabled (OS-level bash isolation)") - print(f" - Filesystem restricted to: {project_dir.resolve()}") - print(" - Bash commands restricted to allowlist") - - mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"] - if linear_enabled: - mcp_servers_list.append("linear (project management)") - if graphiti_mcp_enabled: - mcp_servers_list.append("graphiti-memory (knowledge graph)") - if electron_mcp_enabled: - mcp_servers_list.append(f"electron (desktop automation, port {get_electron_debug_port()})") - if auto_claude_tools_enabled: - mcp_servers_list.append(f"auto-claude ({agent_type} tools)") - print(f" - MCP servers: {', '.join(mcp_servers_list)}") - print() - - # Configure MCP servers - mcp_servers = { - "puppeteer": {"command": "npx", "args": ["puppeteer-mcp-server"]}, - "context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]}, - } - - # Add Linear MCP server if enabled - if linear_enabled: - mcp_servers["linear"] = { - "type": "http", - "url": "https://mcp.linear.app/mcp", - "headers": {"Authorization": f"Bearer {linear_api_key}"}, - } - - # Add Graphiti MCP server if enabled - # Requires running: docker run -d -p 8000:8000 falkordb/graphiti-knowledge-graph-mcp - if graphiti_mcp_enabled: - mcp_servers["graphiti-memory"] = { - "type": "http", - "url": get_graphiti_mcp_url(), - } - - # Add Electron MCP server if enabled - # Electron app must be started with --remote-debugging-port= for connection - # Uses puppeteer-mcp-server to connect to Electron via Chrome DevTools Protocol - if electron_mcp_enabled: - electron_port = get_electron_debug_port() - mcp_servers["electron"] = { - "command": "npx", - "args": [ - "-y", - "@anthropic/puppeteer-mcp-server", - "--debug-port", - str(electron_port), - ], - } - - # Add custom auto-claude MCP server if available - auto_claude_mcp_server = None - if auto_claude_tools_enabled: - auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir) - if auto_claude_mcp_server: - mcp_servers["auto-claude"] = auto_claude_mcp_server - - return ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - system_prompt=( - f"You are an expert full-stack developer building production-quality software. " - f"Your working directory is: {project_dir.resolve()}\n" - f"Your filesystem access is RESTRICTED to this directory only. " - f"Use relative paths (starting with ./) for all file operations. " - f"Never use absolute paths or try to access files outside your working directory.\n\n" - f"You follow existing code patterns, write clean maintainable code, and verify " - f"your work through thorough testing. You communicate progress through Git commits " - f"and build-progress.txt updates." - ), - allowed_tools=allowed_tools_list, - mcp_servers=mcp_servers, - hooks={ - "PreToolUse": [ - HookMatcher(matcher="Bash", hooks=[bash_security_hook]), - ], - }, - max_turns=1000, - cwd=str(project_dir.resolve()), - settings=str(settings_file.resolve()), - ) - ) diff --git a/auto-claude/context.py b/auto-claude/context/main.py similarity index 100% rename from auto-claude/context.py rename to auto-claude/context/main.py diff --git a/auto-claude/core/__init__.py b/auto-claude/core/__init__.py new file mode 100644 index 00000000..9c6bc9f1 --- /dev/null +++ b/auto-claude/core/__init__.py @@ -0,0 +1,36 @@ +""" +Core Framework Module +===================== + +Core components for the Auto Claude autonomous coding framework. +""" + +# Note: We use lazy imports here because the full agent module has many dependencies +# that may not be needed for basic operations like workspace management. + +__all__ = [ + "run_autonomous_agent", + "run_followup_planner", + "WorkspaceManager", + "WorktreeManager", + "ProgressTracker", +] + +def __getattr__(name): + """Lazy imports to avoid circular dependencies and heavy imports.""" + if name in ("run_autonomous_agent", "run_followup_planner"): + from .agent import run_autonomous_agent, run_followup_planner + return locals()[name] + elif name == "WorkspaceManager": + from .workspace import WorkspaceManager + return WorkspaceManager + elif name == "WorktreeManager": + from .worktree import WorktreeManager + return WorktreeManager + elif name == "ProgressTracker": + from .progress import ProgressTracker + return ProgressTracker + elif name in ("create_claude_client", "ClaudeClient"): + from . import client as _client + return getattr(_client, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/auto-claude/core/agent.py b/auto-claude/core/agent.py new file mode 100644 index 00000000..8b2cc8d5 --- /dev/null +++ b/auto-claude/core/agent.py @@ -0,0 +1,63 @@ +""" +Agent Session Logic +=================== + +Core agent interaction functions for running autonomous coding sessions. +Uses subtask-based implementation plans with minimal, focused prompts. + +Architecture: +- Orchestrator (Python) handles all bookkeeping: memory, commits, progress +- Agent focuses ONLY on implementing code +- Post-session processing updates memory automatically (100% reliable) + +Enhanced with status file updates for ccstatusline integration. +Enhanced with Graphiti memory for cross-session context retrieval. + +NOTE: This module is now a facade that imports from agents/ submodules. +All logic has been refactored into focused modules for better maintainability. +""" + +# Re-export everything from the agents module to maintain backwards compatibility +from agents import ( + # Constants + AUTO_CONTINUE_DELAY_SECONDS, + HUMAN_INTERVENTION_FILE, + # Memory functions + debug_memory_system_status, + find_phase_for_subtask, + find_subtask_in_plan, + get_commit_count, + get_graphiti_context, + # Utility functions + get_latest_commit, + load_implementation_plan, + post_session_processing, + # Session management + run_agent_session, + # Main API + run_autonomous_agent, + run_followup_planner, + save_session_memory, + save_session_to_graphiti, + sync_plan_to_source, +) + +# Ensure all exports are available at module level +__all__ = [ + "run_autonomous_agent", + "run_followup_planner", + "debug_memory_system_status", + "get_graphiti_context", + "save_session_memory", + "save_session_to_graphiti", + "run_agent_session", + "post_session_processing", + "get_latest_commit", + "get_commit_count", + "load_implementation_plan", + "find_subtask_in_plan", + "find_phase_for_subtask", + "sync_plan_to_source", + "AUTO_CONTINUE_DELAY_SECONDS", + "HUMAN_INTERVENTION_FILE", +] diff --git a/auto-claude/core/client.py b/auto-claude/core/client.py new file mode 100644 index 00000000..8a628f2a --- /dev/null +++ b/auto-claude/core/client.py @@ -0,0 +1,315 @@ +""" +Claude SDK Client Configuration +=============================== + +Functions for creating and configuring the Claude Agent SDK client. +""" + +import json +import os +from pathlib import Path + +from auto_claude_tools import ( + create_auto_claude_mcp_server, + is_tools_available, +) +from auto_claude_tools import ( + get_allowed_tools as get_agent_allowed_tools, +) +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient +from claude_agent_sdk.types import HookMatcher +from linear_updater import is_linear_enabled +from security import bash_security_hook + + +def is_graphiti_mcp_enabled() -> bool: + """ + Check if Graphiti MCP server integration is enabled. + + Requires GRAPHITI_MCP_URL to be set (e.g., http://localhost:8000/mcp/) + This is separate from GRAPHITI_ENABLED which controls the Python library integration. + """ + return bool(os.environ.get("GRAPHITI_MCP_URL")) + + +def get_graphiti_mcp_url() -> str: + """Get the Graphiti MCP server URL.""" + return os.environ.get("GRAPHITI_MCP_URL", "http://localhost:8000/mcp/") + + +def is_electron_mcp_enabled() -> bool: + """ + Check if Electron MCP server integration is enabled. + + Requires ELECTRON_MCP_ENABLED to be set to 'true'. + When enabled, QA agents can use Puppeteer MCP tools to connect to Electron apps + via Chrome DevTools Protocol on the configured debug port. + """ + return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true" + + +def get_electron_debug_port() -> int: + """Get the Electron remote debugging port (default: 9222).""" + return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222")) + + +# Puppeteer MCP tools for browser automation +PUPPETEER_TOOLS = [ + "mcp__puppeteer__puppeteer_connect_active_tab", + "mcp__puppeteer__puppeteer_navigate", + "mcp__puppeteer__puppeteer_screenshot", + "mcp__puppeteer__puppeteer_click", + "mcp__puppeteer__puppeteer_fill", + "mcp__puppeteer__puppeteer_select", + "mcp__puppeteer__puppeteer_hover", + "mcp__puppeteer__puppeteer_evaluate", +] + +# Linear MCP tools for project management (when LINEAR_API_KEY is set) +LINEAR_TOOLS = [ + "mcp__linear-server__list_teams", + "mcp__linear-server__get_team", + "mcp__linear-server__list_projects", + "mcp__linear-server__get_project", + "mcp__linear-server__create_project", + "mcp__linear-server__update_project", + "mcp__linear-server__list_issues", + "mcp__linear-server__get_issue", + "mcp__linear-server__create_issue", + "mcp__linear-server__update_issue", + "mcp__linear-server__list_comments", + "mcp__linear-server__create_comment", + "mcp__linear-server__list_issue_statuses", + "mcp__linear-server__list_issue_labels", + "mcp__linear-server__list_users", + "mcp__linear-server__get_user", +] + +# Context7 MCP tools for documentation lookup (always enabled) +CONTEXT7_TOOLS = [ + "mcp__context7__resolve-library-id", + "mcp__context7__get-library-docs", +] + +# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set) +# See: https://docs.falkordb.com/agentic-memory/graphiti-mcp-server.html +GRAPHITI_MCP_TOOLS = [ + "mcp__graphiti-memory__search_nodes", # Search entity summaries + "mcp__graphiti-memory__search_facts", # Search relationships between entities + "mcp__graphiti-memory__add_episode", # Add data to knowledge graph + "mcp__graphiti-memory__get_episodes", # Retrieve recent episodes + "mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship +] + +# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set) +# Uses puppeteer-mcp-server to connect to Electron apps via Chrome DevTools Protocol. +# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT). +# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner. +ELECTRON_TOOLS = [ + "mcp__electron__electron_connect", # Connect to Electron app via DevTools + "mcp__electron__electron_screenshot", # Take screenshot of Electron window + "mcp__electron__electron_click", # Click element in Electron app + "mcp__electron__electron_fill", # Fill input field in Electron app + "mcp__electron__electron_evaluate", # Execute JS in Electron renderer + "mcp__electron__electron_get_window_info", # Get window state/bounds + "mcp__electron__electron_get_console", # Get console logs from renderer +] + +# Built-in tools +BUILTIN_TOOLS = [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", +] + + +def create_client( + project_dir: Path, + spec_dir: Path, + model: str, + agent_type: str = "coder", +) -> ClaudeSDKClient: + """ + Create a Claude Agent SDK client with multi-layered security. + + Args: + project_dir: Root directory for the project (working directory) + spec_dir: Directory containing the spec (for settings file) + model: Claude model to use + agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer' + This determines which custom auto-claude tools are available. + + Returns: + Configured ClaudeSDKClient + + Security layers (defense in depth): + 1. Sandbox - OS-level bash command isolation prevents filesystem escape + 2. Permissions - File operations restricted to project_dir only + 3. Security hooks - Bash commands validated against an allowlist + (see security.py for ALLOWED_COMMANDS) + 4. Tool filtering - Each agent type only sees relevant tools (prevents misuse) + """ + oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + if not oauth_token: + raise ValueError( + "CLAUDE_CODE_OAUTH_TOKEN environment variable not set.\n" + "Get your token by running: claude setup-token" + ) + + # Check if Linear integration is enabled + linear_enabled = is_linear_enabled() + linear_api_key = os.environ.get("LINEAR_API_KEY", "") + + # Check if custom auto-claude tools are available + auto_claude_tools_enabled = is_tools_available() + + # Build the list of allowed tools + # Start with agent-specific tools (includes base tools + auto-claude tools) + if auto_claude_tools_enabled: + allowed_tools_list = get_agent_allowed_tools(agent_type) + else: + allowed_tools_list = [*BUILTIN_TOOLS] + + # Check if Graphiti MCP is enabled + graphiti_mcp_enabled = is_graphiti_mcp_enabled() + + # Check if Electron MCP is enabled (for QA agents testing Electron apps) + electron_mcp_enabled = is_electron_mcp_enabled() + + # Add external MCP tools + allowed_tools_list.extend(PUPPETEER_TOOLS) + allowed_tools_list.extend(CONTEXT7_TOOLS) + if linear_enabled: + allowed_tools_list.extend(LINEAR_TOOLS) + if graphiti_mcp_enabled: + allowed_tools_list.extend(GRAPHITI_MCP_TOOLS) + # Add Electron MCP tools only for QA agents (qa_reviewer, qa_fixer) when enabled + # This prevents context bloat for coder/planner agents who don't need desktop automation + if electron_mcp_enabled and agent_type in ("qa_reviewer", "qa_fixer"): + allowed_tools_list.extend(ELECTRON_TOOLS) + + # Create comprehensive security settings + # Note: Using relative paths ("./**") restricts access to project directory + # since cwd is set to project_dir + security_settings = { + "sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True}, + "permissions": { + "defaultMode": "acceptEdits", # Auto-approve edits within allowed directories + "allow": [ + # Allow all file operations within the project directory + "Read(./**)", + "Write(./**)", + "Edit(./**)", + "Glob(./**)", + "Grep(./**)", + # Bash permission granted here, but actual commands are validated + # by the bash_security_hook (see security.py for allowed commands) + "Bash(*)", + # Allow Puppeteer MCP tools for browser automation + *PUPPETEER_TOOLS, + # Allow Context7 MCP tools for documentation lookup + *CONTEXT7_TOOLS, + # Allow Linear MCP tools for project management (if enabled) + *(LINEAR_TOOLS if linear_enabled else []), + # Allow Graphiti MCP tools for knowledge graph memory (if enabled) + *(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []), + # Allow Electron MCP tools for QA agents only (if enabled) + *(ELECTRON_TOOLS if electron_mcp_enabled and agent_type in ("qa_reviewer", "qa_fixer") else []), + ], + }, + } + + # Write settings to a file in the project directory + settings_file = project_dir / ".claude_settings.json" + with open(settings_file, "w") as f: + json.dump(security_settings, f, indent=2) + + print(f"Security settings: {settings_file}") + print(" - Sandbox enabled (OS-level bash isolation)") + print(f" - Filesystem restricted to: {project_dir.resolve()}") + print(" - Bash commands restricted to allowlist") + + mcp_servers_list = ["puppeteer (browser automation)", "context7 (documentation)"] + if linear_enabled: + mcp_servers_list.append("linear (project management)") + if graphiti_mcp_enabled: + mcp_servers_list.append("graphiti-memory (knowledge graph)") + if electron_mcp_enabled: + mcp_servers_list.append(f"electron (desktop automation, port {get_electron_debug_port()})") + if auto_claude_tools_enabled: + mcp_servers_list.append(f"auto-claude ({agent_type} tools)") + print(f" - MCP servers: {', '.join(mcp_servers_list)}") + print() + + # Configure MCP servers + mcp_servers = { + "puppeteer": {"command": "npx", "args": ["puppeteer-mcp-server"]}, + "context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]}, + } + + # Add Linear MCP server if enabled + if linear_enabled: + mcp_servers["linear"] = { + "type": "http", + "url": "https://mcp.linear.app/mcp", + "headers": {"Authorization": f"Bearer {linear_api_key}"}, + } + + # Add Graphiti MCP server if enabled + # Requires running: docker run -d -p 8000:8000 falkordb/graphiti-knowledge-graph-mcp + if graphiti_mcp_enabled: + mcp_servers["graphiti-memory"] = { + "type": "http", + "url": get_graphiti_mcp_url(), + } + + # Add Electron MCP server if enabled + # Electron app must be started with --remote-debugging-port= for connection + # Uses puppeteer-mcp-server to connect to Electron via Chrome DevTools Protocol + if electron_mcp_enabled: + electron_port = get_electron_debug_port() + mcp_servers["electron"] = { + "command": "npx", + "args": [ + "-y", + "@anthropic/puppeteer-mcp-server", + "--debug-port", + str(electron_port), + ], + } + + # Add custom auto-claude MCP server if available + auto_claude_mcp_server = None + if auto_claude_tools_enabled: + auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir) + if auto_claude_mcp_server: + mcp_servers["auto-claude"] = auto_claude_mcp_server + + return ClaudeSDKClient( + options=ClaudeAgentOptions( + model=model, + system_prompt=( + f"You are an expert full-stack developer building production-quality software. " + f"Your working directory is: {project_dir.resolve()}\n" + f"Your filesystem access is RESTRICTED to this directory only. " + f"Use relative paths (starting with ./) for all file operations. " + f"Never use absolute paths or try to access files outside your working directory.\n\n" + f"You follow existing code patterns, write clean maintainable code, and verify " + f"your work through thorough testing. You communicate progress through Git commits " + f"and build-progress.txt updates." + ), + allowed_tools=allowed_tools_list, + mcp_servers=mcp_servers, + hooks={ + "PreToolUse": [ + HookMatcher(matcher="Bash", hooks=[bash_security_hook]), + ], + }, + max_turns=1000, + cwd=str(project_dir.resolve()), + settings=str(settings_file.resolve()), + ) + ) diff --git a/auto-claude/core/debug.py b/auto-claude/core/debug.py new file mode 100644 index 00000000..9bef363d --- /dev/null +++ b/auto-claude/core/debug.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Debug Logging Utility +===================== + +Centralized debug logging for the Auto-Claude framework. +Controlled via environment variables: + - DEBUG=true Enable debug mode + - DEBUG_LEVEL=1|2|3 Log verbosity (1=basic, 2=detailed, 3=verbose) + - DEBUG_LOG_FILE=path Optional file output + +Usage: + from debug import debug, debug_detailed, debug_verbose, is_debug_enabled + + debug("run.py", "Starting task execution", task_id="001") + debug_detailed("agent", "Agent response received", response_length=1234) + debug_verbose("client", "Full request payload", payload=data) +""" + +import json +import os +import sys +import time +from datetime import datetime +from functools import wraps +from pathlib import Path +from typing import Any + + +# ANSI color codes for terminal output +class Colors: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + + # Debug colors + DEBUG = "\033[36m" # Cyan + DEBUG_DIM = "\033[96m" # Light cyan + TIMESTAMP = "\033[90m" # Gray + MODULE = "\033[33m" # Yellow + KEY = "\033[35m" # Magenta + VALUE = "\033[37m" # White + SUCCESS = "\033[32m" # Green + WARNING = "\033[33m" # Yellow + ERROR = "\033[31m" # Red + + +def _get_debug_enabled() -> bool: + """Check if debug mode is enabled via environment variable.""" + return os.environ.get("DEBUG", "").lower() in ("true", "1", "yes", "on") + + +def _get_debug_level() -> int: + """Get debug verbosity level (1-3).""" + try: + level = int(os.environ.get("DEBUG_LEVEL", "1")) + return max(1, min(3, level)) # Clamp to 1-3 + except ValueError: + return 1 + + +def _get_log_file() -> Path | None: + """Get optional log file path.""" + log_file = os.environ.get("DEBUG_LOG_FILE") + if log_file: + return Path(log_file) + return None + + +def is_debug_enabled() -> bool: + """Check if debug mode is enabled.""" + return _get_debug_enabled() + + +def get_debug_level() -> int: + """Get current debug level.""" + return _get_debug_level() + + +def _format_value(value: Any, max_length: int = 200) -> str: + """Format a value for debug output, truncating if necessary.""" + if value is None: + return "None" + + if isinstance(value, (dict, list)): + try: + formatted = json.dumps(value, indent=2, default=str) + if len(formatted) > max_length: + formatted = formatted[:max_length] + "..." + return formatted + except (TypeError, ValueError): + return str(value)[:max_length] + + str_value = str(value) + if len(str_value) > max_length: + return str_value[:max_length] + "..." + return str_value + + +def _write_log(message: str, to_file: bool = True) -> None: + """Write log message to stdout and optionally to file.""" + print(message, file=sys.stderr) + + if to_file: + log_file = _get_log_file() + if log_file: + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + # Strip ANSI codes for file output + import re + + clean_message = re.sub(r"\033\[[0-9;]*m", "", message) + with open(log_file, "a") as f: + f.write(clean_message + "\n") + except Exception: + pass # Silently fail file logging + + +def debug(module: str, message: str, level: int = 1, **kwargs) -> None: + """ + Log a debug message. + + Args: + module: Source module name (e.g., "run.py", "ideation_runner") + message: Debug message + level: Required debug level (1=basic, 2=detailed, 3=verbose) + **kwargs: Additional key-value pairs to log + """ + if not _get_debug_enabled(): + return + + if _get_debug_level() < level: + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + + # Build the log line + parts = [ + f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET}", + f"{Colors.DEBUG}[DEBUG]{Colors.RESET}", + f"{Colors.MODULE}[{module}]{Colors.RESET}", + f"{Colors.DEBUG_DIM}{message}{Colors.RESET}", + ] + + log_line = " ".join(parts) + + # Add kwargs on separate lines if present + if kwargs: + for key, value in kwargs.items(): + formatted_value = _format_value(value) + if "\n" in formatted_value: + # Multi-line value + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}:" + for line in formatted_value.split("\n"): + log_line += f"\n {Colors.VALUE}{line}{Colors.RESET}" + else: + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{formatted_value}{Colors.RESET}" + + _write_log(log_line) + + +def debug_detailed(module: str, message: str, **kwargs) -> None: + """Log a detailed debug message (level 2).""" + debug(module, message, level=2, **kwargs) + + +def debug_verbose(module: str, message: str, **kwargs) -> None: + """Log a verbose debug message (level 3).""" + debug(module, message, level=3, **kwargs) + + +def debug_success(module: str, message: str, **kwargs) -> None: + """Log a success debug message.""" + if not _get_debug_enabled(): + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.SUCCESS}[OK]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {message}" + + if kwargs: + for key, value in kwargs.items(): + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" + + _write_log(log_line) + + +def debug_info(module: str, message: str, **kwargs) -> None: + """Log an info debug message.""" + if not _get_debug_enabled(): + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}[INFO]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {message}" + + if kwargs: + for key, value in kwargs.items(): + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" + + _write_log(log_line) + + +def debug_error(module: str, message: str, **kwargs) -> None: + """Log an error debug message (always shown if debug enabled).""" + if not _get_debug_enabled(): + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.ERROR}[ERROR]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.ERROR}{message}{Colors.RESET}" + + if kwargs: + for key, value in kwargs.items(): + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" + + _write_log(log_line) + + +def debug_warning(module: str, message: str, **kwargs) -> None: + """Log a warning debug message.""" + if not _get_debug_enabled(): + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.WARNING}[WARN]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.WARNING}{message}{Colors.RESET}" + + if kwargs: + for key, value in kwargs.items(): + log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" + + _write_log(log_line) + + +def debug_section(module: str, title: str) -> None: + """Log a section header for organizing debug output.""" + if not _get_debug_enabled(): + return + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + separator = "─" * 60 + log_line = f"\n{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}{Colors.BOLD}┌{separator}┐{Colors.RESET}" + log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}│ {module}: {title}{' ' * (58 - len(module) - len(title) - 2)}│{Colors.RESET}" + log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}└{separator}┘{Colors.RESET}" + + _write_log(log_line) + + +def debug_timer(module: str): + """ + Decorator to time function execution. + + Usage: + @debug_timer("run.py") + def my_function(): + ... + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not _get_debug_enabled(): + return func(*args, **kwargs) + + start = time.time() + debug_detailed(module, f"Starting {func.__name__}()") + + try: + result = func(*args, **kwargs) + elapsed = time.time() - start + debug_success( + module, + f"Completed {func.__name__}()", + elapsed_ms=f"{elapsed * 1000:.1f}ms", + ) + return result + except Exception as e: + elapsed = time.time() - start + debug_error( + module, + f"Failed {func.__name__}()", + error=str(e), + elapsed_ms=f"{elapsed * 1000:.1f}ms", + ) + raise + + return wrapper + + return decorator + + +def debug_async_timer(module: str): + """ + Decorator to time async function execution. + + Usage: + @debug_async_timer("ideation_runner") + async def my_async_function(): + ... + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + if not _get_debug_enabled(): + return await func(*args, **kwargs) + + start = time.time() + debug_detailed(module, f"Starting {func.__name__}()") + + try: + result = await func(*args, **kwargs) + elapsed = time.time() - start + debug_success( + module, + f"Completed {func.__name__}()", + elapsed_ms=f"{elapsed * 1000:.1f}ms", + ) + return result + except Exception as e: + elapsed = time.time() - start + debug_error( + module, + f"Failed {func.__name__}()", + error=str(e), + elapsed_ms=f"{elapsed * 1000:.1f}ms", + ) + raise + + return wrapper + + return decorator + + +def debug_env_status() -> None: + """Print debug environment status on startup.""" + if not _get_debug_enabled(): + return + + debug_section("debug", "Debug Mode Enabled") + debug( + "debug", + "Environment configuration", + DEBUG=os.environ.get("DEBUG", "not set"), + DEBUG_LEVEL=_get_debug_level(), + DEBUG_LOG_FILE=os.environ.get("DEBUG_LOG_FILE", "not set"), + ) + + +# Print status on import if debug is enabled +if _get_debug_enabled(): + debug_env_status() diff --git a/auto-claude/core/progress.py b/auto-claude/core/progress.py new file mode 100644 index 00000000..1e416045 --- /dev/null +++ b/auto-claude/core/progress.py @@ -0,0 +1,467 @@ +""" +Progress Tracking Utilities +=========================== + +Functions for tracking and displaying progress of the autonomous coding agent. +Uses subtask-based implementation plans (implementation_plan.json). + +Enhanced with colored output, icons, and better visual formatting. +""" + +import json +from pathlib import Path + +from ui import ( + Icons, + bold, + box, + highlight, + icon, + muted, + print_phase_status, + print_status, + progress_bar, + success, + warning, +) + + +def count_subtasks(spec_dir: Path) -> tuple[int, int]: + """ + Count completed and total subtasks in implementation_plan.json. + + Args: + spec_dir: Directory containing implementation_plan.json + + Returns: + (completed_count, total_count) + """ + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return 0, 0 + + try: + with open(plan_file) as f: + plan = json.load(f) + + total = 0 + completed = 0 + + for phase in plan.get("phases", []): + for subtask in phase.get("subtasks", []): + total += 1 + if subtask.get("status") == "completed": + completed += 1 + + return completed, total + except (OSError, json.JSONDecodeError): + return 0, 0 + + +def count_subtasks_detailed(spec_dir: Path) -> dict: + """ + Count subtasks by status. + + Returns: + Dict with completed, in_progress, pending, failed counts + """ + plan_file = spec_dir / "implementation_plan.json" + + result = { + "completed": 0, + "in_progress": 0, + "pending": 0, + "failed": 0, + "total": 0, + } + + if not plan_file.exists(): + return result + + try: + with open(plan_file) as f: + plan = json.load(f) + + for phase in plan.get("phases", []): + for subtask in phase.get("subtasks", []): + result["total"] += 1 + status = subtask.get("status", "pending") + if status in result: + result[status] += 1 + else: + result["pending"] += 1 + + return result + except (OSError, json.JSONDecodeError): + return result + + +def is_build_complete(spec_dir: Path) -> bool: + """ + Check if all subtasks are completed. + + Args: + spec_dir: Directory containing implementation_plan.json + + Returns: + True if all subtasks complete, False otherwise + """ + completed, total = count_subtasks(spec_dir) + return total > 0 and completed == total + + +def get_progress_percentage(spec_dir: Path) -> float: + """ + Get the progress as a percentage. + + Args: + spec_dir: Directory containing implementation_plan.json + + Returns: + Percentage of subtasks completed (0-100) + """ + completed, total = count_subtasks(spec_dir) + if total == 0: + return 0.0 + return (completed / total) * 100 + + +def print_session_header( + session_num: int, + is_planner: bool, + subtask_id: str = None, + subtask_desc: str = None, + phase_name: str = None, + attempt: int = 1, +) -> None: + """Print a formatted header for the session.""" + session_type = "PLANNER AGENT" if is_planner else "CODING AGENT" + session_icon = Icons.GEAR if is_planner else Icons.LIGHTNING + + content = [ + bold(f"{icon(session_icon)} SESSION {session_num}: {session_type}"), + ] + + if subtask_id: + content.append("") + subtask_line = f"{icon(Icons.SUBTASK)} Subtask: {highlight(subtask_id)}" + if subtask_desc: + # Truncate long descriptions + desc = subtask_desc[:50] + "..." if len(subtask_desc) > 50 else subtask_desc + subtask_line += f" - {desc}" + content.append(subtask_line) + + if phase_name: + content.append(f"{icon(Icons.PHASE)} Phase: {phase_name}") + + if attempt > 1: + content.append(warning(f"{icon(Icons.WARNING)} Attempt: {attempt}")) + + print() + print(box(content, width=70, style="heavy")) + print() + + +def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None: + """Print a summary of current progress with enhanced formatting.""" + completed, total = count_subtasks(spec_dir) + + if total > 0: + print() + # Progress bar + print(f"Progress: {progress_bar(completed, total, width=40)}") + + # Status message + if completed == total: + print_status("BUILD COMPLETE - All subtasks completed!", "success") + else: + remaining = total - completed + print_status(f"{remaining} subtasks remaining", "info") + + # Phase summary + try: + with open(spec_dir / "implementation_plan.json") as f: + plan = json.load(f) + + print("\nPhases:") + for phase in plan.get("phases", []): + phase_subtasks = phase.get("subtasks", []) + phase_completed = sum( + 1 for s in phase_subtasks if s.get("status") == "completed" + ) + phase_total = len(phase_subtasks) + phase_name = phase.get("name", phase.get("id", "Unknown")) + + if phase_completed == phase_total: + status = "complete" + elif phase_completed > 0 or any( + s.get("status") == "in_progress" for s in phase_subtasks + ): + status = "in_progress" + else: + # Check if blocked by dependencies + deps = phase.get("depends_on", []) + all_deps_complete = True + for dep_id in deps: + for p in plan.get("phases", []): + if p.get("id") == dep_id or p.get("phase") == dep_id: + p_subtasks = p.get("subtasks", []) + if not all( + s.get("status") == "completed" for s in p_subtasks + ): + all_deps_complete = False + break + status = "pending" if all_deps_complete else "blocked" + + print_phase_status(phase_name, phase_completed, phase_total, status) + + # Show next subtask if requested + if show_next and completed < total: + next_subtask = get_next_subtask(spec_dir) + if next_subtask: + print() + next_id = next_subtask.get("id", "unknown") + next_desc = next_subtask.get("description", "") + if len(next_desc) > 60: + next_desc = next_desc[:57] + "..." + print( + f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}" + ) + + except (OSError, json.JSONDecodeError): + pass + else: + print() + print_status("No implementation subtasks yet - planner needs to run", "pending") + + +def print_build_complete_banner(spec_dir: Path) -> None: + """Print a completion banner.""" + content = [ + success(f"{icon(Icons.SUCCESS)} BUILD COMPLETE!"), + "", + "All subtasks have been implemented successfully.", + "", + muted("Next steps:"), + f" 1. Review the {highlight('auto-claude/*')} branch", + " 2. Run manual tests", + " 3. Create a PR and merge to main", + ] + + print() + print(box(content, width=70, style="heavy")) + print() + + +def print_paused_banner( + spec_dir: Path, + spec_name: str, + has_worktree: bool = False, +) -> None: + """Print a paused banner with resume instructions.""" + completed, total = count_subtasks(spec_dir) + + content = [ + warning(f"{icon(Icons.PAUSE)} BUILD PAUSED"), + "", + f"Progress saved: {completed}/{total} subtasks complete", + ] + + if has_worktree: + content.append("") + content.append(muted("Your build is in a separate workspace and is safe.")) + + print() + print(box(content, width=70, style="heavy")) + + +def get_plan_summary(spec_dir: Path) -> dict: + """ + Get a detailed summary of implementation plan status. + + Args: + spec_dir: Directory containing implementation_plan.json + + Returns: + Dictionary with plan statistics + """ + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return { + "workflow_type": None, + "total_phases": 0, + "total_subtasks": 0, + "completed_subtasks": 0, + "pending_subtasks": 0, + "in_progress_subtasks": 0, + "failed_subtasks": 0, + "phases": [], + } + + try: + with open(plan_file) as f: + plan = json.load(f) + + summary = { + "workflow_type": plan.get("workflow_type"), + "total_phases": len(plan.get("phases", [])), + "total_subtasks": 0, + "completed_subtasks": 0, + "pending_subtasks": 0, + "in_progress_subtasks": 0, + "failed_subtasks": 0, + "phases": [], + } + + for phase in plan.get("phases", []): + phase_info = { + "id": phase.get("id"), + "phase": phase.get("phase"), + "name": phase.get("name"), + "depends_on": phase.get("depends_on", []), + "subtasks": [], + "completed": 0, + "total": 0, + } + + for subtask in phase.get("subtasks", []): + status = subtask.get("status", "pending") + summary["total_subtasks"] += 1 + phase_info["total"] += 1 + + if status == "completed": + summary["completed_subtasks"] += 1 + phase_info["completed"] += 1 + elif status == "in_progress": + summary["in_progress_subtasks"] += 1 + elif status == "failed": + summary["failed_subtasks"] += 1 + else: + summary["pending_subtasks"] += 1 + + phase_info["subtasks"].append( + { + "id": subtask.get("id"), + "description": subtask.get("description"), + "status": status, + "service": subtask.get("service"), + } + ) + + summary["phases"].append(phase_info) + + return summary + + except (OSError, json.JSONDecodeError): + return { + "workflow_type": None, + "total_phases": 0, + "total_subtasks": 0, + "completed_subtasks": 0, + "pending_subtasks": 0, + "in_progress_subtasks": 0, + "failed_subtasks": 0, + "phases": [], + } + + +def get_current_phase(spec_dir: Path) -> dict | None: + """Get the current phase being worked on.""" + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return None + + try: + with open(plan_file) as f: + plan = json.load(f) + + for phase in plan.get("phases", []): + subtasks = phase.get("subtasks", []) + # Phase is current if it has incomplete subtasks and dependencies are met + has_incomplete = any(s.get("status") != "completed" for s in subtasks) + if has_incomplete: + return { + "id": phase.get("id"), + "phase": phase.get("phase"), + "name": phase.get("name"), + "completed": sum( + 1 for s in subtasks if s.get("status") == "completed" + ), + "total": len(subtasks), + } + + return None + + except (OSError, json.JSONDecodeError): + return None + + +def get_next_subtask(spec_dir: Path) -> dict | None: + """ + Find the next subtask to work on, respecting phase dependencies. + + Args: + spec_dir: Directory containing implementation_plan.json + + Returns: + The next subtask dict to work on, or None if all complete + """ + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return None + + try: + with open(plan_file) as f: + plan = json.load(f) + + phases = plan.get("phases", []) + + # Build a map of phase completion + phase_complete = {} + for phase in phases: + phase_id = phase.get("id") or phase.get("phase") + subtasks = phase.get("subtasks", []) + phase_complete[phase_id] = all( + s.get("status") == "completed" for s in subtasks + ) + + # Find next available subtask + for phase in phases: + phase_id = phase.get("id") or phase.get("phase") + depends_on = phase.get("depends_on", []) + + # Check if dependencies are satisfied + deps_satisfied = all(phase_complete.get(dep, False) for dep in depends_on) + if not deps_satisfied: + continue + + # Find first pending subtask in this phase + for subtask in phase.get("subtasks", []): + if subtask.get("status") == "pending": + return { + "phase_id": phase_id, + "phase_name": phase.get("name"), + "phase_num": phase.get("phase"), + **subtask, + } + + return None + + except (OSError, json.JSONDecodeError): + return None + + +def format_duration(seconds: float) -> str: + """Format a duration in human-readable form.""" + if seconds < 60: + return f"{seconds:.0f}s" + elif seconds < 3600: + minutes = seconds / 60 + return f"{minutes:.1f}m" + else: + hours = seconds / 3600 + return f"{hours:.1f}h" diff --git a/auto-claude/workspace_original.py b/auto-claude/core/workspace.py similarity index 78% rename from auto-claude/workspace_original.py rename to auto-claude/core/workspace.py index 3a9baf78..a3672dcc 100644 --- a/auto-claude/workspace_original.py +++ b/auto-claude/core/workspace.py @@ -6,19 +6,15 @@ Workspace Management - Per-Spec Architecture Handles workspace isolation through Git worktrees, where each spec gets its own isolated worktree in .worktrees/{spec-name}/. -Key changes from old design: -- Each spec has its own worktree (not shared) -- Worktree path: .worktrees/{spec-name}/ -- Branch name: auto-claude/{spec-name} -- Fixed: get_existing_build_worktree() now properly checks spec_name -- Fixed: finalize_workspace() skips prompts in auto_continue mode +This module has been refactored for better maintainability: +- Models and enums: workspace/models.py +- Git utilities: workspace/git_utils.py +- Setup functions: workspace/setup.py +- Display functions: workspace/display.py +- Finalization: workspace/finalization.py +- Complex merge operations: remain here (workspace.py) -Terminology mapping (technical -> user-friendly): -- worktree -> "separate workspace" -- branch -> "version of your project" -- uncommitted changes -> "unsaved work" -- merge -> "add to your project" -- working directory -> "your project" +Public API is exported via workspace/__init__.py for backward compatibility. """ import asyncio @@ -27,7 +23,6 @@ import shutil import subprocess import sys from dataclasses import dataclass -from enum import Enum from pathlib import Path from typing import Optional @@ -69,645 +64,81 @@ from merge import ( FileTimelineTracker, ) -# Track if we've already tried to install the git hook this session -_git_hook_check_done = False +# Import from refactored modules in core/workspace/ +from core.workspace.models import ( + WorkspaceMode, + WorkspaceChoice, + ParallelMergeTask, + ParallelMergeResult, + MergeLock, + MergeLockError, +) + +from core.workspace.git_utils import ( + has_uncommitted_changes, + get_current_branch, + get_existing_build_worktree, + get_file_content_from_ref as _get_file_content_from_ref, + get_changed_files_from_branch as _get_changed_files_from_branch, + is_process_running as _is_process_running, + is_binary_file as _is_binary_file, + is_lock_file as _is_lock_file, + validate_merged_syntax as _validate_merged_syntax, + create_conflict_file_with_git as _create_conflict_file_with_git, + MAX_FILE_LINES_FOR_AI, + MAX_PARALLEL_AI_MERGES, + MAX_SYNTAX_FIX_RETRIES, + BINARY_EXTENSIONS, + LOCK_FILES, + MERGE_LOCK_TIMEOUT, +) + +from core.workspace.setup import ( + choose_workspace, + copy_spec_to_worktree, + setup_workspace, + ensure_timeline_hook_installed as _ensure_timeline_hook_installed, + initialize_timeline_tracking as _initialize_timeline_tracking, +) + +from core.workspace.display import ( + show_build_summary, + show_changed_files, + print_merge_success as _print_merge_success, + print_conflict_info as _print_conflict_info, +) + +from core.workspace.finalization import ( + finalize_workspace, + handle_workspace_choice, + review_existing_build, + discard_existing_build, + check_existing_build, + list_all_worktrees, + cleanup_all_worktrees, +) MODULE = "workspace" - -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 staging 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) -> Path | None: - """ - Check if there's an existing worktree for this specific spec. - - Args: - project_dir: The main project directory - spec_name: The spec folder name (e.g., "001-feature-name") - - Returns: - Path to the worktree if it exists for this spec, None otherwise - """ - # Per-spec worktree path: .worktrees/{spec-name}/ - worktree_path = project_dir / ".worktrees" / spec_name - if worktree_path.exists(): - return worktree_path - return None - - -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. - - Uses simple, non-technical language. Safe defaults. - - Args: - project_dir: The project directory - spec_name: Name of the spec being built - force_isolated: Skip prompts and use isolated mode - force_direct: Skip prompts and use direct mode - auto_continue: Non-interactive mode (for UI integration) - skip all prompts - - Returns: - WorkspaceMode indicating where to work - """ - # Handle forced modes - if force_isolated: - return WorkspaceMode.ISOLATED - if force_direct: - return WorkspaceMode.DIRECT - - # Non-interactive mode: default to isolated for safety - if auto_continue: - print("Auto-continue: Using isolated workspace for safety.") - return WorkspaceMode.ISOLATED - - # Check for unsaved work - has_unsaved = has_uncommitted_changes(project_dir) - - if has_unsaved: - # Unsaved work detected - use isolated mode for safety - 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("Press Enter to continue...") - except KeyboardInterrupt: - print() - print_status("Cancelled.", "info") - sys.exit(0) - - return WorkspaceMode.ISOLATED - - # Clean working directory - give choice with enhanced menu - 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 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. - - The AI's filesystem is restricted to the worktree, so spec files - must be copied inside for access. - - 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 - """ - # Determine target location inside worktree - # Use .auto-claude/specs/{spec_name}/ as the standard location - # Note: auto-claude/ is source code, .auto-claude/ is the installed instance - target_spec_dir = worktree_path / ".auto-claude" / "specs" / spec_name - - # Create parent directories if needed - target_spec_dir.parent.mkdir(parents=True, exist_ok=True) - - # Copy spec files (overwrite if exists to get latest) - 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: Path | None = None, -) -> tuple[Path, WorktreeManager | None, Path | None]: - """ - Set up the workspace based on user's choice. - - 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 (e.g., "001-feature-name") - 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) - - When using isolated mode with source_spec_dir: - - working_directory: Path to the worktree - - worktree_manager: Manager for the worktree - - localized_spec_dir: Path to spec files INSIDE the worktree (accessible to AI) - """ - if mode == WorkspaceMode.DIRECT: - # Work directly in project - spec_dir stays as-is - return project_dir, None, source_spec_dir - - # Create isolated workspace using per-spec worktree - print() - print_status("Setting up separate workspace...", "progress") - - # Ensure timeline tracking hook is installed (once per session) - _ensure_timeline_hook_installed(project_dir) - - manager = WorktreeManager(project_dir) - manager.setup() - - # Get or create worktree for THIS SPECIFIC SPEC - worktree_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, worktree_info.path, spec_name - ) - print_status("Spec files copied to workspace", "success") - - print_status(f"Workspace ready: {worktree_info.path.name}", "success") - print() - - # Initialize FileTimelineTracker for this task - _initialize_timeline_tracking( - project_dir=project_dir, - spec_name=spec_name, - worktree_path=worktree_info.path, - source_spec_dir=localized_spec_dir or source_spec_dir, - ) - - return worktree_info.path, manager, localized_spec_dir - - -def _ensure_timeline_hook_installed(project_dir: Path) -> None: - """ - Ensure the FileTimelineTracker git post-commit hook is installed. - - This enables tracking human commits to main branch for drift detection. - Called once per session during first workspace setup. - """ - global _git_hook_check_done - if _git_hook_check_done: - return - - _git_hook_check_done = True - - try: - git_dir = project_dir / ".git" - if not git_dir.exists(): - return # Not a git repo - - # Handle worktrees (where .git is a file, not directory) - if git_dir.is_file(): - content = git_dir.read_text().strip() - if content.startswith("gitdir:"): - git_dir = Path(content.split(":", 1)[1].strip()) - else: - return - - hook_path = git_dir / "hooks" / "post-commit" - - # Check if hook already installed - if hook_path.exists(): - if "FileTimelineTracker" in hook_path.read_text(): - debug(MODULE, "FileTimelineTracker hook already installed") - return - - # Auto-install the hook (silent, non-intrusive) - from merge.install_hook import install_hook - install_hook(project_dir) - debug(MODULE, "Auto-installed FileTimelineTracker git hook") - - except Exception as e: - # Non-fatal - hook installation is optional - debug_warning(MODULE, f"Could not auto-install timeline hook: {e}") - - -def _initialize_timeline_tracking( - project_dir: Path, - spec_name: str, - worktree_path: Path, - source_spec_dir: Path | None = None, -) -> None: - """ - Initialize FileTimelineTracker for a new task. - - This registers the task's branch point and the files it intends to modify, - enabling intent-aware merge conflict resolution later. - """ - try: - tracker = FileTimelineTracker(project_dir) - - # Get task intent from implementation plan - task_intent = "" - task_title = spec_name - files_to_modify = [] - - if source_spec_dir: - plan_path = source_spec_dir / "implementation_plan.json" - if plan_path.exists(): - import json - with open(plan_path) as f: - plan = json.load(f) - task_title = plan.get("title", spec_name) - task_intent = plan.get("description", "") - - # Extract files from phases/subtasks - for phase in plan.get("phases", []): - for subtask in phase.get("subtasks", []): - files_to_modify.extend(subtask.get("files", [])) - - # Get the current branch point commit - import subprocess - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) - branch_point = result.stdout.strip() if result.returncode == 0 else None - - if files_to_modify and branch_point: - # Register the task with known files - tracker.on_task_start( - task_id=spec_name, - files_to_modify=list(set(files_to_modify)), # Dedupe - branch_point_commit=branch_point, - task_intent=task_intent, - task_title=task_title, - ) - debug(MODULE, f"Timeline tracking initialized for {spec_name}", - files_tracked=len(files_to_modify), - branch_point=branch_point[:8] if branch_point else None) - else: - # Initialize retroactively from worktree if no plan - tracker.initialize_from_worktree( - task_id=spec_name, - worktree_path=worktree_path, - task_intent=task_intent, - task_title=task_title, - ) - - except Exception as e: - # Non-fatal - timeline tracking is supplementary - debug_warning(MODULE, f"Could not initialize timeline tracking: {e}") - print(muted(f" Note: Timeline tracking could not be initialized: {e}")) - - -def show_build_summary(manager: WorktreeManager, spec_name: str) -> None: - """Show a summary of what was built.""" - summary = manager.get_change_summary(spec_name) - files = manager.get_changed_files(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: WorktreeManager | None, - auto_continue: bool = False, -) -> WorkspaceChoice: - """ - Handle post-build workflow - let user decide what to do with changes. - - Safe design: - - No "discard" option (requires separate --discard command) - - Default is "test" - encourages testing before merging - - Everything is preserved until user explicitly merges or discards - - Args: - project_dir: The project directory - spec_name: Name of the spec that was built - manager: The worktree manager (None if direct mode was used) - auto_continue: If True, skip interactive prompts (UI mode) - - Returns: - WorkspaceChoice indicating what user wants to do - """ - if manager is None: - # Direct mode - nothing to finalize - 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 # Already merged - - # In auto_continue mode (UI), skip interactive prompts - # The worktree stays for the UI to manage - if auto_continue: - worktree_info = manager.get_worktree_info(spec_name) - if worktree_info: - print() - print(success(f"Build complete in worktree: {worktree_info.path}")) - print(muted("Worktree preserved for UI review.")) - return WorkspaceChoice.LATER - - # Isolated mode - show options with testing as the recommended path - 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) - - # Get the worktree path for test instructions - worktree_info = manager.get_worktree_info(spec_name) - staging_path = worktree_info.path if worktree_info else None - - # Enhanced menu for post-build options - 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. - - Args: - choice: What the user wants to do - project_dir: The project directory - spec_name: Name of the spec - manager: The worktree manager - """ - worktree_info = manager.get_worktree_info(spec_name) - staging_path = worktree_info.path if worktree_info else None - - if choice == WorkspaceChoice.TEST: - # Show testing instructions - 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() - if staging_path: - print(highlight(f" cd {staging_path}")) - else: - print(highlight(f" cd {project_dir}/.worktrees/{spec_name}")) - - # Show likely test/run commands - if staging_path: - commands = manager.get_test_commands(spec_name) - print() - print("Then run your project:") - for cmd in commands[:2]: # Show top 2 commands - 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:") - if worktree_info: - print( - muted( - f" git diff {worktree_info.base_branch}...{worktree_info.branch}" - ) - ) - print() - print("To test the feature:") - if staging_path: - print(highlight(f" cd {staging_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:") - if staging_path: - print(highlight(f" cd {staging_path}")) - else: - print(highlight(f" cd {project_dir}/.worktrees/{spec_name}")) - 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() - +# The following functions are now imported from refactored modules above. +# They are kept here only to avoid breaking the existing code that still needs +# the complex merge operations below. + +# Remaining complex merge operations that reference each other: +# - merge_existing_build +# - _try_smart_merge +# - _try_smart_merge_inner +# - _check_git_conflicts +# - _resolve_git_conflicts_with_ai +# - _create_async_claude_client +# - _async_ai_call +# - _merge_file_with_ai_async +# - _run_parallel_merges +# - _record_merge_completion +# - _get_task_intent +# - _get_recent_merges_context +# - _merge_file_with_ai +# - _heuristic_merge def merge_existing_build( project_dir: Path, @@ -1236,15 +667,22 @@ def _resolve_git_conflicts_with_ai( simple_merges.append((file_path, None)) # None = delete debug(MODULE, f" {file_path}: deleted (no AI needed)") else: - # File exists in both - needs AI merge - files_needing_ai_merge.append(ParallelMergeTask( - file_path=file_path, - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - )) - debug(MODULE, f" {file_path}: needs AI merge") + # File exists in both - check if it's a lock file + if _is_lock_file(file_path): + # Lock files should never go through AI - just take worktree version + # User can run package manager install to regenerate if needed + simple_merges.append((file_path, worktree_content)) + debug(MODULE, f" {file_path}: lock file (taking worktree version)") + else: + # Regular file - needs AI merge + files_needing_ai_merge.append(ParallelMergeTask( + file_path=file_path, + main_content=main_content, + worktree_content=worktree_content, + base_content=base_content, + spec_name=spec_name, + )) + debug(MODULE, f" {file_path}: needs AI merge") except Exception as e: print(error(f" ✗ Failed to categorize {file_path}: {e}")) @@ -1265,7 +703,11 @@ def _resolve_git_conflicts_with_ai( target_path.write_text(merged_content, encoding="utf-8") subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) resolved_files.append(file_path) - print(success(f" ✓ {file_path} (new file)")) + # Determine the type for display + if _is_lock_file(file_path): + print(success(f" ✓ {file_path} (lock file - took worktree version)")) + else: + print(success(f" ✓ {file_path} (new file)")) else: # Delete the file target_path = project_dir / file_path @@ -1330,14 +772,8 @@ def _resolve_git_conflicts_with_ai( if remaining_conflicts: print(muted(f" Failed: {len(remaining_conflicts)}")) - if remaining_conflicts: - return { - "success": False, - "resolved_files": resolved_files, - "remaining_conflicts": remaining_conflicts, - } - - # All conflicts resolved - now merge remaining non-conflicting files + # ALWAYS process non-conflicting files, even if some conflicts failed + # This ensures we get as much of the build as possible # (New files were already copied at the start) print(muted(" Merging remaining files...")) @@ -1371,18 +807,31 @@ def _resolve_git_conflicts_with_ai( if resolved_files: _record_merge_completion(project_dir, spec_name, resolved_files) - return { - "success": True, + # Build result - partial success if some files failed but we got others + result = { + "success": len(remaining_conflicts) == 0, "resolved_files": resolved_files, "stats": { "files_merged": len(resolved_files), - "conflicts_resolved": len(conflicting_files), + "conflicts_resolved": len(conflicting_files) - len(remaining_conflicts), "ai_assisted": ai_merged_count, "auto_merged": auto_merged_count, "parallel_ai_merges": len(files_needing_ai_merge), }, } + # Add remaining conflicts if any (for UI to show what needs manual attention) + if remaining_conflicts: + result["remaining_conflicts"] = remaining_conflicts + result["partial_success"] = len(resolved_files) > 0 + print() + print(warning(f" ⚠ {len(remaining_conflicts)} file(s) could not be auto-merged:")) + for conflict in remaining_conflicts: + print(muted(f" - {conflict['file']}: {conflict['reason']}")) + print(muted(" These files may need manual review.")) + + return result + def _get_file_content_from_ref(project_dir: Path, ref: str, file_path: str) -> Optional[str]: """Get file content from a git ref (branch, commit, etc.).""" @@ -1702,14 +1151,55 @@ async def _merge_file_with_ai_async( resolutions = extract_conflict_resolutions(response, conflicts, language) if resolutions: merged = reassemble_with_resolutions(merged_content, conflicts, resolutions) - is_valid, _ = _validate_merged_syntax(file_path, merged, project_dir) + is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) + + # Retry loop: if syntax is invalid, give AI feedback to fix it + retry_count = 0 + while not is_valid and retry_count < MAX_SYNTAX_FIX_RETRIES: + retry_count += 1 + debug(MODULE, f"[PARALLEL] Syntax error in merge, retry {retry_count}/{MAX_SYNTAX_FIX_RETRIES}: {file_path}") + + # Build fix prompt with error feedback + fix_prompt = f"""The merged code you produced has a syntax error: + +{error_msg} + +Here is your merged code that has the error: +```{language} +{merged} +``` + +Please fix the syntax error and output the corrected code. Make sure: +1. All lines are properly separated (no concatenated lines) +2. All brackets/braces are properly matched +3. All statements end correctly + +Output ONLY the fixed code, no explanations.""" + + fix_response = await _async_ai_call( + client, + "You are an expert code fixer. Fix the syntax error in the code.", + fix_prompt, + ) + + if fix_response: + fixed = resolver._extract_code_block(fix_response, language) + if not fixed and resolver._looks_like_code(fix_response, language): + fixed = fix_response.strip() + if fixed: + merged = fixed + is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) + if is_valid: - debug_success(MODULE, f"[PARALLEL] Conflict-only merge succeeded: {file_path}") + debug_success(MODULE, f"[PARALLEL] Conflict-only merge succeeded: {file_path}" + + (f" (after {retry_count} fix retries)" if retry_count > 0 else "")) return ParallelMergeResult( file_path=file_path, merged_content=merged, success=True ) + else: + debug(MODULE, f"[PARALLEL] Conflict-only merge failed validation after {retry_count} retries, falling back to full-file: {file_path}") # Case 3: Full-file AI merge (fallback) debug(MODULE, f"[PARALLEL] Full-file merge for: {file_path}") @@ -1736,21 +1226,62 @@ async def _merge_file_with_ai_async( merged = response.strip() if merged: - is_valid, _ = _validate_merged_syntax(file_path, merged, project_dir) + is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) + + # Retry loop: if syntax is invalid, give AI feedback to fix it + retry_count = 0 + while not is_valid and retry_count < MAX_SYNTAX_FIX_RETRIES: + retry_count += 1 + debug(MODULE, f"[PARALLEL] Syntax error in full-file merge, retry {retry_count}/{MAX_SYNTAX_FIX_RETRIES}: {file_path}") + + # Build fix prompt with error feedback + fix_prompt = f"""The merged code you produced has a syntax error: + +{error_msg} + +Here is your merged code that has the error: +```{language} +{merged} +``` + +Please fix the syntax error and output the corrected code. Make sure: +1. All lines are properly separated (no concatenated lines) +2. All brackets/braces are properly matched +3. All statements end correctly + +Output ONLY the fixed code, no explanations.""" + + fix_response = await _async_ai_call( + client, + "You are an expert code fixer. Fix the syntax error in the code.", + fix_prompt, + ) + + if fix_response: + fixed = resolver._extract_code_block(fix_response, language) + if not fixed and resolver._looks_like_code(fix_response, language): + fixed = fix_response.strip() + if fixed: + merged = fixed + is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) + if is_valid: - debug_success(MODULE, f"[PARALLEL] Full-file merge succeeded: {file_path}") + debug_success(MODULE, f"[PARALLEL] Full-file merge succeeded: {file_path}" + + (f" (after {retry_count} fix retries)" if retry_count > 0 else "")) return ParallelMergeResult( file_path=file_path, merged_content=merged, success=True ) + else: + debug_error(MODULE, f"[PARALLEL] Full-file merge failed validation after {retry_count} retries: {file_path}") # AI couldn't merge return ParallelMergeResult( file_path=file_path, merged_content=None, success=False, - error="AI could not merge file" + error="AI could not merge file - syntax validation failed after retries" ) except Exception as e: diff --git a/auto-claude/workspace/README.md b/auto-claude/core/workspace/README.md similarity index 100% rename from auto-claude/workspace/README.md rename to auto-claude/core/workspace/README.md diff --git a/auto-claude/workspace/__init__.py b/auto-claude/core/workspace/__init__.py similarity index 98% rename from auto-claude/workspace/__init__.py rename to auto-claude/core/workspace/__init__.py index f30483ae..c088c313 100644 --- a/auto-claude/workspace/__init__.py +++ b/auto-claude/core/workspace/__init__.py @@ -104,6 +104,7 @@ from .finalization import ( __all__ = [ # Merge Operations (from workspace.py) 'merge_existing_build', + '_run_parallel_merges', # Private but used by tests # Models 'WorkspaceMode', 'WorkspaceChoice', diff --git a/auto-claude/workspace/display.py b/auto-claude/core/workspace/display.py similarity index 100% rename from auto-claude/workspace/display.py rename to auto-claude/core/workspace/display.py diff --git a/auto-claude/workspace/finalization.py b/auto-claude/core/workspace/finalization.py similarity index 100% rename from auto-claude/workspace/finalization.py rename to auto-claude/core/workspace/finalization.py diff --git a/auto-claude/workspace/git_utils.py b/auto-claude/core/workspace/git_utils.py similarity index 100% rename from auto-claude/workspace/git_utils.py rename to auto-claude/core/workspace/git_utils.py diff --git a/auto-claude/workspace/models.py b/auto-claude/core/workspace/models.py similarity index 100% rename from auto-claude/workspace/models.py rename to auto-claude/core/workspace/models.py diff --git a/auto-claude/workspace/setup.py b/auto-claude/core/workspace/setup.py similarity index 100% rename from auto-claude/workspace/setup.py rename to auto-claude/core/workspace/setup.py diff --git a/auto-claude/core/worktree.py b/auto-claude/core/worktree.py new file mode 100644 index 00000000..2aaae53b --- /dev/null +++ b/auto-claude/core/worktree.py @@ -0,0 +1,561 @@ +#!/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 + + +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: str | None = 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: Path | None = 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 _unstage_gitignored_files(self) -> None: + """ + Unstage any staged files that are gitignored in the current branch. + + This is needed after a --no-commit merge because files that exist in the + source branch (like spec files in auto-claude/specs/) get staged even if + they're gitignored in the target branch. + """ + # Get list of staged files + result = self._run_git(["diff", "--cached", "--name-only"]) + if result.returncode != 0 or not result.stdout.strip(): + return + + staged_files = result.stdout.strip().split("\n") + + # Check which staged files are gitignored + # git check-ignore returns the files that ARE ignored + result = self._run_git(["check-ignore", "--stdin"], cwd=self.project_dir) + # We need to pass the files via stdin + result = subprocess.run( + ["git", "check-ignore", "--stdin"], + cwd=self.project_dir, + input="\n".join(staged_files), + capture_output=True, + text=True, + ) + + if not result.stdout.strip(): + return + + ignored_files = result.stdout.strip().split("\n") + + if ignored_files: + print(f"Unstaging {len(ignored_files)} gitignored file(s)...") + # Unstage each ignored file + for file in ignored_files: + if file.strip(): + self._run_git(["reset", "HEAD", "--", file.strip()]) + + 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) -> WorktreeInfo | None: + """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, no_commit: 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 + no_commit: If True, merge changes but don't commit (stage only for review) + + 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 + + if no_commit: + print( + f"Merging {info.branch} into {self.base_branch} (staged, not committed)..." + ) + else: + 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 + merge_args = ["merge", "--no-ff", info.branch] + if no_commit: + # --no-commit stages the merge but doesn't create the commit + merge_args.append("--no-commit") + else: + merge_args.extend(["-m", f"auto-claude: Merge {info.branch}"]) + + result = self._run_git(merge_args) + + if result.returncode != 0: + print("Merge conflict! Aborting merge...") + self._run_git(["merge", "--abort"]) + return False + + if no_commit: + # Unstage any files that are gitignored in the main branch + # These get staged during merge because they exist in the worktree branch + self._unstage_gitignored_files() + print( + f"Changes from {info.branch} are now staged in your working directory." + ) + print("Review the changes, then commit when ready:") + print(" git commit -m 'your commit message'") + else: + 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(): + 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"]) + + 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 + + # ==================== Backward Compatibility ==================== + # These methods provide backward compatibility with the old single-worktree API + + def get_staging_path(self) -> Path | None: + """ + Backward compatibility: Get path to any existing spec worktree. + Prefer using get_worktree_path(spec_name) instead. + """ + worktrees = self.list_all_worktrees() + if worktrees: + return worktrees[0].path + return None + + def get_staging_info(self) -> WorktreeInfo | None: + """ + Backward compatibility: Get info about any existing spec worktree. + Prefer using get_worktree_info(spec_name) instead. + """ + worktrees = self.list_all_worktrees() + if worktrees: + return worktrees[0] + return None + + def merge_staging(self, delete_after: bool = True) -> bool: + """ + Backward compatibility: Merge first found worktree. + Prefer using merge_worktree(spec_name) instead. + """ + worktrees = self.list_all_worktrees() + if worktrees: + return self.merge_worktree(worktrees[0].spec_name, delete_after) + return False + + def remove_staging(self, delete_branch: bool = True) -> None: + """ + Backward compatibility: Remove first found worktree. + Prefer using remove_worktree(spec_name) instead. + """ + worktrees = self.list_all_worktrees() + if worktrees: + self.remove_worktree(worktrees[0].spec_name, delete_branch) + + def get_or_create_staging(self, spec_name: str) -> WorktreeInfo: + """ + Backward compatibility: Alias for get_or_create_worktree. + """ + return self.get_or_create_worktree(spec_name) + + def staging_exists(self) -> bool: + """ + Backward compatibility: Check if any spec worktree exists. + Prefer using worktree_exists(spec_name) instead. + """ + return len(self.list_all_worktrees()) > 0 + + def commit_in_staging(self, message: str) -> bool: + """ + Backward compatibility: Commit in first found worktree. + Prefer using commit_in_worktree(spec_name, message) instead. + """ + worktrees = self.list_all_worktrees() + if worktrees: + return self.commit_in_worktree(worktrees[0].spec_name, message) + return False + + def has_uncommitted_changes(self, in_staging: bool = False) -> bool: + """Check if there are uncommitted changes.""" + worktrees = self.list_all_worktrees() + if in_staging and worktrees: + cwd = worktrees[0].path + else: + cwd = None + result = self._run_git(["status", "--porcelain"], cwd=cwd) + return bool(result.stdout.strip()) + + +# Keep STAGING_WORKTREE_NAME for backward compatibility in imports +STAGING_WORKTREE_NAME = "auto-claude" diff --git a/auto-claude/critique.py b/auto-claude/critique.py index 3308db84..68fc3b57 100644 --- a/auto-claude/critique.py +++ b/auto-claude/critique.py @@ -1,369 +1,2 @@ -#!/usr/bin/env python3 -""" -Self-Critique System -==================== - -Implements a self-critique loop that agents must run before marking subtasks complete. -This helps catch quality issues early, before verification stage. - -The critique system ensures: -- Code follows patterns from reference files -- All required files were modified/created -- Error handling is present -- No debugging artifacts left behind -- Implementation matches subtask requirements -""" - -import re -from dataclasses import dataclass, field - - -@dataclass -class CritiqueResult: - """Result of a self-critique evaluation.""" - - passes: bool - issues: list[str] = field(default_factory=list) - improvements_made: list[str] = field(default_factory=list) - recommendations: list[str] = field(default_factory=list) - - def to_dict(self) -> dict: - """Convert to dictionary for storage.""" - return { - "passes": self.passes, - "issues": self.issues, - "improvements_made": self.improvements_made, - "recommendations": self.recommendations, - } - - @classmethod - def from_dict(cls, data: dict) -> "CritiqueResult": - """Load from dictionary.""" - return cls( - passes=data.get("passes", False), - issues=data.get("issues", []), - improvements_made=data.get("improvements_made", []), - recommendations=data.get("recommendations", []), - ) - - -def generate_critique_prompt( - subtask: dict, files_modified: list[str], patterns_from: list[str] -) -> str: - """ - Generate a critique prompt for the agent to self-evaluate. - - Args: - subtask: The subtask being implemented - files_modified: List of files actually modified - patterns_from: List of pattern files to compare against - - Returns: - Formatted prompt for self-critique - """ - subtask_id = subtask.get("id", "unknown") - subtask_desc = subtask.get("description", "No description") - service = subtask.get("service", "all services") - files_to_modify = subtask.get("files_to_modify", []) - files_to_create = subtask.get("files_to_create", []) - - prompt = f"""## MANDATORY Self-Critique: {subtask_id} - -**Subtask Description:** {subtask_desc} -**Service:** {service} - -Before marking this subtask as complete, you MUST perform a thorough self-critique. -This is NOT optional - it's a required quality gate. - -### STEP 1: Code Quality Checklist - -Review your implementation against these criteria: - -**Pattern Adherence:** -- [ ] Follows patterns from reference files exactly: {", ".join(patterns_from) if patterns_from else "N/A"} -- [ ] Variable naming matches codebase conventions -- [ ] Imports organized correctly (grouped, sorted) -- [ ] Code style consistent with existing files - -**Error Handling:** -- [ ] Try-catch blocks where operations can fail -- [ ] Meaningful error messages -- [ ] Proper error propagation -- [ ] Edge cases considered - -**Code Cleanliness:** -- [ ] No console.log/print statements for debugging -- [ ] No commented-out code blocks -- [ ] No TODO comments without context -- [ ] No hardcoded values that should be configurable - -**Best Practices:** -- [ ] Functions are focused and single-purpose -- [ ] No code duplication -- [ ] Appropriate use of constants -- [ ] Documentation/comments where needed - -### STEP 2: Implementation Completeness - -**Files Modified:** -Expected: {", ".join(files_to_modify) if files_to_modify else "None"} -Actual: {", ".join(files_modified) if files_modified else "None"} -- [ ] All files_to_modify were actually modified -- [ ] No unexpected files were modified - -**Files Created:** -Expected: {", ".join(files_to_create) if files_to_create else "None"} -- [ ] All files_to_create were actually created -- [ ] Files follow naming conventions - -**Requirements:** -- [ ] Subtask description requirements fully met -- [ ] All acceptance criteria from spec considered -- [ ] No scope creep - stayed within subtask boundaries - -### STEP 3: Potential Issues Analysis - -List any concerns, limitations, or potential problems with your implementation: - -1. [Issue 1, or "None identified"] -2. [Issue 2, if any] -3. [Issue 3, if any] - -Be honest. Finding issues now is better than discovering them during verification. - -### STEP 4: Improvements Made - -If you identified issues in your critique, list what you fixed: - -1. [Improvement 1, or "No fixes needed"] -2. [Improvement 2, if applicable] -3. [Improvement 3, if applicable] - -### STEP 5: Final Verdict - -**PROCEED:** [YES/NO - Only YES if all critical items pass] - -**REASON:** [Brief explanation of your decision] - -**CONFIDENCE:** [High/Medium/Low - How confident are you in this implementation?] - ---- - -## Instructions for Agent - -1. Work through each section methodically -2. Check each box honestly - don't skip items -3. If you find issues, FIX THEM before continuing -4. Re-run this critique after fixes -5. Only mark the subtask complete when verdict is YES with High confidence -6. Document your critique results in your response - -Remember: The next session has no context. Quality issues you miss now will be harder to fix later. -""" - - return prompt - - -def parse_critique_response(response: str) -> CritiqueResult: - """ - Parse the agent's critique response into structured data. - - Args: - response: The agent's response to the critique prompt - - Returns: - CritiqueResult with parsed information - """ - issues = [] - improvements = [] - recommendations = [] - passes = False - - # Extract PROCEED verdict - proceed_match = re.search( - r"\*\*PROCEED:\*\*\s*\[?\s*(YES|NO)", response, re.IGNORECASE - ) - if proceed_match: - passes = proceed_match.group(1).upper() == "YES" - - # Extract issues from Step 3 - issues_section = re.search( - r"### STEP 3:.*?Potential Issues.*?\n\n(.*?)(?=###|\Z)", - response, - re.DOTALL | re.IGNORECASE, - ) - if issues_section: - issue_lines = issues_section.group(1).strip().split("\n") - for line in issue_lines: - line = line.strip() - if not line or line.startswith("---"): - continue - # Remove list markers - issue = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip() - # Skip if it's a placeholder or indicates no issues - if ( - issue - and issue.lower() - not in ["none", "none identified", "no issues", "no concerns"] - and issue - not in [ - '[Issue 1, or "None identified"]', - "[Issue 2, if any]", - "[Issue 3, if any]", - ] - ): - issues.append(issue) - - # Extract improvements from Step 4 - improvements_section = re.search( - r"### STEP 4:.*?Improvements Made.*?\n\n(.*?)(?=###|\Z)", - response, - re.DOTALL | re.IGNORECASE, - ) - if improvements_section: - improvement_lines = improvements_section.group(1).strip().split("\n") - for line in improvement_lines: - line = line.strip() - if not line or line.startswith("---"): - continue - # Remove list markers - improvement = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip() - # Skip if it's a placeholder or indicates no improvements - if ( - improvement - and improvement.lower() - not in ["none", "no fixes needed", "no improvements", "n/a"] - and improvement - not in [ - '[Improvement 1, or "No fixes needed"]', - "[Improvement 2, if applicable]", - "[Improvement 3, if applicable]", - ] - ): - improvements.append(improvement) - - # Extract confidence level as recommendation - confidence_match = re.search( - r"\*\*CONFIDENCE:\*\*\s*\[?\s*(High|Medium|Low)", response, re.IGNORECASE - ) - if confidence_match: - confidence = confidence_match.group(1) - if confidence.lower() != "high": - recommendations.append( - f"Confidence level: {confidence} - consider additional review" - ) - - return CritiqueResult( - passes=passes, - issues=issues, - improvements_made=improvements, - recommendations=recommendations, - ) - - -def should_proceed(result: CritiqueResult) -> bool: - """ - Determine if the subtask should be marked complete based on critique. - - Args: - result: The critique result - - Returns: - True if subtask can be marked complete, False otherwise - """ - # Must pass the critique - if not result.passes: - return False - - # If there are unresolved issues, don't proceed - if result.issues: - return False - - return True - - -def format_critique_summary(result: CritiqueResult) -> str: - """ - Format a critique result as a human-readable summary. - - Args: - result: The critique result - - Returns: - Formatted summary string - """ - lines = ["## Critique Summary"] - lines.append("") - lines.append(f"**Status:** {'PASSED ✓' if result.passes else 'FAILED ✗'}") - lines.append("") - - if result.issues: - lines.append("**Issues Identified:**") - for i, issue in enumerate(result.issues, 1): - lines.append(f"{i}. {issue}") - lines.append("") - - if result.improvements_made: - lines.append("**Improvements Made:**") - for i, improvement in enumerate(result.improvements_made, 1): - lines.append(f"{i}. {improvement}") - lines.append("") - - if result.recommendations: - lines.append("**Recommendations:**") - for i, rec in enumerate(result.recommendations, 1): - lines.append(f"{i}. {rec}") - lines.append("") - - if should_proceed(result): - lines.append("**Decision:** Subtask is ready to be marked complete.") - else: - lines.append("**Decision:** Subtask needs more work before completion.") - - return "\n".join(lines) - - -# Example usage for testing -if __name__ == "__main__": - # Demo subtask - subtask = { - "id": "auth-middleware", - "description": "Add JWT authentication middleware", - "service": "backend", - "files_to_modify": ["app/middleware/auth.py"], - "patterns_from": ["app/middleware/cors.py"], - } - - files_modified = ["app/middleware/auth.py"] - - # Generate prompt - prompt = generate_critique_prompt(subtask, files_modified, subtask["patterns_from"]) - print(prompt) - print("\n" + "=" * 80 + "\n") - - # Simulate a critique response - sample_response = """ -### STEP 3: Potential Issues Analysis - -1. Token expiration edge case not fully tested -2. None - -### STEP 4: Improvements Made - -1. Added comprehensive error handling for invalid tokens -2. Improved logging for debugging -3. Added input validation for JWT format - -### STEP 5: Final Verdict - -**PROCEED:** YES - -**REASON:** All critical items verified, patterns followed, error handling complete - -**CONFIDENCE:** High -""" - - # Parse response - result = parse_critique_response(sample_response) - print(format_critique_summary(result)) - print(f"\nShould proceed: {should_proceed(result)}") +"""Backward compatibility shim - import from spec.critique instead.""" +from spec.critique import * diff --git a/auto-claude/debug.py b/auto-claude/debug.py index 9bef363d..ee2df503 100644 --- a/auto-claude/debug.py +++ b/auto-claude/debug.py @@ -1,349 +1,2 @@ -#!/usr/bin/env python3 -""" -Debug Logging Utility -===================== - -Centralized debug logging for the Auto-Claude framework. -Controlled via environment variables: - - DEBUG=true Enable debug mode - - DEBUG_LEVEL=1|2|3 Log verbosity (1=basic, 2=detailed, 3=verbose) - - DEBUG_LOG_FILE=path Optional file output - -Usage: - from debug import debug, debug_detailed, debug_verbose, is_debug_enabled - - debug("run.py", "Starting task execution", task_id="001") - debug_detailed("agent", "Agent response received", response_length=1234) - debug_verbose("client", "Full request payload", payload=data) -""" - -import json -import os -import sys -import time -from datetime import datetime -from functools import wraps -from pathlib import Path -from typing import Any - - -# ANSI color codes for terminal output -class Colors: - RESET = "\033[0m" - BOLD = "\033[1m" - DIM = "\033[2m" - - # Debug colors - DEBUG = "\033[36m" # Cyan - DEBUG_DIM = "\033[96m" # Light cyan - TIMESTAMP = "\033[90m" # Gray - MODULE = "\033[33m" # Yellow - KEY = "\033[35m" # Magenta - VALUE = "\033[37m" # White - SUCCESS = "\033[32m" # Green - WARNING = "\033[33m" # Yellow - ERROR = "\033[31m" # Red - - -def _get_debug_enabled() -> bool: - """Check if debug mode is enabled via environment variable.""" - return os.environ.get("DEBUG", "").lower() in ("true", "1", "yes", "on") - - -def _get_debug_level() -> int: - """Get debug verbosity level (1-3).""" - try: - level = int(os.environ.get("DEBUG_LEVEL", "1")) - return max(1, min(3, level)) # Clamp to 1-3 - except ValueError: - return 1 - - -def _get_log_file() -> Path | None: - """Get optional log file path.""" - log_file = os.environ.get("DEBUG_LOG_FILE") - if log_file: - return Path(log_file) - return None - - -def is_debug_enabled() -> bool: - """Check if debug mode is enabled.""" - return _get_debug_enabled() - - -def get_debug_level() -> int: - """Get current debug level.""" - return _get_debug_level() - - -def _format_value(value: Any, max_length: int = 200) -> str: - """Format a value for debug output, truncating if necessary.""" - if value is None: - return "None" - - if isinstance(value, (dict, list)): - try: - formatted = json.dumps(value, indent=2, default=str) - if len(formatted) > max_length: - formatted = formatted[:max_length] + "..." - return formatted - except (TypeError, ValueError): - return str(value)[:max_length] - - str_value = str(value) - if len(str_value) > max_length: - return str_value[:max_length] + "..." - return str_value - - -def _write_log(message: str, to_file: bool = True) -> None: - """Write log message to stdout and optionally to file.""" - print(message, file=sys.stderr) - - if to_file: - log_file = _get_log_file() - if log_file: - try: - log_file.parent.mkdir(parents=True, exist_ok=True) - # Strip ANSI codes for file output - import re - - clean_message = re.sub(r"\033\[[0-9;]*m", "", message) - with open(log_file, "a") as f: - f.write(clean_message + "\n") - except Exception: - pass # Silently fail file logging - - -def debug(module: str, message: str, level: int = 1, **kwargs) -> None: - """ - Log a debug message. - - Args: - module: Source module name (e.g., "run.py", "ideation_runner") - message: Debug message - level: Required debug level (1=basic, 2=detailed, 3=verbose) - **kwargs: Additional key-value pairs to log - """ - if not _get_debug_enabled(): - return - - if _get_debug_level() < level: - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - - # Build the log line - parts = [ - f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET}", - f"{Colors.DEBUG}[DEBUG]{Colors.RESET}", - f"{Colors.MODULE}[{module}]{Colors.RESET}", - f"{Colors.DEBUG_DIM}{message}{Colors.RESET}", - ] - - log_line = " ".join(parts) - - # Add kwargs on separate lines if present - if kwargs: - for key, value in kwargs.items(): - formatted_value = _format_value(value) - if "\n" in formatted_value: - # Multi-line value - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}:" - for line in formatted_value.split("\n"): - log_line += f"\n {Colors.VALUE}{line}{Colors.RESET}" - else: - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{formatted_value}{Colors.RESET}" - - _write_log(log_line) - - -def debug_detailed(module: str, message: str, **kwargs) -> None: - """Log a detailed debug message (level 2).""" - debug(module, message, level=2, **kwargs) - - -def debug_verbose(module: str, message: str, **kwargs) -> None: - """Log a verbose debug message (level 3).""" - debug(module, message, level=3, **kwargs) - - -def debug_success(module: str, message: str, **kwargs) -> None: - """Log a success debug message.""" - if not _get_debug_enabled(): - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.SUCCESS}[OK]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {message}" - - if kwargs: - for key, value in kwargs.items(): - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" - - _write_log(log_line) - - -def debug_info(module: str, message: str, **kwargs) -> None: - """Log an info debug message.""" - if not _get_debug_enabled(): - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}[INFO]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {message}" - - if kwargs: - for key, value in kwargs.items(): - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" - - _write_log(log_line) - - -def debug_error(module: str, message: str, **kwargs) -> None: - """Log an error debug message (always shown if debug enabled).""" - if not _get_debug_enabled(): - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.ERROR}[ERROR]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.ERROR}{message}{Colors.RESET}" - - if kwargs: - for key, value in kwargs.items(): - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" - - _write_log(log_line) - - -def debug_warning(module: str, message: str, **kwargs) -> None: - """Log a warning debug message.""" - if not _get_debug_enabled(): - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - log_line = f"{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.WARNING}[WARN]{Colors.RESET} {Colors.MODULE}[{module}]{Colors.RESET} {Colors.WARNING}{message}{Colors.RESET}" - - if kwargs: - for key, value in kwargs.items(): - log_line += f"\n {Colors.KEY}{key}{Colors.RESET}: {Colors.VALUE}{_format_value(value)}{Colors.RESET}" - - _write_log(log_line) - - -def debug_section(module: str, title: str) -> None: - """Log a section header for organizing debug output.""" - if not _get_debug_enabled(): - return - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - separator = "─" * 60 - log_line = f"\n{Colors.TIMESTAMP}[{timestamp}]{Colors.RESET} {Colors.DEBUG}{Colors.BOLD}┌{separator}┐{Colors.RESET}" - log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}│ {module}: {title}{' ' * (58 - len(module) - len(title) - 2)}│{Colors.RESET}" - log_line += f"\n{Colors.TIMESTAMP} {Colors.RESET} {Colors.DEBUG}{Colors.BOLD}└{separator}┘{Colors.RESET}" - - _write_log(log_line) - - -def debug_timer(module: str): - """ - Decorator to time function execution. - - Usage: - @debug_timer("run.py") - def my_function(): - ... - """ - - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - if not _get_debug_enabled(): - return func(*args, **kwargs) - - start = time.time() - debug_detailed(module, f"Starting {func.__name__}()") - - try: - result = func(*args, **kwargs) - elapsed = time.time() - start - debug_success( - module, - f"Completed {func.__name__}()", - elapsed_ms=f"{elapsed * 1000:.1f}ms", - ) - return result - except Exception as e: - elapsed = time.time() - start - debug_error( - module, - f"Failed {func.__name__}()", - error=str(e), - elapsed_ms=f"{elapsed * 1000:.1f}ms", - ) - raise - - return wrapper - - return decorator - - -def debug_async_timer(module: str): - """ - Decorator to time async function execution. - - Usage: - @debug_async_timer("ideation_runner") - async def my_async_function(): - ... - """ - - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - if not _get_debug_enabled(): - return await func(*args, **kwargs) - - start = time.time() - debug_detailed(module, f"Starting {func.__name__}()") - - try: - result = await func(*args, **kwargs) - elapsed = time.time() - start - debug_success( - module, - f"Completed {func.__name__}()", - elapsed_ms=f"{elapsed * 1000:.1f}ms", - ) - return result - except Exception as e: - elapsed = time.time() - start - debug_error( - module, - f"Failed {func.__name__}()", - error=str(e), - elapsed_ms=f"{elapsed * 1000:.1f}ms", - ) - raise - - return wrapper - - return decorator - - -def debug_env_status() -> None: - """Print debug environment status on startup.""" - if not _get_debug_enabled(): - return - - debug_section("debug", "Debug Mode Enabled") - debug( - "debug", - "Environment configuration", - DEBUG=os.environ.get("DEBUG", "not set"), - DEBUG_LEVEL=_get_debug_level(), - DEBUG_LOG_FILE=os.environ.get("DEBUG_LOG_FILE", "not set"), - ) - - -# Print status on import if debug is enabled -if _get_debug_enabled(): - debug_env_status() +"""Backward compatibility shim - import from core.debug instead.""" +from core.debug import * diff --git a/auto-claude/graphiti_config.py b/auto-claude/graphiti_config.py index 7b0decc0..6b632227 100644 --- a/auto-claude/graphiti_config.py +++ b/auto-claude/graphiti_config.py @@ -1,529 +1,2 @@ -""" -Graphiti Integration Configuration -================================== - -Constants, status mappings, and configuration helpers for Graphiti memory integration. -Follows the same patterns as linear_config.py for consistency. - -Multi-Provider Support (V2): -- LLM Providers: OpenAI, Anthropic, Azure OpenAI, Ollama -- Embedder Providers: OpenAI, Voyage AI, Azure OpenAI, Ollama - -Environment Variables: - # Core - GRAPHITI_ENABLED: Set to "true" to enable Graphiti integration - GRAPHITI_LLM_PROVIDER: openai|anthropic|azure_openai|ollama (default: openai) - GRAPHITI_EMBEDDER_PROVIDER: openai|voyage|azure_openai|ollama (default: openai) - - # OpenAI - OPENAI_API_KEY: Required for OpenAI provider - OPENAI_MODEL: Model for LLM (default: gpt-5-mini) - OPENAI_EMBEDDING_MODEL: Model for embeddings (default: text-embedding-3-small) - - # Anthropic (LLM only - needs separate embedder) - ANTHROPIC_API_KEY: Required for Anthropic provider - GRAPHITI_ANTHROPIC_MODEL: Model for LLM (default: claude-sonnet-4-5-latest) - - # Azure OpenAI - AZURE_OPENAI_API_KEY: Required for Azure provider - AZURE_OPENAI_BASE_URL: Azure endpoint URL - AZURE_OPENAI_LLM_DEPLOYMENT: Deployment name for LLM - AZURE_OPENAI_EMBEDDING_DEPLOYMENT: Deployment name for embeddings - - # Voyage AI (embeddings only - commonly used with Anthropic) - VOYAGE_API_KEY: Required for Voyage embedder - VOYAGE_EMBEDDING_MODEL: Model (default: voyage-3) - - # Ollama (local) - OLLAMA_BASE_URL: Ollama server URL (default: http://localhost:11434) - OLLAMA_LLM_MODEL: Model for LLM (e.g., deepseek-r1:7b) - OLLAMA_EMBEDDING_MODEL: Model for embeddings (e.g., nomic-embed-text) - OLLAMA_EMBEDDING_DIM: Embedding dimension (required for Ollama, e.g., 768) - - # FalkorDB - GRAPHITI_FALKORDB_HOST: FalkorDB host (default: localhost) - GRAPHITI_FALKORDB_PORT: FalkorDB port (default: 6380) - GRAPHITI_FALKORDB_PASSWORD: FalkorDB password (default: empty) - GRAPHITI_DATABASE: Graph database name (default: auto_claude_memory) - GRAPHITI_TELEMETRY_ENABLED: Set to "false" to disable telemetry (default: true) -""" - -import json -import os -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from pathlib import Path -from typing import Optional - -# Default configuration values -DEFAULT_FALKORDB_HOST = "localhost" -DEFAULT_FALKORDB_PORT = 6380 -DEFAULT_DATABASE = "auto_claude_memory" -DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" - -# Graphiti state marker file (stores connection info and status) -GRAPHITI_STATE_MARKER = ".graphiti_state.json" - -# Episode types for different memory categories -EPISODE_TYPE_SESSION_INSIGHT = "session_insight" -EPISODE_TYPE_CODEBASE_DISCOVERY = "codebase_discovery" -EPISODE_TYPE_PATTERN = "pattern" -EPISODE_TYPE_GOTCHA = "gotcha" -EPISODE_TYPE_TASK_OUTCOME = "task_outcome" -EPISODE_TYPE_QA_RESULT = "qa_result" -EPISODE_TYPE_HISTORICAL_CONTEXT = "historical_context" - - -class LLMProvider(str, Enum): - """Supported LLM providers for Graphiti.""" - - OPENAI = "openai" - ANTHROPIC = "anthropic" - AZURE_OPENAI = "azure_openai" - OLLAMA = "ollama" - - -class EmbedderProvider(str, Enum): - """Supported embedder providers for Graphiti.""" - - OPENAI = "openai" - VOYAGE = "voyage" - AZURE_OPENAI = "azure_openai" - OLLAMA = "ollama" - - -@dataclass -class GraphitiConfig: - """Configuration for Graphiti memory integration with multi-provider support.""" - - # Core settings - enabled: bool = False - llm_provider: str = "openai" - embedder_provider: str = "openai" - - # FalkorDB connection - falkordb_host: str = DEFAULT_FALKORDB_HOST - falkordb_port: int = DEFAULT_FALKORDB_PORT - falkordb_password: str = "" - database: str = DEFAULT_DATABASE - telemetry_enabled: bool = True - - # OpenAI settings - openai_api_key: str = "" - openai_model: str = "gpt-5-mini" - openai_embedding_model: str = "text-embedding-3-small" - - # Anthropic settings (LLM only) - anthropic_api_key: str = "" - anthropic_model: str = "claude-sonnet-4-5-latest" - - # Azure OpenAI settings - azure_openai_api_key: str = "" - azure_openai_base_url: str = "" - azure_openai_llm_deployment: str = "" - azure_openai_embedding_deployment: str = "" - - # Voyage AI settings (embeddings only) - voyage_api_key: str = "" - voyage_embedding_model: str = "voyage-3" - - # Ollama settings (local) - ollama_base_url: str = DEFAULT_OLLAMA_BASE_URL - ollama_llm_model: str = "" - ollama_embedding_model: str = "" - ollama_embedding_dim: int = 0 # Required for Ollama embeddings - - @classmethod - def from_env(cls) -> "GraphitiConfig": - """Create config from environment variables.""" - # Check if Graphiti is explicitly enabled - enabled_str = os.environ.get("GRAPHITI_ENABLED", "").lower() - enabled = enabled_str in ("true", "1", "yes") - - # Provider selection - llm_provider = os.environ.get("GRAPHITI_LLM_PROVIDER", "openai").lower() - embedder_provider = os.environ.get( - "GRAPHITI_EMBEDDER_PROVIDER", "openai" - ).lower() - - # FalkorDB connection settings - falkordb_host = os.environ.get("GRAPHITI_FALKORDB_HOST", DEFAULT_FALKORDB_HOST) - - try: - falkordb_port = int( - os.environ.get("GRAPHITI_FALKORDB_PORT", str(DEFAULT_FALKORDB_PORT)) - ) - except ValueError: - falkordb_port = DEFAULT_FALKORDB_PORT - - falkordb_password = os.environ.get("GRAPHITI_FALKORDB_PASSWORD", "") - database = os.environ.get("GRAPHITI_DATABASE", DEFAULT_DATABASE) - - # Telemetry setting - telemetry_str = os.environ.get("GRAPHITI_TELEMETRY_ENABLED", "true").lower() - telemetry_enabled = telemetry_str not in ("false", "0", "no") - - # OpenAI settings - openai_api_key = os.environ.get("OPENAI_API_KEY", "") - openai_model = os.environ.get("OPENAI_MODEL", "gpt-5-mini") - openai_embedding_model = os.environ.get( - "OPENAI_EMBEDDING_MODEL", "text-embedding-3-small" - ) - - # Anthropic settings - anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "") - anthropic_model = os.environ.get( - "GRAPHITI_ANTHROPIC_MODEL", "claude-sonnet-4-5-latest" - ) - - # Azure OpenAI settings - azure_openai_api_key = os.environ.get("AZURE_OPENAI_API_KEY", "") - azure_openai_base_url = os.environ.get("AZURE_OPENAI_BASE_URL", "") - azure_openai_llm_deployment = os.environ.get("AZURE_OPENAI_LLM_DEPLOYMENT", "") - azure_openai_embedding_deployment = os.environ.get( - "AZURE_OPENAI_EMBEDDING_DEPLOYMENT", "" - ) - - # Voyage AI settings - voyage_api_key = os.environ.get("VOYAGE_API_KEY", "") - voyage_embedding_model = os.environ.get("VOYAGE_EMBEDDING_MODEL", "voyage-3") - - # Ollama settings - ollama_base_url = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL) - ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL", "") - ollama_embedding_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "") - - # Ollama embedding dimension (required for Ollama) - try: - ollama_embedding_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "0")) - except ValueError: - ollama_embedding_dim = 0 - - return cls( - enabled=enabled, - llm_provider=llm_provider, - embedder_provider=embedder_provider, - falkordb_host=falkordb_host, - falkordb_port=falkordb_port, - falkordb_password=falkordb_password, - database=database, - telemetry_enabled=telemetry_enabled, - openai_api_key=openai_api_key, - openai_model=openai_model, - openai_embedding_model=openai_embedding_model, - anthropic_api_key=anthropic_api_key, - anthropic_model=anthropic_model, - azure_openai_api_key=azure_openai_api_key, - azure_openai_base_url=azure_openai_base_url, - azure_openai_llm_deployment=azure_openai_llm_deployment, - azure_openai_embedding_deployment=azure_openai_embedding_deployment, - voyage_api_key=voyage_api_key, - voyage_embedding_model=voyage_embedding_model, - ollama_base_url=ollama_base_url, - ollama_llm_model=ollama_llm_model, - ollama_embedding_model=ollama_embedding_model, - ollama_embedding_dim=ollama_embedding_dim, - ) - - def is_valid(self) -> bool: - """ - Check if config has minimum required values for operation. - - Returns True if: - - GRAPHITI_ENABLED is true - - LLM provider is configured correctly - - Embedder provider is configured correctly - """ - if not self.enabled: - return False - - # Validate LLM provider - if not self._validate_llm_provider(): - return False - - # Validate embedder provider - if not self._validate_embedder_provider(): - return False - - return True - - def _validate_llm_provider(self) -> bool: - """Validate LLM provider configuration.""" - 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_openai_api_key - and self.azure_openai_base_url - and self.azure_openai_llm_deployment - ) - elif self.llm_provider == "ollama": - return bool(self.ollama_llm_model) - return False - - def _validate_embedder_provider(self) -> bool: - """Validate embedder provider configuration.""" - 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_openai_api_key - and self.azure_openai_base_url - and self.azure_openai_embedding_deployment - ) - elif self.embedder_provider == "ollama": - return bool(self.ollama_embedding_model and self.ollama_embedding_dim) - return False - - def get_validation_errors(self) -> list[str]: - """Get list of validation errors for current configuration.""" - errors = [] - - if not self.enabled: - errors.append("GRAPHITI_ENABLED must be set to true") - return errors - - # LLM provider validation - if self.llm_provider == "openai": - if not self.openai_api_key: - errors.append("OpenAI LLM provider requires OPENAI_API_KEY") - elif self.llm_provider == "anthropic": - if not self.anthropic_api_key: - errors.append("Anthropic LLM provider requires ANTHROPIC_API_KEY") - elif self.llm_provider == "azure_openai": - if not self.azure_openai_api_key: - errors.append("Azure OpenAI LLM provider requires AZURE_OPENAI_API_KEY") - if not self.azure_openai_base_url: - errors.append( - "Azure OpenAI LLM provider requires AZURE_OPENAI_BASE_URL" - ) - if not self.azure_openai_llm_deployment: - errors.append( - "Azure OpenAI LLM provider requires AZURE_OPENAI_LLM_DEPLOYMENT" - ) - elif self.llm_provider == "ollama": - if not self.ollama_llm_model: - errors.append("Ollama LLM provider requires OLLAMA_LLM_MODEL") - else: - errors.append(f"Unknown LLM provider: {self.llm_provider}") - - # Embedder provider validation - if self.embedder_provider == "openai": - if not self.openai_api_key: - errors.append("OpenAI embedder provider requires OPENAI_API_KEY") - elif self.embedder_provider == "voyage": - if not self.voyage_api_key: - errors.append("Voyage embedder provider requires VOYAGE_API_KEY") - elif self.embedder_provider == "azure_openai": - if not self.azure_openai_api_key: - errors.append( - "Azure OpenAI embedder provider requires AZURE_OPENAI_API_KEY" - ) - if not self.azure_openai_base_url: - errors.append( - "Azure OpenAI embedder provider requires AZURE_OPENAI_BASE_URL" - ) - if not self.azure_openai_embedding_deployment: - errors.append( - "Azure OpenAI embedder provider requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT" - ) - elif self.embedder_provider == "ollama": - if not self.ollama_embedding_model: - errors.append( - "Ollama embedder provider requires OLLAMA_EMBEDDING_MODEL" - ) - if not self.ollama_embedding_dim: - errors.append("Ollama embedder provider requires OLLAMA_EMBEDDING_DIM") - else: - errors.append(f"Unknown embedder provider: {self.embedder_provider}") - - return errors - - def get_connection_uri(self) -> str: - """Get the FalkorDB connection URI.""" - if self.falkordb_password: - return f"redis://:{self.falkordb_password}@{self.falkordb_host}:{self.falkordb_port}" - return f"redis://{self.falkordb_host}:{self.falkordb_port}" - - def get_provider_summary(self) -> str: - """Get a summary of configured providers.""" - return f"LLM: {self.llm_provider}, Embedder: {self.embedder_provider}" - - -@dataclass -class GraphitiState: - """State of Graphiti integration for an auto-claude spec.""" - - initialized: bool = False - database: str | None = None - indices_built: bool = False - created_at: str | None = None - last_session: int | None = None - episode_count: int = 0 - error_log: list = field(default_factory=list) - # V2 additions - llm_provider: str | None = None - embedder_provider: str | None = None - - def to_dict(self) -> dict: - return { - "initialized": self.initialized, - "database": self.database, - "indices_built": self.indices_built, - "created_at": self.created_at, - "last_session": self.last_session, - "episode_count": self.episode_count, - "error_log": self.error_log[-10:], # Keep last 10 errors - "llm_provider": self.llm_provider, - "embedder_provider": self.embedder_provider, - } - - @classmethod - def from_dict(cls, data: dict) -> "GraphitiState": - return cls( - initialized=data.get("initialized", False), - database=data.get("database"), - indices_built=data.get("indices_built", False), - created_at=data.get("created_at"), - last_session=data.get("last_session"), - episode_count=data.get("episode_count", 0), - error_log=data.get("error_log", []), - llm_provider=data.get("llm_provider"), - embedder_provider=data.get("embedder_provider"), - ) - - def save(self, spec_dir: Path) -> None: - """Save state to the spec directory.""" - marker_file = spec_dir / GRAPHITI_STATE_MARKER - with open(marker_file, "w") as f: - json.dump(self.to_dict(), f, indent=2) - - @classmethod - def load(cls, spec_dir: Path) -> Optional["GraphitiState"]: - """Load state from the spec directory.""" - marker_file = spec_dir / GRAPHITI_STATE_MARKER - if not marker_file.exists(): - return None - - try: - with open(marker_file) as f: - return cls.from_dict(json.load(f)) - except (OSError, json.JSONDecodeError): - return None - - def record_error(self, error_msg: str) -> None: - """Record an error in the state.""" - self.error_log.append( - { - "timestamp": datetime.now().isoformat(), - "error": error_msg[:500], # Limit error message length - } - ) - # Keep only last 10 errors - self.error_log = self.error_log[-10:] - - -def is_graphiti_enabled() -> bool: - """ - Quick check if Graphiti integration is available. - - Returns True if: - - GRAPHITI_ENABLED is set to true/1/yes - - Required provider credentials are configured - """ - config = GraphitiConfig.from_env() - return config.is_valid() - - -def get_graphiti_status() -> dict: - """ - Get the current Graphiti integration status. - - Returns: - Dict with status information: - - enabled: bool - - available: bool (has required dependencies) - - host: str - - port: int - - database: str - - llm_provider: str - - embedder_provider: str - - reason: str (why unavailable if not available) - - errors: list (validation errors if any) - """ - config = GraphitiConfig.from_env() - - status = { - "enabled": config.enabled, - "available": False, - "host": config.falkordb_host, - "port": config.falkordb_port, - "database": config.database, - "llm_provider": config.llm_provider, - "embedder_provider": config.embedder_provider, - "reason": "", - "errors": [], - } - - if not config.enabled: - status["reason"] = "GRAPHITI_ENABLED not set to true" - return status - - # Get validation errors - errors = config.get_validation_errors() - if errors: - status["errors"] = errors - status["reason"] = errors[0] # First error as primary reason - return status - - status["available"] = True - return status - - -def get_available_providers() -> dict: - """ - Get list of available providers based on current environment. - - Returns: - Dict with lists of available LLM and embedder providers - """ - config = GraphitiConfig.from_env() - - available_llm = [] - available_embedder = [] - - # Check OpenAI - if config.openai_api_key: - available_llm.append("openai") - available_embedder.append("openai") - - # Check Anthropic - if config.anthropic_api_key: - available_llm.append("anthropic") - - # Check Azure OpenAI - if config.azure_openai_api_key and config.azure_openai_base_url: - if config.azure_openai_llm_deployment: - available_llm.append("azure_openai") - if config.azure_openai_embedding_deployment: - available_embedder.append("azure_openai") - - # Check Voyage - if config.voyage_api_key: - available_embedder.append("voyage") - - # Check Ollama - if config.ollama_llm_model: - available_llm.append("ollama") - if config.ollama_embedding_model and config.ollama_embedding_dim: - available_embedder.append("ollama") - - return { - "llm_providers": available_llm, - "embedder_providers": available_embedder, - } +"""Backward compatibility shim - import from integrations.graphiti.config instead.""" +from integrations.graphiti.config import * diff --git a/auto-claude/graphiti_providers.py b/auto-claude/graphiti_providers.py index 87affdec..6ca2cdba 100644 --- a/auto-claude/graphiti_providers.py +++ b/auto-claude/graphiti_providers.py @@ -1,70 +1,2 @@ -""" -Graphiti Multi-Provider Entry Point -==================================== - -Main entry point for Graphiti provider functionality. -This module re-exports all functionality from the graphiti_providers package. - -The actual implementation has been refactored into a package structure: -- graphiti_providers/exceptions.py - Provider exceptions -- graphiti_providers/models.py - Embedding dimensions and constants -- graphiti_providers/llm_providers/ - LLM provider implementations -- graphiti_providers/embedder_providers/ - Embedder provider implementations -- graphiti_providers/cross_encoder.py - Cross-encoder/reranker -- graphiti_providers/validators.py - Validation and health checks -- graphiti_providers/utils.py - Utility functions -- graphiti_providers/factory.py - Factory functions - -For backward compatibility, this module re-exports all public APIs. - -Usage: - from graphiti_providers import create_llm_client, create_embedder - from graphiti_config import GraphitiConfig - - config = GraphitiConfig.from_env() - llm_client = create_llm_client(config) - embedder = create_embedder(config) -""" - -# Re-export all public APIs from the package -from graphiti_providers import ( - # Exceptions - ProviderError, - ProviderNotInstalled, - # Factory functions - create_llm_client, - create_embedder, - create_cross_encoder, - # Models - EMBEDDING_DIMENSIONS, - get_expected_embedding_dim, - # Validators - validate_embedding_config, - test_llm_connection, - test_embedder_connection, - test_ollama_connection, - # Utilities - is_graphiti_enabled, - get_graph_hints, -) - -__all__ = [ - # Exceptions - "ProviderError", - "ProviderNotInstalled", - # Factory functions - "create_llm_client", - "create_embedder", - "create_cross_encoder", - # Models - "EMBEDDING_DIMENSIONS", - "get_expected_embedding_dim", - # Validators - "validate_embedding_config", - "test_llm_connection", - "test_embedder_connection", - "test_ollama_connection", - # Utilities - "is_graphiti_enabled", - "get_graph_hints", -] +"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead.""" +from integrations.graphiti.providers_pkg import * diff --git a/auto-claude/graphiti_providers.py.bak b/auto-claude/graphiti_providers.py.bak deleted file mode 100644 index f97a166e..00000000 --- a/auto-claude/graphiti_providers.py.bak +++ /dev/null @@ -1,705 +0,0 @@ -""" -Graphiti Multi-Provider Factory -================================ - -Factory functions for creating LLM clients and embedders for Graphiti. -Supports multiple providers: OpenAI, Anthropic, Azure OpenAI, and Ollama. - -This module provides: -- Lazy imports to avoid ImportError when provider packages not installed -- Factory functions that create the correct client based on provider selection -- Provider-specific configuration validation -- Graceful error handling with helpful messages - -Usage: - from graphiti_providers import create_llm_client, create_embedder - from graphiti_config import GraphitiConfig - - config = GraphitiConfig.from_env() - llm_client = create_llm_client(config) - embedder = create_embedder(config) -""" - -import logging -from typing import TYPE_CHECKING, Any, Optional - -if TYPE_CHECKING: - from pathlib import Path - - from graphiti_config import GraphitiConfig - -logger = logging.getLogger(__name__) - - -class ProviderError(Exception): - """Raised when a provider cannot be initialized.""" - - pass - - -class ProviderNotInstalled(ProviderError): - """Raised when required packages for a provider are not installed.""" - - pass - - -# ============================================================================ -# LLM Client Factory -# ============================================================================ - - -def create_llm_client(config: "GraphitiConfig") -> Any: - """ - Create an LLM client based on the configured provider. - - Args: - config: GraphitiConfig with provider settings - - Returns: - LLM client instance for Graphiti - - Raises: - ProviderNotInstalled: If required packages are missing - ProviderError: If client creation fails - """ - provider = config.llm_provider - - logger.info(f"Creating LLM client for provider: {provider}") - - if provider == "openai": - return _create_openai_llm_client(config) - elif provider == "anthropic": - return _create_anthropic_llm_client(config) - elif provider == "azure_openai": - return _create_azure_openai_llm_client(config) - elif provider == "ollama": - return _create_ollama_llm_client(config) - else: - raise ProviderError(f"Unknown LLM provider: {provider}") - - -def _create_openai_llm_client(config: "GraphitiConfig") -> Any: - """Create OpenAI LLM client.""" - try: - from graphiti_core.llm_client.config import LLMConfig - from graphiti_core.llm_client.openai_client import OpenAIClient - except ImportError as e: - raise ProviderNotInstalled( - f"OpenAI provider requires graphiti-core. " - f"Install with: pip install graphiti-core\n" - f"Error: {e}" - ) - - if not config.openai_api_key: - raise ProviderError("OpenAI provider requires OPENAI_API_KEY") - - llm_config = LLMConfig( - api_key=config.openai_api_key, - model=config.openai_model, - ) - - # GPT-5 family and o1/o3 models support reasoning/verbosity params - model_lower = config.openai_model.lower() - supports_reasoning = ( - model_lower.startswith("gpt-5") - or model_lower.startswith("o1") - or model_lower.startswith("o3") - ) - - if supports_reasoning: - # Use defaults for models that support reasoning params - return OpenAIClient(config=llm_config) - else: - # Disable reasoning/verbosity for older models that don't support them - return OpenAIClient(config=llm_config, reasoning=None, verbosity=None) - - -def _create_anthropic_llm_client(config: "GraphitiConfig") -> Any: - """Create Anthropic LLM client.""" - try: - from graphiti_core.llm_client.anthropic_client import AnthropicClient - from graphiti_core.llm_client.config import LLMConfig - except ImportError as e: - raise ProviderNotInstalled( - f"Anthropic provider requires graphiti-core[anthropic]. " - f"Install with: pip install graphiti-core[anthropic]\n" - f"Error: {e}" - ) - - if not config.anthropic_api_key: - raise ProviderError("Anthropic provider requires ANTHROPIC_API_KEY") - - llm_config = LLMConfig( - api_key=config.anthropic_api_key, - model=config.anthropic_model, - ) - - return AnthropicClient(config=llm_config) - - -def _create_azure_openai_llm_client(config: "GraphitiConfig") -> Any: - """Create Azure OpenAI LLM client.""" - try: - from graphiti_core.llm_client.azure_openai_client import AzureOpenAILLMClient - from graphiti_core.llm_client.config import LLMConfig - from openai import AsyncOpenAI - except ImportError as e: - raise ProviderNotInstalled( - f"Azure OpenAI provider requires graphiti-core and openai. " - f"Install with: pip install graphiti-core openai\n" - f"Error: {e}" - ) - - if not config.azure_openai_api_key: - raise ProviderError("Azure OpenAI provider requires AZURE_OPENAI_API_KEY") - if not config.azure_openai_base_url: - raise ProviderError("Azure OpenAI provider requires AZURE_OPENAI_BASE_URL") - if not config.azure_openai_llm_deployment: - raise ProviderError( - "Azure OpenAI provider requires AZURE_OPENAI_LLM_DEPLOYMENT" - ) - - azure_client = AsyncOpenAI( - base_url=config.azure_openai_base_url, - api_key=config.azure_openai_api_key, - ) - - llm_config = LLMConfig( - model=config.azure_openai_llm_deployment, - small_model=config.azure_openai_llm_deployment, - ) - - return AzureOpenAILLMClient(azure_client=azure_client, config=llm_config) - - -def _create_ollama_llm_client(config: "GraphitiConfig") -> Any: - """Create Ollama LLM client (using OpenAI-compatible interface).""" - try: - from graphiti_core.llm_client.config import LLMConfig - from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient - except ImportError as e: - raise ProviderNotInstalled( - f"Ollama provider requires graphiti-core. " - f"Install with: pip install graphiti-core\n" - f"Error: {e}" - ) - - if not config.ollama_llm_model: - raise ProviderError("Ollama provider requires OLLAMA_LLM_MODEL") - - # Ensure Ollama base URL ends with /v1 for OpenAI compatibility - base_url = config.ollama_base_url - if not base_url.endswith("/v1"): - base_url = base_url.rstrip("/") + "/v1" - - llm_config = LLMConfig( - api_key="ollama", # Ollama requires a dummy API key - model=config.ollama_llm_model, - small_model=config.ollama_llm_model, - base_url=base_url, - ) - - return OpenAIGenericClient(config=llm_config) - - -# ============================================================================ -# Embedder Factory -# ============================================================================ - - -def create_embedder(config: "GraphitiConfig") -> Any: - """ - Create an embedder based on the configured provider. - - Args: - config: GraphitiConfig with provider settings - - Returns: - Embedder instance for Graphiti - - Raises: - ProviderNotInstalled: If required packages are missing - ProviderError: If embedder creation fails - """ - provider = config.embedder_provider - - logger.info(f"Creating embedder for provider: {provider}") - - if provider == "openai": - return _create_openai_embedder(config) - elif provider == "voyage": - return _create_voyage_embedder(config) - elif provider == "azure_openai": - return _create_azure_openai_embedder(config) - elif provider == "ollama": - return _create_ollama_embedder(config) - else: - raise ProviderError(f"Unknown embedder provider: {provider}") - - -def _create_openai_embedder(config: "GraphitiConfig") -> Any: - """Create OpenAI embedder.""" - try: - from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig - except ImportError as e: - raise ProviderNotInstalled( - f"OpenAI embedder requires graphiti-core. " - f"Install with: pip install graphiti-core\n" - f"Error: {e}" - ) - - if not config.openai_api_key: - raise ProviderError("OpenAI embedder requires OPENAI_API_KEY") - - embedder_config = OpenAIEmbedderConfig( - api_key=config.openai_api_key, - embedding_model=config.openai_embedding_model, - ) - - return OpenAIEmbedder(config=embedder_config) - - -def _create_voyage_embedder(config: "GraphitiConfig") -> Any: - """Create Voyage AI embedder (commonly used with Anthropic LLM).""" - try: - from graphiti_core.embedder.voyage import VoyageAIConfig, VoyageEmbedder - except ImportError as e: - raise ProviderNotInstalled( - f"Voyage embedder requires graphiti-core[voyage]. " - f"Install with: pip install graphiti-core[voyage]\n" - f"Error: {e}" - ) - - if not config.voyage_api_key: - raise ProviderError("Voyage embedder requires VOYAGE_API_KEY") - - voyage_config = VoyageAIConfig( - api_key=config.voyage_api_key, - embedding_model=config.voyage_embedding_model, - ) - - return VoyageEmbedder(config=voyage_config) - - -def _create_azure_openai_embedder(config: "GraphitiConfig") -> Any: - """Create Azure OpenAI embedder.""" - try: - from graphiti_core.embedder.azure_openai import AzureOpenAIEmbedderClient - from openai import AsyncOpenAI - except ImportError as e: - raise ProviderNotInstalled( - f"Azure OpenAI embedder requires graphiti-core and openai. " - f"Install with: pip install graphiti-core openai\n" - f"Error: {e}" - ) - - if not config.azure_openai_api_key: - raise ProviderError("Azure OpenAI embedder requires AZURE_OPENAI_API_KEY") - if not config.azure_openai_base_url: - raise ProviderError("Azure OpenAI embedder requires AZURE_OPENAI_BASE_URL") - if not config.azure_openai_embedding_deployment: - raise ProviderError( - "Azure OpenAI embedder requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT" - ) - - azure_client = AsyncOpenAI( - base_url=config.azure_openai_base_url, - api_key=config.azure_openai_api_key, - ) - - return AzureOpenAIEmbedderClient( - azure_client=azure_client, - model=config.azure_openai_embedding_deployment, - ) - - -def _create_ollama_embedder(config: "GraphitiConfig") -> Any: - """Create Ollama embedder (using OpenAI-compatible interface).""" - try: - from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig - except ImportError as e: - raise ProviderNotInstalled( - f"Ollama embedder requires graphiti-core. " - f"Install with: pip install graphiti-core\n" - f"Error: {e}" - ) - - if not config.ollama_embedding_model: - raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL") - - # Ensure Ollama base URL ends with /v1 for OpenAI compatibility - base_url = config.ollama_base_url - if not base_url.endswith("/v1"): - base_url = base_url.rstrip("/") + "/v1" - - embedder_config = OpenAIEmbedderConfig( - api_key="ollama", # Ollama requires a dummy API key - embedding_model=config.ollama_embedding_model, - embedding_dim=config.ollama_embedding_dim, - base_url=base_url, - ) - - return OpenAIEmbedder(config=embedder_config) - - -# ============================================================================ -# Cross-Encoder / Reranker Factory (Optional) -# ============================================================================ - - -def create_cross_encoder( - config: "GraphitiConfig", llm_client: Any = None -) -> Any | None: - """ - Create a cross-encoder/reranker for improved search quality. - - This is optional and primarily useful for Ollama setups. - Other providers typically have built-in reranking. - - Args: - config: GraphitiConfig with provider settings - llm_client: Optional LLM client for reranking - - Returns: - Cross-encoder instance, or None if not applicable - """ - # Only create for Ollama provider currently - if config.llm_provider != "ollama": - return None - - if llm_client is None: - return None - - try: - from graphiti_core.cross_encoder.openai_reranker_client import ( - OpenAIRerankerClient, - ) - from graphiti_core.llm_client.config import LLMConfig - except ImportError: - logger.debug("Cross-encoder not available (optional)") - return None - - try: - # Create LLM config for reranker - base_url = config.ollama_base_url - if not base_url.endswith("/v1"): - base_url = base_url.rstrip("/") + "/v1" - - llm_config = LLMConfig( - api_key="ollama", - model=config.ollama_llm_model, - base_url=base_url, - ) - - return OpenAIRerankerClient(client=llm_client, config=llm_config) - except Exception as e: - logger.warning(f"Could not create cross-encoder: {e}") - return None - - -# ============================================================================ -# Embedding Dimension Validation -# ============================================================================ - -# Known embedding dimensions by provider and model -EMBEDDING_DIMENSIONS = { - # OpenAI - "text-embedding-3-small": 1536, - "text-embedding-3-large": 3072, - "text-embedding-ada-002": 1536, - # Voyage AI - "voyage-3": 1024, - "voyage-3.5": 1024, - "voyage-3-lite": 512, - "voyage-3.5-lite": 512, - "voyage-2": 1024, - "voyage-large-2": 1536, - # Ollama (common models) - "nomic-embed-text": 768, - "mxbai-embed-large": 1024, - "all-minilm": 384, - "snowflake-arctic-embed": 1024, -} - - -def get_expected_embedding_dim(model: str) -> int | None: - """ - Get the expected embedding dimension for a known model. - - Args: - model: Embedding model name - - Returns: - Expected dimension, or None if unknown - """ - # Try exact match first - if model in EMBEDDING_DIMENSIONS: - return EMBEDDING_DIMENSIONS[model] - - # Try partial match (model name might have version suffix) - model_lower = model.lower() - for known_model, dim in EMBEDDING_DIMENSIONS.items(): - if known_model.lower() in model_lower or model_lower in known_model.lower(): - return dim - - return None - - -def validate_embedding_config(config: "GraphitiConfig") -> tuple[bool, str]: - """ - Validate embedding configuration for consistency. - - Checks that embedding dimensions are correctly configured, - especially important for Ollama where explicit dimension is required. - - Args: - config: GraphitiConfig to validate - - Returns: - Tuple of (is_valid, message) - """ - provider = config.embedder_provider - - if provider == "ollama": - # Ollama requires explicit embedding dimension - if not config.ollama_embedding_dim: - expected = get_expected_embedding_dim(config.ollama_embedding_model) - if expected: - return False, ( - f"Ollama embedder requires OLLAMA_EMBEDDING_DIM. " - f"For model '{config.ollama_embedding_model}', " - f"expected dimension is {expected}." - ) - else: - return False, ( - "Ollama embedder requires OLLAMA_EMBEDDING_DIM. " - "Check your model's documentation for the correct dimension." - ) - - # Check for known dimension mismatches - if provider == "openai": - expected = get_expected_embedding_dim(config.openai_embedding_model) - # OpenAI handles this automatically, just log info - if expected: - logger.debug( - f"OpenAI embedding model '{config.openai_embedding_model}' has dimension {expected}" - ) - - elif provider == "voyage": - expected = get_expected_embedding_dim(config.voyage_embedding_model) - if expected: - logger.debug( - f"Voyage embedding model '{config.voyage_embedding_model}' has dimension {expected}" - ) - - return True, "Embedding configuration valid" - - -# ============================================================================ -# Provider Health Checks -# ============================================================================ - - -async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]: - """ - Test if LLM provider is reachable. - - Args: - config: GraphitiConfig with provider settings - - Returns: - Tuple of (success, message) - """ - try: - llm_client = create_llm_client(config) - # Most clients don't have a ping method, so just verify creation succeeded - return ( - True, - f"LLM client created successfully for provider: {config.llm_provider}", - ) - except ProviderNotInstalled as e: - return False, str(e) - except ProviderError as e: - return False, str(e) - except Exception as e: - return False, f"Failed to create LLM client: {e}" - - -async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]: - """ - Test if embedder provider is reachable. - - Args: - config: GraphitiConfig with provider settings - - Returns: - Tuple of (success, message) - """ - # First validate config - valid, msg = validate_embedding_config(config) - if not valid: - return False, msg - - try: - embedder = create_embedder(config) - return ( - True, - f"Embedder created successfully for provider: {config.embedder_provider}", - ) - except ProviderNotInstalled as e: - return False, str(e) - except ProviderError as e: - return False, str(e) - except Exception as e: - return False, f"Failed to create embedder: {e}" - - -async def test_ollama_connection( - base_url: str = "http://localhost:11434", -) -> tuple[bool, str]: - """ - Test if Ollama server is running and reachable. - - Args: - base_url: Ollama server URL - - Returns: - Tuple of (success, message) - """ - import asyncio - - try: - import aiohttp - except ImportError: - # Fall back to sync request - import urllib.error - import urllib.request - - try: - # Normalize URL (remove /v1 suffix if present) - url = base_url.rstrip("/") - if url.endswith("/v1"): - url = url[:-3] - - req = urllib.request.Request(f"{url}/api/tags", method="GET") - with urllib.request.urlopen(req, timeout=5) as response: - if response.status == 200: - return True, f"Ollama is running at {url}" - return False, f"Ollama returned status {response.status}" - except urllib.error.URLError as e: - return False, f"Cannot connect to Ollama at {url}: {e.reason}" - except Exception as e: - return False, f"Ollama connection error: {e}" - - # Use aiohttp if available - try: - # Normalize URL - url = base_url.rstrip("/") - if url.endswith("/v1"): - url = url[:-3] - - async with aiohttp.ClientSession() as session: - async with session.get( - f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5) - ) as response: - if response.status == 200: - return True, f"Ollama is running at {url}" - return False, f"Ollama returned status {response.status}" - except asyncio.TimeoutError: - return False, f"Ollama connection timed out at {url}" - except aiohttp.ClientError as e: - return False, f"Cannot connect to Ollama at {url}: {e}" - except Exception as e: - return False, f"Ollama connection error: {e}" - - -# ============================================================================ -# Re-exports and Convenience Functions -# ============================================================================ - - -def is_graphiti_enabled() -> bool: - """ - Check if Graphiti memory integration is available and configured. - - This is a convenience re-export from graphiti_config. - Returns True if GRAPHITI_ENABLED=true and provider credentials are valid. - """ - from graphiti_config import is_graphiti_enabled as _is_graphiti_enabled - - return _is_graphiti_enabled() - - -async def get_graph_hints( - query: str, - project_id: str, - max_results: int = 10, - spec_dir: Optional["Path"] = None, -) -> list[dict]: - """ - Get relevant hints from the Graphiti knowledge graph. - - This is a convenience function for querying historical context - from the memory system. Used by spec_runner, ideation_runner, - and roadmap_runner to inject historical insights. - - Args: - query: Search query (e.g., "authentication patterns", "API design") - project_id: Project identifier for scoping results - max_results: Maximum number of hints to return - spec_dir: Optional spec directory for loading memory instance - - Returns: - List of hint dictionaries with keys: - - content: str - The hint content - - score: float - Relevance score - - type: str - Type of hint (pattern, gotcha, outcome, etc.) - - Note: - Returns empty list if Graphiti is not enabled or unavailable. - This function never raises - it always fails gracefully. - """ - if not is_graphiti_enabled(): - logger.debug("Graphiti not enabled, returning empty hints") - return [] - - try: - from pathlib import Path - - from graphiti_memory import GraphitiMemory, GroupIdMode - - # Determine project directory from project_id or use current dir - project_dir = Path.cwd() - - # Use spec_dir if provided, otherwise create a temp context - if spec_dir is None: - # Create a temporary spec dir for the query - import tempfile - - spec_dir = Path(tempfile.mkdtemp(prefix="graphiti_query_")) - - # Create memory instance with project-level scope for cross-spec hints - memory = GraphitiMemory( - spec_dir=spec_dir, - project_dir=project_dir, - group_id_mode=GroupIdMode.PROJECT, - ) - - # Query for relevant context - hints = await memory.get_relevant_context( - query=query, - num_results=max_results, - include_project_context=True, - ) - - await memory.close() - - logger.info(f"Retrieved {len(hints)} graph hints for query: {query[:50]}...") - return hints - - except ImportError as e: - logger.debug(f"Graphiti packages not available: {e}") - return [] - except Exception as e: - logger.warning(f"Failed to get graph hints: {e}") - return [] diff --git a/auto-claude/ideation_runner_old.py.bak b/auto-claude/ideation_runner_old.py.bak deleted file mode 100644 index 56eb47c7..00000000 --- a/auto-claude/ideation_runner_old.py.bak +++ /dev/null @@ -1,1122 +0,0 @@ -#!/usr/bin/env python3 -""" -Ideation Creation Orchestrator -============================== - -AI-powered ideation generation for projects. -Analyzes project context, existing features, and generates three types of ideas: -1. Low-Hanging Fruit - Quick wins building on existing patterns -2. UI/UX Improvements - Visual and interaction enhancements -3. High-Value Features - Strategic features for target users - -Usage: - python auto-claude/ideation_runner.py --project /path/to/project - python auto-claude/ideation_runner.py --project /path/to/project --types low_hanging_fruit,high_value_features - python auto-claude/ideation_runner.py --project /path/to/project --refresh -""" - -import asyncio -import json -import os -import subprocess -import sys -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Optional, List - -# Add auto-claude to path -sys.path.insert(0, str(Path(__file__).parent)) - -# Load .env file -from dotenv import load_dotenv -env_file = Path(__file__).parent / ".env" -if env_file.exists(): - load_dotenv(env_file) - -from client import create_client -from ui import ( - Icons, - icon, - box, - success, - error, - warning, - info, - muted, - highlight, - bold, - print_status, - print_key_value, - print_section, -) -from debug import ( - debug, - debug_detailed, - debug_verbose, - debug_success, - debug_error, - debug_warning, - debug_section, -) -from graphiti_providers import get_graph_hints, is_graphiti_enabled -from init import init_auto_claude_dir - - -# Configuration -MAX_RETRIES = 3 -PROMPTS_DIR = Path(__file__).parent / "prompts" - -# Ideation types -# Note: high_value_features removed - strategic features belong to Roadmap -# low_hanging_fruit renamed to code_improvements to cover all code-revealed opportunities -IDEATION_TYPES = [ - "code_improvements", - "ui_ux_improvements", - "documentation_gaps", - "security_hardening", - "performance_optimizations", - "code_quality", -] - -IDEATION_TYPE_LABELS = { - "code_improvements": "Code Improvements", - "ui_ux_improvements": "UI/UX Improvements", - "documentation_gaps": "Documentation Gaps", - "security_hardening": "Security Hardening", - "performance_optimizations": "Performance Optimizations", - "code_quality": "Code Quality & Refactoring", -} - -IDEATION_TYPE_PROMPTS = { - "code_improvements": "ideation_code_improvements.md", - "ui_ux_improvements": "ideation_ui_ux.md", - "documentation_gaps": "ideation_documentation.md", - "security_hardening": "ideation_security.md", - "performance_optimizations": "ideation_performance.md", - "code_quality": "ideation_code_quality.md", -} - - -@dataclass -class IdeationPhaseResult: - """Result of an ideation phase execution.""" - phase: str - ideation_type: Optional[str] - success: bool - output_files: list[str] - ideas_count: int - errors: list[str] - retries: int - - -@dataclass -class IdeationConfig: - """Configuration for ideation generation.""" - project_dir: Path - output_dir: Path - enabled_types: List[str] = field(default_factory=lambda: IDEATION_TYPES.copy()) - include_roadmap_context: bool = True - include_kanban_context: bool = True - max_ideas_per_type: int = 5 - model: str = "claude-opus-4-5-20251101" - refresh: bool = False - append: bool = False # If True, preserve existing ideas when merging - - -class IdeationOrchestrator: - """Orchestrates the ideation creation process.""" - - def __init__( - self, - project_dir: Path, - output_dir: Optional[Path] = None, - enabled_types: Optional[List[str]] = None, - include_roadmap_context: bool = True, - include_kanban_context: bool = True, - max_ideas_per_type: int = 5, - model: str = "claude-opus-4-5-20251101", - refresh: bool = False, - append: bool = False, - ): - self.project_dir = Path(project_dir) - self.model = model - self.refresh = refresh - self.append = append # Preserve existing ideas when merging - self.enabled_types = enabled_types or IDEATION_TYPES.copy() - self.include_roadmap_context = include_roadmap_context - self.include_kanban_context = include_kanban_context - self.max_ideas_per_type = max_ideas_per_type - - # Default output to project's .auto-claude directory (installed instance) - # Note: auto-claude/ is source code, .auto-claude/ is the installed instance - if output_dir: - self.output_dir = Path(output_dir) - else: - # Initialize .auto-claude directory and ensure it's in .gitignore - init_auto_claude_dir(self.project_dir) - self.output_dir = self.project_dir / ".auto-claude" / "ideation" - - self.output_dir.mkdir(parents=True, exist_ok=True) - - # Create screenshots directory for UI/UX analysis - (self.output_dir / "screenshots").mkdir(exist_ok=True) - - def _run_script(self, script: str, args: list[str]) -> tuple[bool, str]: - """Run a Python script and return (success, output).""" - script_path = Path(__file__).parent / script - - if not script_path.exists(): - return False, f"Script not found: {script_path}" - - cmd = [sys.executable, str(script_path)] + args - - try: - result = subprocess.run( - cmd, - cwd=self.project_dir, - capture_output=True, - text=True, - timeout=300, - ) - - if result.returncode == 0: - return True, result.stdout - else: - return False, result.stderr or result.stdout - - except subprocess.TimeoutExpired: - return False, "Script timed out" - except Exception as e: - return False, str(e) - - async def _run_agent( - self, - prompt_file: str, - additional_context: str = "", - ) -> tuple[bool, str]: - """Run an agent with the given prompt.""" - prompt_path = PROMPTS_DIR / prompt_file - - if not prompt_path.exists(): - return False, f"Prompt not found: {prompt_path}" - - # Load prompt - prompt = prompt_path.read_text() - - # Add context - prompt += f"\n\n---\n\n**Output Directory**: {self.output_dir}\n" - prompt += f"**Project Directory**: {self.project_dir}\n" - prompt += f"**Max Ideas**: {self.max_ideas_per_type}\n" - - if additional_context: - prompt += f"\n{additional_context}\n" - - # Create client - client = create_client(self.project_dir, self.output_dir, self.model) - - try: - async with client: - await client.query(prompt) - - response_text = "" - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - if block_type == "TextBlock" and hasattr(block, "text"): - response_text += block.text - print(block.text, end="", flush=True) - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - print(f"\n[Tool: {block.name}]", flush=True) - - print() - return True, response_text - - except Exception as e: - return False, str(e) - - async def _get_graph_hints(self, ideation_type: str) -> list[dict]: - """Get graph hints for a specific ideation type from Graphiti. - - This runs in parallel with ideation agents to provide historical context. - """ - if not is_graphiti_enabled(): - return [] - - # Create a query based on ideation type - query_map = { - "code_improvements": "code patterns, quick wins, and improvement opportunities that worked well", - "ui_ux_improvements": "UI and UX improvements and user interface patterns", - "documentation_gaps": "documentation improvements and common user confusion points", - "security_hardening": "security vulnerabilities and hardening measures", - "performance_optimizations": "performance bottlenecks and optimization techniques", - "code_quality": "code quality improvements and refactoring patterns", - } - - query = query_map.get(ideation_type, f"ideas for {ideation_type}") - - try: - hints = await get_graph_hints( - query=query, - project_id=str(self.project_dir), - max_results=5, - ) - debug_success("ideation_runner", f"Got {len(hints)} graph hints for {ideation_type}") - return hints - except Exception as e: - debug_warning("ideation_runner", f"Graph hints failed for {ideation_type}: {e}") - return [] - - def _gather_context(self) -> dict: - """Gather context from project for ideation.""" - context = { - "existing_features": [], - "tech_stack": [], - "target_audience": None, - "planned_features": [], - } - - # Get project index (from .auto-claude - the installed instance) - project_index_path = self.project_dir / ".auto-claude" / "project_index.json" - if project_index_path.exists(): - try: - with open(project_index_path) as f: - index = json.load(f) - # Extract tech stack from services - for service_name, service_info in index.get("services", {}).items(): - if service_info.get("language"): - context["tech_stack"].append(service_info["language"]) - if service_info.get("framework"): - context["tech_stack"].append(service_info["framework"]) - context["tech_stack"] = list(set(context["tech_stack"])) - except (json.JSONDecodeError, KeyError): - pass - - # Get roadmap context if enabled - if self.include_roadmap_context: - roadmap_path = self.project_dir / ".auto-claude" / "roadmap" / "roadmap.json" - if roadmap_path.exists(): - try: - with open(roadmap_path) as f: - roadmap = json.load(f) - # Extract planned features - for feature in roadmap.get("features", []): - context["planned_features"].append(feature.get("title", "")) - # Get target audience - audience = roadmap.get("target_audience", {}) - context["target_audience"] = audience.get("primary") - except (json.JSONDecodeError, KeyError): - pass - - # Also check discovery for audience - discovery_path = self.project_dir / ".auto-claude" / "roadmap" / "roadmap_discovery.json" - if discovery_path.exists() and not context["target_audience"]: - try: - with open(discovery_path) as f: - discovery = json.load(f) - audience = discovery.get("target_audience", {}) - context["target_audience"] = audience.get("primary_persona") - - # Also get existing features - current_state = discovery.get("current_state", {}) - context["existing_features"] = current_state.get("existing_features", []) - except (json.JSONDecodeError, KeyError): - pass - - # Get kanban context if enabled - if self.include_kanban_context: - specs_dir = self.project_dir / ".auto-claude" / "specs" - if specs_dir.exists(): - for spec_dir in specs_dir.iterdir(): - if spec_dir.is_dir(): - spec_file = spec_dir / "spec.md" - if spec_file.exists(): - # Extract title from spec - content = spec_file.read_text() - lines = content.split("\n") - for line in lines: - if line.startswith("# "): - context["planned_features"].append(line[2:].strip()) - break - - # Remove duplicates from planned features - context["planned_features"] = list(set(context["planned_features"])) - - return context - - async def phase_graph_hints(self) -> IdeationPhaseResult: - """Retrieve graph hints for all enabled ideation types in parallel. - - This phase runs concurrently with context gathering to fetch - historical insights from Graphiti without slowing down the pipeline. - """ - hints_file = self.output_dir / "graph_hints.json" - - if hints_file.exists(): - print_status("graph_hints.json already exists", "success") - return IdeationPhaseResult( - phase="graph_hints", - ideation_type=None, - success=True, - output_files=[str(hints_file)], - ideas_count=0, - errors=[], - retries=0, - ) - - if not is_graphiti_enabled(): - print_status("Graphiti not enabled, skipping graph hints", "info") - with open(hints_file, "w") as f: - json.dump({ - "enabled": False, - "reason": "Graphiti not configured", - "hints_by_type": {}, - "created_at": datetime.now().isoformat(), - }, f, indent=2) - return IdeationPhaseResult( - phase="graph_hints", - ideation_type=None, - success=True, - output_files=[str(hints_file)], - ideas_count=0, - errors=[], - retries=0, - ) - - print_status("Querying Graphiti for ideation hints...", "progress") - - # Fetch hints for all enabled types in parallel - hint_tasks = [ - self._get_graph_hints(ideation_type) - for ideation_type in self.enabled_types - ] - - results = await asyncio.gather(*hint_tasks, return_exceptions=True) - - # Collect hints by type - hints_by_type = {} - total_hints = 0 - errors = [] - - for i, result in enumerate(results): - ideation_type = self.enabled_types[i] - if isinstance(result, Exception): - errors.append(f"{ideation_type}: {str(result)}") - hints_by_type[ideation_type] = [] - else: - hints_by_type[ideation_type] = result - total_hints += len(result) - - # Save hints - with open(hints_file, "w") as f: - json.dump({ - "enabled": True, - "hints_by_type": hints_by_type, - "total_hints": total_hints, - "created_at": datetime.now().isoformat(), - }, f, indent=2) - - if total_hints > 0: - print_status(f"Retrieved {total_hints} graph hints across {len(self.enabled_types)} types", "success") - else: - print_status("No relevant graph hints found", "info") - - return IdeationPhaseResult( - phase="graph_hints", - ideation_type=None, - success=True, - output_files=[str(hints_file)], - ideas_count=0, - errors=errors, - retries=0, - ) - - async def phase_context(self) -> IdeationPhaseResult: - """Create ideation context file.""" - - context_file = self.output_dir / "ideation_context.json" - - print_status("Gathering project context...", "progress") - - context = self._gather_context() - - # Check for graph hints and include them - hints_file = self.output_dir / "graph_hints.json" - graph_hints = {} - if hints_file.exists(): - try: - with open(hints_file) as f: - hints_data = json.load(f) - graph_hints = hints_data.get("hints_by_type", {}) - except (json.JSONDecodeError, IOError): - pass - - # Write context file - context_data = { - "existing_features": context["existing_features"], - "tech_stack": context["tech_stack"], - "target_audience": context["target_audience"], - "planned_features": context["planned_features"], - "graph_hints": graph_hints, # Include graph hints in context - "config": { - "enabled_types": self.enabled_types, - "include_roadmap_context": self.include_roadmap_context, - "include_kanban_context": self.include_kanban_context, - "max_ideas_per_type": self.max_ideas_per_type, - }, - "created_at": datetime.now().isoformat(), - } - - with open(context_file, "w") as f: - json.dump(context_data, f, indent=2) - - print_status(f"Created ideation_context.json", "success") - print_key_value("Tech Stack", ", ".join(context["tech_stack"][:5]) or "Unknown") - print_key_value("Planned Features", str(len(context["planned_features"]))) - print_key_value("Target Audience", context["target_audience"] or "Not specified") - if graph_hints: - total_hints = sum(len(h) for h in graph_hints.values()) - print_key_value("Graph Hints", str(total_hints)) - - return IdeationPhaseResult( - phase="context", - ideation_type=None, - success=True, - output_files=[str(context_file)], - ideas_count=0, - errors=[], - retries=0, - ) - - async def phase_project_index(self) -> IdeationPhaseResult: - """Ensure project index exists.""" - - project_index = self.output_dir / "project_index.json" - auto_build_index = self.project_dir / ".auto-claude" / "project_index.json" - - # Check if we can copy existing index - if auto_build_index.exists(): - import shutil - shutil.copy(auto_build_index, project_index) - print_status("Copied existing project_index.json", "success") - return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0) - - if project_index.exists() and not self.refresh: - print_status("project_index.json already exists", "success") - return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0) - - # Run analyzer - print_status("Running project analyzer...", "progress") - success, output = self._run_script( - "analyzer.py", - ["--output", str(project_index)] - ) - - if success and project_index.exists(): - print_status("Created project_index.json", "success") - return IdeationPhaseResult("project_index", None, True, [str(project_index)], 0, [], 0) - - return IdeationPhaseResult("project_index", None, False, [], 0, [output], 1) - - async def phase_ideation_type(self, ideation_type: str) -> IdeationPhaseResult: - """Run ideation for a specific type.""" - - prompt_file = IDEATION_TYPE_PROMPTS.get(ideation_type) - if not prompt_file: - return IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=False, - output_files=[], - ideas_count=0, - errors=[f"Unknown ideation type: {ideation_type}"], - retries=0, - ) - - output_file = self.output_dir / f"{ideation_type}_ideas.json" - - if output_file.exists() and not self.refresh: - # Load and validate existing ideas - only skip if we have valid ideas - try: - with open(output_file) as f: - data = json.load(f) - count = len(data.get(ideation_type, [])) - - if count >= 1: - # Valid ideas exist, skip regeneration - print_status(f"{ideation_type}_ideas.json already exists ({count} ideas)", "success") - return IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=True, - output_files=[str(output_file)], - ideas_count=count, - errors=[], - retries=0, - ) - else: - # File exists but has no valid ideas - needs regeneration - print_status(f"{ideation_type}_ideas.json exists but has 0 ideas, regenerating...", "warning") - except (json.JSONDecodeError, KeyError): - # Invalid file - will regenerate - print_status(f"{ideation_type}_ideas.json exists but is invalid, regenerating...", "warning") - - errors = [] - - # First attempt: run the full ideation agent - print_status(f"Running {IDEATION_TYPE_LABELS[ideation_type]} agent...", "progress") - - context = f""" -**Ideation Context**: {self.output_dir / "ideation_context.json"} -**Project Index**: {self.output_dir / "project_index.json"} -**Output File**: {output_file} -**Max Ideas**: {self.max_ideas_per_type} - -Generate up to {self.max_ideas_per_type} {IDEATION_TYPE_LABELS[ideation_type]} ideas. -Avoid duplicating features that are already planned (see ideation_context.json). -Output your ideas to {output_file.name}. -""" - success, output = await self._run_agent( - prompt_file, - additional_context=context, - ) - - # Validate the output - validation_result = self._validate_ideation_output(output_file, ideation_type) - - if validation_result["success"]: - print_status(f"Created {output_file.name} ({validation_result['count']} ideas)", "success") - return IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=True, - output_files=[str(output_file)], - ideas_count=validation_result["count"], - errors=[], - retries=0, - ) - - errors.append(validation_result["error"]) - - # Recovery attempts: show the current state and ask AI to fix it - for recovery_attempt in range(MAX_RETRIES - 1): - print_status(f"Running recovery agent (attempt {recovery_attempt + 1})...", "warning") - - recovery_success = await self._run_recovery_agent( - output_file, - ideation_type, - validation_result["error"], - validation_result.get("current_content", "") - ) - - if recovery_success: - # Re-validate after recovery - validation_result = self._validate_ideation_output(output_file, ideation_type) - - if validation_result["success"]: - print_status(f"Recovery successful: {output_file.name} ({validation_result['count']} ideas)", "success") - return IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=True, - output_files=[str(output_file)], - ideas_count=validation_result["count"], - errors=[], - retries=recovery_attempt + 1, - ) - else: - errors.append(f"Recovery {recovery_attempt + 1}: {validation_result['error']}") - else: - errors.append(f"Recovery {recovery_attempt + 1}: Agent failed to run") - - return IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=False, - output_files=[], - ideas_count=0, - errors=errors, - retries=MAX_RETRIES, - ) - - def _validate_ideation_output(self, output_file: Path, ideation_type: str) -> dict: - """Validate ideation output file and return validation result.""" - debug_detailed("ideation_runner", f"Validating output for {ideation_type}", - output_file=str(output_file)) - - if not output_file.exists(): - debug_warning("ideation_runner", "Output file does not exist", - output_file=str(output_file)) - return { - "success": False, - "error": "Output file does not exist", - "current_content": "", - "count": 0, - } - - try: - content = output_file.read_text() - data = json.loads(content) - debug_verbose("ideation_runner", "Parsed JSON successfully", - keys=list(data.keys())) - - # Check for correct key - ideas = data.get(ideation_type, []) - - # Also check for common incorrect key "ideas" - if not ideas and "ideas" in data: - debug_warning("ideation_runner", "Wrong JSON key detected", - expected=ideation_type, found="ideas") - return { - "success": False, - "error": f"Wrong JSON key: found 'ideas' but expected '{ideation_type}'", - "current_content": content, - "count": 0, - } - - if len(ideas) >= 1: - debug_success("ideation_runner", f"Validation passed for {ideation_type}", - ideas_count=len(ideas)) - return { - "success": True, - "error": None, - "current_content": content, - "count": len(ideas), - } - else: - debug_warning("ideation_runner", f"No ideas found for {ideation_type}") - return { - "success": False, - "error": f"No {ideation_type} ideas found in output", - "current_content": content, - "count": 0, - } - - except json.JSONDecodeError as e: - debug_error("ideation_runner", "JSON parse error", error=str(e)) - return { - "success": False, - "error": f"Invalid JSON: {e}", - "current_content": output_file.read_text() if output_file.exists() else "", - "count": 0, - } - - async def _run_recovery_agent( - self, - output_file: Path, - ideation_type: str, - error: str, - current_content: str, - ) -> bool: - """Run a recovery agent to fix validation errors in the output file.""" - - # Truncate content if too long - max_content_length = 8000 - if len(current_content) > max_content_length: - current_content = current_content[:max_content_length] + "\n... (truncated)" - - recovery_prompt = f"""# Ideation Output Recovery - -The ideation output file failed validation. Your task is to fix it. - -## Error -{error} - -## Expected Format -The output file must be valid JSON with the following structure: - -```json -{{ - "{ideation_type}": [ - {{ - "id": "...", - "type": "{ideation_type}", - "title": "...", - "description": "...", - ... other fields ... - }} - ] -}} -``` - -**CRITICAL**: The top-level key MUST be `"{ideation_type}"` (not "ideas" or anything else). - -## Current File Content -File: {output_file} - -```json -{current_content} -``` - -## Your Task -1. Read the current file content above -2. Identify what's wrong based on the error message -3. Fix the JSON structure to match the expected format -4. Write the corrected content to {output_file} - -Common fixes: -- If the key is "ideas", rename it to "{ideation_type}" -- If the JSON is invalid, fix the syntax errors -- If there are no ideas, ensure the array has at least one idea object - -Write the fixed JSON to the file now. -""" - - client = create_client(self.project_dir, self.output_dir, self.model) - - try: - async with client: - await client.query(recovery_prompt) - - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - if block_type == "TextBlock" and hasattr(block, "text"): - print(block.text, end="", flush=True) - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - print(f"\n[Recovery Tool: {block.name}]", flush=True) - - print() - return True - - except Exception as e: - print_status(f"Recovery agent error: {e}", "error") - return False - - async def phase_merge(self) -> IdeationPhaseResult: - """Merge all ideation outputs into a single ideation.json.""" - - ideation_file = self.output_dir / "ideation.json" - - # Load existing ideas if in append mode - existing_ideas = [] - existing_session = None - if self.append and ideation_file.exists(): - try: - with open(ideation_file) as f: - existing_session = json.load(f) - existing_ideas = existing_session.get("ideas", []) - print_status(f"Preserving {len(existing_ideas)} existing ideas", "info") - except json.JSONDecodeError: - pass - - # Collect new ideas from the enabled types - new_ideas = [] - output_files = [] - - for ideation_type in self.enabled_types: - type_file = self.output_dir / f"{ideation_type}_ideas.json" - if type_file.exists(): - try: - with open(type_file) as f: - data = json.load(f) - ideas = data.get(ideation_type, []) - new_ideas.extend(ideas) - output_files.append(str(type_file)) - except (json.JSONDecodeError, KeyError): - pass - - # In append mode, filter out ideas from types we're regenerating - # (to avoid duplicates) and keep ideas from other types - if self.append and existing_ideas: - # Keep existing ideas that are NOT from the types we just generated - preserved_ideas = [ - idea for idea in existing_ideas - if idea.get("type") not in self.enabled_types - ] - all_ideas = preserved_ideas + new_ideas - print_status(f"Merged: {len(preserved_ideas)} preserved + {len(new_ideas)} new = {len(all_ideas)} total", "info") - else: - all_ideas = new_ideas - - # Load context for metadata - context_file = self.output_dir / "ideation_context.json" - context_data = {} - if context_file.exists(): - try: - with open(context_file) as f: - context_data = json.load(f) - except json.JSONDecodeError: - pass - - # Create merged ideation session - # Preserve session ID and generated_at if appending - session_id = existing_session.get("id") if existing_session else f"ideation-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - generated_at = existing_session.get("generated_at") if existing_session else datetime.now().isoformat() - - ideation_session = { - "id": session_id, - "project_id": str(self.project_dir), - "config": context_data.get("config", {}), - "ideas": all_ideas, - "project_context": { - "existing_features": context_data.get("existing_features", []), - "tech_stack": context_data.get("tech_stack", []), - "target_audience": context_data.get("target_audience"), - "planned_features": context_data.get("planned_features", []), - }, - "summary": { - "total_ideas": len(all_ideas), - "by_type": {}, - "by_status": {}, - }, - "generated_at": generated_at, - "updated_at": datetime.now().isoformat(), - } - - # Count by type and status - for idea in all_ideas: - idea_type = idea.get("type", "unknown") - idea_status = idea.get("status", "draft") - ideation_session["summary"]["by_type"][idea_type] = \ - ideation_session["summary"]["by_type"].get(idea_type, 0) + 1 - ideation_session["summary"]["by_status"][idea_status] = \ - ideation_session["summary"]["by_status"].get(idea_status, 0) + 1 - - with open(ideation_file, "w") as f: - json.dump(ideation_session, f, indent=2) - - action = "Updated" if self.append else "Created" - print_status(f"{action} ideation.json ({len(all_ideas)} total ideas)", "success") - - return IdeationPhaseResult( - phase="merge", - ideation_type=None, - success=True, - output_files=[str(ideation_file)], - ideas_count=len(all_ideas), - errors=[], - retries=0, - ) - - async def _run_ideation_type_with_streaming(self, ideation_type: str) -> IdeationPhaseResult: - """Run a single ideation type and stream results when complete.""" - result = await self.phase_ideation_type(ideation_type) - - if result.success: - # Signal that this type is complete - UI can now show these ideas - print(f"IDEATION_TYPE_COMPLETE:{ideation_type}:{result.ideas_count}") - sys.stdout.flush() - else: - print(f"IDEATION_TYPE_FAILED:{ideation_type}") - sys.stdout.flush() - - return result - - async def run(self) -> bool: - """Run the complete ideation generation process.""" - - debug_section("ideation_runner", "Starting Ideation Generation") - debug("ideation_runner", "Configuration", - project_dir=str(self.project_dir), - output_dir=str(self.output_dir), - model=self.model, - enabled_types=self.enabled_types, - refresh=self.refresh, - append=self.append) - - print(box( - f"Project: {self.project_dir}\n" - f"Output: {self.output_dir}\n" - f"Model: {self.model}\n" - f"Types: {', '.join(self.enabled_types)}", - title="IDEATION GENERATOR", - style="heavy" - )) - - results = [] - - # Phase 1: Project Index - debug("ideation_runner", "Starting Phase 1: Project Analysis") - print_section("PHASE 1: PROJECT ANALYSIS", Icons.FOLDER) - result = await self.phase_project_index() - results.append(result) - if not result.success: - print_status("Project analysis failed", "error") - return False - - # Phase 2: Context & Graph Hints (in parallel) - print_section("PHASE 2: CONTEXT & GRAPH HINTS (PARALLEL)", Icons.SEARCH) - - # Run context gathering and graph hints in parallel - context_task = self.phase_context() - hints_task = self.phase_graph_hints() - context_result, hints_result = await asyncio.gather(context_task, hints_task) - - results.append(hints_result) - results.append(context_result) - - if not context_result.success: - print_status("Context gathering failed", "error") - return False - # Note: hints_result.success is always True (graceful degradation) - - # Phase 3: Run all ideation types IN PARALLEL - debug("ideation_runner", "Starting Phase 3: Generating Ideas", - types=self.enabled_types, parallel=True) - print_section("PHASE 3: GENERATING IDEAS (PARALLEL)", Icons.SUBTASK) - print_status(f"Starting {len(self.enabled_types)} ideation agents in parallel...", "progress") - - # Create tasks for all enabled types - ideation_tasks = [ - self._run_ideation_type_with_streaming(ideation_type) - for ideation_type in self.enabled_types - ] - - # Run all ideation types concurrently - ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True) - - # Process results - for i, result in enumerate(ideation_results): - ideation_type = self.enabled_types[i] - if isinstance(result, Exception): - print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed with exception: {result}", "error") - results.append(IdeationPhaseResult( - phase="ideation", - ideation_type=ideation_type, - success=False, - output_files=[], - ideas_count=0, - errors=[str(result)], - retries=0, - )) - else: - results.append(result) - if result.success: - print_status(f"{IDEATION_TYPE_LABELS[ideation_type]}: {result.ideas_count} ideas", "success") - else: - print_status(f"{IDEATION_TYPE_LABELS[ideation_type]} ideation failed", "warning") - for err in result.errors: - print(f" {muted('Error:')} {err}") - - # Final Phase: Merge - print_section("PHASE 4: MERGE & FINALIZE", Icons.SUCCESS) - result = await self.phase_merge() - results.append(result) - - # Summary - ideation_file = self.output_dir / "ideation.json" - if ideation_file.exists(): - with open(ideation_file) as f: - ideation = json.load(f) - - ideas = ideation.get("ideas", []) - summary = ideation.get("summary", {}) - by_type = summary.get("by_type", {}) - - print(box( - f"Total Ideas: {len(ideas)}\n\n" - f"By Type:\n" + - "\n".join(f" {icon(Icons.ARROW_RIGHT)} {IDEATION_TYPE_LABELS.get(t, t)}: {c}" - for t, c in by_type.items()) + - f"\n\nIdeation saved to: {ideation_file}", - title=f"{icon(Icons.SUCCESS)} IDEATION COMPLETE", - style="heavy" - )) - - return True - - -def main(): - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser( - description="AI-powered ideation generation", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--project", - type=Path, - default=Path.cwd(), - help="Project directory (default: current directory)", - ) - parser.add_argument( - "--output", - type=Path, - help="Output directory for ideation files (default: project/auto-claude/ideation)", - ) - parser.add_argument( - "--types", - type=str, - help=f"Comma-separated ideation types to run (options: {','.join(IDEATION_TYPES)})", - ) - parser.add_argument( - "--no-roadmap", - action="store_true", - help="Don't include roadmap context", - ) - parser.add_argument( - "--no-kanban", - action="store_true", - help="Don't include kanban context", - ) - parser.add_argument( - "--max-ideas", - type=int, - default=5, - help="Maximum ideas per type (default: 5)", - ) - parser.add_argument( - "--model", - type=str, - default="claude-opus-4-5-20251101", - help="Model to use (default: claude-opus-4-5-20251101)", - ) - parser.add_argument( - "--refresh", - action="store_true", - help="Force regeneration even if ideation exists", - ) - parser.add_argument( - "--append", - action="store_true", - help="Append new ideas to existing session instead of replacing", - ) - - args = parser.parse_args() - - # Validate project directory - project_dir = args.project.resolve() - if not project_dir.exists(): - print(f"Error: Project directory does not exist: {project_dir}") - sys.exit(1) - - # Parse types - enabled_types = None - if args.types: - enabled_types = [t.strip() for t in args.types.split(",")] - invalid_types = [t for t in enabled_types if t not in IDEATION_TYPES] - if invalid_types: - print(f"Error: Invalid ideation types: {invalid_types}") - print(f"Valid types: {IDEATION_TYPES}") - sys.exit(1) - - orchestrator = IdeationOrchestrator( - project_dir=project_dir, - output_dir=args.output, - enabled_types=enabled_types, - include_roadmap_context=not args.no_roadmap, - include_kanban_context=not args.no_kanban, - max_ideas_per_type=args.max_ideas, - model=args.model, - refresh=args.refresh, - append=args.append, - ) - - try: - success = asyncio.run(orchestrator.run()) - sys.exit(0 if success else 1) - except KeyboardInterrupt: - print("\n\nIdeation generation interrupted.") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/auto-claude/implementation_plan.py b/auto-claude/implementation_plan.py index 733e78a4..64641781 100644 --- a/auto-claude/implementation_plan.py +++ b/auto-claude/implementation_plan.py @@ -1,168 +1,3 @@ -#!/usr/bin/env python3 -""" -Implementation Plan Manager -============================ - -DEPRECATED: This module is now a compatibility shim. The implementation has been -refactored into the implementation_plan/ package for better modularity. - -Please import from the package directly: - from implementation_plan import ImplementationPlan, Subtask, Phase, etc. - -This file re-exports all public APIs for backwards compatibility. - -Core data structures and utilities for subtask-based implementation plans. -Replaces the test-centric feature_list.json with implementation_plan.json. - -The key insight: Tests verify outcomes, but SUBTASKS define implementation steps. -For complex multi-service features, implementation order matters. - -Workflow Types: -- feature: Standard multi-service feature (phases = services) -- refactor: Migration/refactor work (phases = stages: add, migrate, remove) -- investigation: Bug hunting (phases = investigate, hypothesize, fix) -- migration: Data migration (phases = prepare, test, execute, cleanup) -- simple: Single-service enhancement (minimal overhead) -""" - -# Re-export everything from the implementation_plan package -from implementation_plan import ( - Chunk, - ChunkStatus, - ImplementationPlan, - Phase, - PhaseType, - Subtask, - SubtaskStatus, - Verification, - VerificationType, - WorkflowType, - create_feature_plan, - create_investigation_plan, - create_refactor_plan, -) - -__all__ = [ - # Enums - "WorkflowType", - "PhaseType", - "SubtaskStatus", - "VerificationType", - # Models - "Verification", - "Subtask", - "Phase", - "ImplementationPlan", - # Factories - "create_feature_plan", - "create_investigation_plan", - "create_refactor_plan", - # Backwards compatibility - "Chunk", - "ChunkStatus", -] - - -# CLI for testing -if __name__ == "__main__": - import json - import sys - from pathlib import Path - - if len(sys.argv) < 2: - print("Usage: python implementation_plan.py ") - print(" python implementation_plan.py --demo") - sys.exit(1) - - if sys.argv[1] == "--demo": - # Create a demo plan - plan = create_feature_plan( - feature="Avatar Upload with Processing", - services=["backend", "worker", "frontend"], - phases_config=[ - { - "name": "Backend Foundation", - "parallel_safe": True, - "subtasks": [ - { - "id": "avatar-model", - "service": "backend", - "description": "Add avatar fields to User model", - "files_to_modify": ["app/models/user.py"], - "files_to_create": ["migrations/add_avatar.py"], - "verification": { - "type": "command", - "run": "flask db upgrade", - }, - }, - { - "id": "avatar-endpoint", - "service": "backend", - "description": "POST /api/users/avatar endpoint", - "files_to_modify": ["app/routes/users.py"], - "patterns_from": ["app/routes/profile.py"], - "verification": { - "type": "api", - "method": "POST", - "url": "/api/users/avatar", - }, - }, - ], - }, - { - "name": "Worker Pipeline", - "depends_on": [1], - "subtasks": [ - { - "id": "image-task", - "service": "worker", - "description": "Celery task for image processing", - "files_to_create": ["app/tasks/images.py"], - "patterns_from": ["app/tasks/reports.py"], - }, - ], - }, - { - "name": "Frontend", - "depends_on": [1], - "subtasks": [ - { - "id": "avatar-component", - "service": "frontend", - "description": "AvatarUpload React component", - "files_to_create": ["src/components/AvatarUpload.tsx"], - "patterns_from": ["src/components/FileUpload.tsx"], - }, - ], - }, - { - "name": "Integration", - "depends_on": [2, 3], - "type": "integration", - "subtasks": [ - { - "id": "e2e-wiring", - "all_services": True, - "description": "Connect frontend → backend → worker", - "verification": { - "type": "browser", - "scenario": "Upload → Process → Display", - }, - }, - ], - }, - ], - ) - plan.final_acceptance = [ - "User can upload avatar from profile page", - "Avatar is automatically resized", - "Large/invalid files show error", - ] - - print(json.dumps(plan.to_dict(), indent=2)) - print("\n---\n") - print(plan.get_status_summary()) - else: - # Load and display existing plan - plan = ImplementationPlan.load(Path(sys.argv[1])) - print(plan.get_status_summary()) +"""Backward compatibility shim - import from implementation_plan package instead.""" +from implementation_plan.main import * +from implementation_plan import * diff --git a/auto-claude/implementation_plan/main.py b/auto-claude/implementation_plan/main.py new file mode 100644 index 00000000..733e78a4 --- /dev/null +++ b/auto-claude/implementation_plan/main.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Implementation Plan Manager +============================ + +DEPRECATED: This module is now a compatibility shim. The implementation has been +refactored into the implementation_plan/ package for better modularity. + +Please import from the package directly: + from implementation_plan import ImplementationPlan, Subtask, Phase, etc. + +This file re-exports all public APIs for backwards compatibility. + +Core data structures and utilities for subtask-based implementation plans. +Replaces the test-centric feature_list.json with implementation_plan.json. + +The key insight: Tests verify outcomes, but SUBTASKS define implementation steps. +For complex multi-service features, implementation order matters. + +Workflow Types: +- feature: Standard multi-service feature (phases = services) +- refactor: Migration/refactor work (phases = stages: add, migrate, remove) +- investigation: Bug hunting (phases = investigate, hypothesize, fix) +- migration: Data migration (phases = prepare, test, execute, cleanup) +- simple: Single-service enhancement (minimal overhead) +""" + +# Re-export everything from the implementation_plan package +from implementation_plan import ( + Chunk, + ChunkStatus, + ImplementationPlan, + Phase, + PhaseType, + Subtask, + SubtaskStatus, + Verification, + VerificationType, + WorkflowType, + create_feature_plan, + create_investigation_plan, + create_refactor_plan, +) + +__all__ = [ + # Enums + "WorkflowType", + "PhaseType", + "SubtaskStatus", + "VerificationType", + # Models + "Verification", + "Subtask", + "Phase", + "ImplementationPlan", + # Factories + "create_feature_plan", + "create_investigation_plan", + "create_refactor_plan", + # Backwards compatibility + "Chunk", + "ChunkStatus", +] + + +# CLI for testing +if __name__ == "__main__": + import json + import sys + from pathlib import Path + + if len(sys.argv) < 2: + print("Usage: python implementation_plan.py ") + print(" python implementation_plan.py --demo") + sys.exit(1) + + if sys.argv[1] == "--demo": + # Create a demo plan + plan = create_feature_plan( + feature="Avatar Upload with Processing", + services=["backend", "worker", "frontend"], + phases_config=[ + { + "name": "Backend Foundation", + "parallel_safe": True, + "subtasks": [ + { + "id": "avatar-model", + "service": "backend", + "description": "Add avatar fields to User model", + "files_to_modify": ["app/models/user.py"], + "files_to_create": ["migrations/add_avatar.py"], + "verification": { + "type": "command", + "run": "flask db upgrade", + }, + }, + { + "id": "avatar-endpoint", + "service": "backend", + "description": "POST /api/users/avatar endpoint", + "files_to_modify": ["app/routes/users.py"], + "patterns_from": ["app/routes/profile.py"], + "verification": { + "type": "api", + "method": "POST", + "url": "/api/users/avatar", + }, + }, + ], + }, + { + "name": "Worker Pipeline", + "depends_on": [1], + "subtasks": [ + { + "id": "image-task", + "service": "worker", + "description": "Celery task for image processing", + "files_to_create": ["app/tasks/images.py"], + "patterns_from": ["app/tasks/reports.py"], + }, + ], + }, + { + "name": "Frontend", + "depends_on": [1], + "subtasks": [ + { + "id": "avatar-component", + "service": "frontend", + "description": "AvatarUpload React component", + "files_to_create": ["src/components/AvatarUpload.tsx"], + "patterns_from": ["src/components/FileUpload.tsx"], + }, + ], + }, + { + "name": "Integration", + "depends_on": [2, 3], + "type": "integration", + "subtasks": [ + { + "id": "e2e-wiring", + "all_services": True, + "description": "Connect frontend → backend → worker", + "verification": { + "type": "browser", + "scenario": "Upload → Process → Display", + }, + }, + ], + }, + ], + ) + plan.final_acceptance = [ + "User can upload avatar from profile page", + "Avatar is automatically resized", + "Large/invalid files show error", + ] + + print(json.dumps(plan.to_dict(), indent=2)) + print("\n---\n") + print(plan.get_status_summary()) + else: + # Load and display existing plan + plan = ImplementationPlan.load(Path(sys.argv[1])) + print(plan.get_status_summary()) diff --git a/auto-claude/insight_extractor.py b/auto-claude/insight_extractor.py index 281fdb87..b7a650d2 100644 --- a/auto-claude/insight_extractor.py +++ b/auto-claude/insight_extractor.py @@ -1,584 +1,41 @@ """ -Insight Extractor -================= +Insight Extractor Re-export +=========================== -Automatically extracts structured insights from completed coding sessions. -Runs after each session to capture rich, actionable knowledge for Graphiti memory. - -Uses the Claude Agent SDK (same as the rest of the system) for extraction. -Falls back to generic insights if extraction fails (never blocks the build). +Re-exports the insight_extractor module from analysis/ for backwards compatibility. +Uses importlib to avoid triggering analysis/__init__.py imports. """ -import json -import logging -import os -import subprocess +import importlib.util +import sys from pathlib import Path -from typing import Any -logger = logging.getLogger(__name__) - -# Check for Claude SDK availability -try: - from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient - SDK_AVAILABLE = True -except ImportError: - SDK_AVAILABLE = False - ClaudeAgentOptions = None - ClaudeSDKClient = None - -# Default model for insight extraction (fast and cheap) -DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest" - -# Maximum diff size to send to the LLM (avoid context limits) -MAX_DIFF_CHARS = 15000 - -# Maximum attempt history entries to include -MAX_ATTEMPTS_TO_INCLUDE = 3 - - -def is_extraction_enabled() -> bool: - """Check if insight extraction is enabled.""" - # Extraction requires Claude SDK and OAuth token - if not SDK_AVAILABLE: - return False - if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): - return False - enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower() - return enabled_str in ("true", "1", "yes") - - -def get_extraction_model() -> str: - """Get the model to use for insight extraction.""" - return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL) - - -# ============================================================================= -# Git Helpers -# ============================================================================= - - -def get_session_diff( - project_dir: Path, - commit_before: str | None, - commit_after: str | None, -) -> str: - """ - Get the git diff between two commits. - - Args: - project_dir: Project root directory - commit_before: Commit hash before session (or None) - commit_after: Commit hash after session (or None) - - Returns: - Diff text (truncated if too large) - """ - if not commit_before or not commit_after: - return "(No commits to diff)" - - if commit_before == commit_after: - return "(No changes - same commit)" - - try: - result = subprocess.run( - ["git", "diff", commit_before, commit_after], - cwd=project_dir, - capture_output=True, - text=True, - timeout=30, - ) - diff = result.stdout - - if len(diff) > MAX_DIFF_CHARS: - # Truncate and add note - diff = ( - diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)" - ) - - return diff if diff else "(Empty diff)" - - except subprocess.TimeoutExpired: - logger.warning("Git diff timed out") - return "(Git diff timed out)" - except Exception as e: - logger.warning(f"Failed to get git diff: {e}") - return f"(Failed to get diff: {e})" - - -def get_changed_files( - project_dir: Path, - commit_before: str | None, - commit_after: str | None, -) -> list[str]: - """ - Get list of files changed between two commits. - - Args: - project_dir: Project root directory - commit_before: Commit hash before session - commit_after: Commit hash after session - - Returns: - List of changed file paths - """ - if not commit_before or not commit_after or commit_before == commit_after: - return [] - - try: - result = subprocess.run( - ["git", "diff", "--name-only", commit_before, commit_after], - cwd=project_dir, - capture_output=True, - text=True, - timeout=10, - ) - files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()] - return files - - except Exception as e: - logger.warning(f"Failed to get changed files: {e}") - return [] - - -def get_commit_messages( - project_dir: Path, - commit_before: str | None, - commit_after: str | None, -) -> str: - """Get commit messages between two commits.""" - if not commit_before or not commit_after or commit_before == commit_after: - return "(No commits)" - - try: - result = subprocess.run( - ["git", "log", "--oneline", f"{commit_before}..{commit_after}"], - cwd=project_dir, - capture_output=True, - text=True, - timeout=10, - ) - return result.stdout.strip() if result.stdout.strip() else "(No commits)" - - except Exception as e: - logger.warning(f"Failed to get commit messages: {e}") - return f"(Failed: {e})" - - -# ============================================================================= -# Input Gathering -# ============================================================================= - - -def gather_extraction_inputs( - spec_dir: Path, - project_dir: Path, - subtask_id: str, - session_num: int, - commit_before: str | None, - commit_after: str | None, - success: bool, - recovery_manager: Any, -) -> dict: - """ - Gather all inputs needed for insight extraction. - - Args: - spec_dir: Spec directory - project_dir: Project root - subtask_id: The subtask that was worked on - session_num: Session number - commit_before: Commit before session - commit_after: Commit after session - success: Whether session succeeded - recovery_manager: Recovery manager with attempt history - - Returns: - Dict with all inputs for the extractor - """ - # Get subtask description from implementation plan - subtask_description = _get_subtask_description(spec_dir, subtask_id) - - # Get git diff - diff = get_session_diff(project_dir, commit_before, commit_after) - - # Get changed files - changed_files = get_changed_files(project_dir, commit_before, commit_after) - - # Get commit messages - commit_messages = get_commit_messages(project_dir, commit_before, commit_after) - - # Get attempt history - attempt_history = _get_attempt_history(recovery_manager, subtask_id) - - return { - "subtask_id": subtask_id, - "subtask_description": subtask_description, - "session_num": session_num, - "success": success, - "diff": diff, - "changed_files": changed_files, - "commit_messages": commit_messages, - "attempt_history": attempt_history, - } - - -def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str: - """Get subtask description from implementation plan.""" - plan_file = spec_dir / "implementation_plan.json" - if not plan_file.exists(): - return f"Subtask: {subtask_id}" - - try: - with open(plan_file) as f: - plan = json.load(f) - - # Search through phases for the subtask - for phase in plan.get("phases", []): - for subtask in phase.get("subtasks", []): - if subtask.get("id") == subtask_id: - return subtask.get("description", f"Subtask: {subtask_id}") - - return f"Subtask: {subtask_id}" - - except Exception as e: - logger.warning(f"Failed to load subtask description: {e}") - return f"Subtask: {subtask_id}" - - -def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]: - """Get previous attempt history for this subtask.""" - if not recovery_manager: - return [] - - try: - history = recovery_manager.get_subtask_history(subtask_id) - attempts = history.get("attempts", []) - - # Limit to recent attempts - return attempts[-MAX_ATTEMPTS_TO_INCLUDE:] - - except Exception as e: - logger.warning(f"Failed to get attempt history: {e}") - return [] - - -# ============================================================================= -# LLM Extraction -# ============================================================================= - - -def _build_extraction_prompt(inputs: dict) -> str: - """Build the prompt for insight extraction.""" - prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md" - - if prompt_file.exists(): - base_prompt = prompt_file.read_text() - else: - # Fallback if prompt file missing - base_prompt = """Extract structured insights from this coding session. -Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations""" - - # Build session context - session_context = f""" ---- - -## SESSION DATA - -### Subtask -- **ID**: {inputs["subtask_id"]} -- **Description**: {inputs["subtask_description"]} -- **Session Number**: {inputs["session_num"]} -- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"} - -### Files Changed -{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"} - -### Commit Messages -{inputs["commit_messages"]} - -### Git Diff -```diff -{inputs["diff"]} -``` - -### Previous Attempts -{_format_attempt_history(inputs["attempt_history"])} - ---- - -Now analyze this session and output ONLY the JSON object. -""" - - return base_prompt + session_context - - -def _format_attempt_history(attempts: list[dict]) -> str: - """Format attempt history for the prompt.""" - if not attempts: - return "(First attempt - no previous history)" - - lines = [] - for i, attempt in enumerate(attempts, 1): - success = "SUCCESS" if attempt.get("success") else "FAILED" - approach = attempt.get("approach", "Unknown approach") - error = attempt.get("error", "") - lines.append(f"**Attempt {i}** ({success}): {approach}") - if error: - lines.append(f" Error: {error}") - - return "\n".join(lines) - - -async def run_insight_extraction(inputs: dict, project_dir: Path | None = None) -> dict | None: - """ - Run the insight extraction using Claude Agent SDK. - - Args: - inputs: Gathered session inputs - project_dir: Project directory for SDK context (optional) - - Returns: - Extracted insights dict or None if failed - """ - if not SDK_AVAILABLE: - logger.warning("Claude SDK not available, skipping insight extraction") - return None - - oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not oauth_token: - logger.warning("CLAUDE_CODE_OAUTH_TOKEN not set, skipping insight extraction") - return None - - model = get_extraction_model() - prompt = _build_extraction_prompt(inputs) - - # Use current directory if project_dir not specified - cwd = str(project_dir.resolve()) if project_dir else os.getcwd() - - try: - # Create a minimal SDK client for insight extraction - # No tools needed - just text generation - client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model=model, - system_prompt=( - "You are an expert code analyst. You extract structured insights from coding sessions. " - "Always respond with valid JSON only, no markdown formatting or explanations." - ), - allowed_tools=[], # No tools needed for extraction - max_turns=1, # Single turn extraction - cwd=cwd, - ) - ) - - # Use async context manager - async with client: - await client.query(prompt) - - # Collect the response - response_text = "" - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - if hasattr(block, "text"): - response_text += block.text - - # Parse JSON from response - return parse_insights(response_text) - - except Exception as e: - logger.warning(f"Insight extraction failed: {e}") - return None - - -def parse_insights(response_text: str) -> dict | None: - """ - Parse the LLM response into structured insights. - - Args: - response_text: Raw LLM response - - Returns: - Parsed insights dict or None if parsing failed - """ - # Try to extract JSON from the response - text = response_text.strip() - - # Handle markdown code blocks - if text.startswith("```"): - # Remove code block markers - lines = text.split("\n") - # Remove first line (```json or ```) - if lines[0].startswith("```"): - lines = lines[1:] - # Remove last line if it's `` - if lines and lines[-1].strip() == "```": - lines = lines[:-1] - text = "\n".join(lines) - - try: - insights = json.loads(text) - - # Validate structure - if not isinstance(insights, dict): - logger.warning("Insights is not a dict") - return None - - # Ensure required keys exist with defaults - insights.setdefault("file_insights", []) - insights.setdefault("patterns_discovered", []) - insights.setdefault("gotchas_discovered", []) - insights.setdefault("approach_outcome", {}) - insights.setdefault("recommendations", []) - - return insights - - except json.JSONDecodeError as e: - logger.warning(f"Failed to parse insights JSON: {e}") - logger.debug(f"Response text was: {text[:500]}") - return None - - -# ============================================================================= -# Main Entry Point -# ============================================================================= - - -async def extract_session_insights( - spec_dir: Path, - project_dir: Path, - subtask_id: str, - session_num: int, - commit_before: str | None, - commit_after: str | None, - success: bool, - recovery_manager: Any, -) -> dict: - """ - Extract insights from a completed coding session. - - This is the main entry point called from post_session_processing(). - Falls back to generic insights if extraction fails. - - Args: - spec_dir: Spec directory - project_dir: Project root - subtask_id: Subtask that was worked on - session_num: Session number - commit_before: Commit before session - commit_after: Commit after session - success: Whether session succeeded - recovery_manager: Recovery manager with attempt history - - Returns: - Insights dict (rich if extraction succeeded, generic if failed) - """ - # Check if extraction is enabled - if not is_extraction_enabled(): - logger.info("Insight extraction disabled") - return _get_generic_insights(subtask_id, success) - - # Check for no changes - if commit_before == commit_after: - logger.info("No changes to extract insights from") - return _get_generic_insights(subtask_id, success) - - try: - # Gather inputs - inputs = gather_extraction_inputs( - spec_dir=spec_dir, - project_dir=project_dir, - subtask_id=subtask_id, - session_num=session_num, - commit_before=commit_before, - commit_after=commit_after, - success=success, - recovery_manager=recovery_manager, - ) - - # Run extraction - extracted = await run_insight_extraction(inputs, project_dir=project_dir) - - if extracted: - # Add metadata - extracted["subtask_id"] = subtask_id - extracted["session_num"] = session_num - extracted["success"] = success - extracted["changed_files"] = inputs["changed_files"] - - logger.info( - f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, " - f"{len(extracted.get('patterns_discovered', []))} patterns, " - f"{len(extracted.get('gotchas_discovered', []))} gotchas" - ) - return extracted - else: - logger.warning("Extraction returned no results, using generic insights") - return _get_generic_insights(subtask_id, success) - - except Exception as e: - logger.warning(f"Insight extraction failed: {e}, using generic insights") - return _get_generic_insights(subtask_id, success) - - -def _get_generic_insights(subtask_id: str, success: bool) -> dict: - """Return generic insights when extraction fails or is disabled.""" - return { - "file_insights": [], - "patterns_discovered": [], - "gotchas_discovered": [], - "approach_outcome": { - "success": success, - "approach_used": f"Implemented subtask: {subtask_id}", - "why_it_worked": None, - "why_it_failed": None, - "alternatives_tried": [], - }, - "recommendations": [], - "subtask_id": subtask_id, - "success": success, - "changed_files": [], - } - - -# ============================================================================= -# CLI for Testing -# ============================================================================= - -if __name__ == "__main__": - import argparse - import asyncio - - parser = argparse.ArgumentParser(description="Test insight extraction") - parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory") - parser.add_argument( - "--project-dir", type=Path, required=True, help="Project directory" - ) - parser.add_argument( - "--commit-before", type=str, required=True, help="Commit before session" - ) - parser.add_argument( - "--commit-after", type=str, required=True, help="Commit after session" - ) - parser.add_argument( - "--subtask-id", type=str, default="test-subtask", help="Subtask ID" - ) - - args = parser.parse_args() - - async def main(): - insights = await extract_session_insights( - spec_dir=args.spec_dir, - project_dir=args.project_dir, - subtask_id=args.subtask_id, - session_num=1, - commit_before=args.commit_before, - commit_after=args.commit_after, - success=True, - recovery_manager=None, - ) - print(json.dumps(insights, indent=2)) - - asyncio.run(main()) +# Load the module directly without going through the package +_module_path = Path(__file__).parent / "analysis" / "insight_extractor.py" +_spec = importlib.util.spec_from_file_location("_insight_extractor_impl", _module_path) +_module = importlib.util.module_from_spec(_spec) +sys.modules["_insight_extractor_impl"] = _module +_spec.loader.exec_module(_module) + +# Re-export all public functions +extract_session_insights = _module.extract_session_insights +gather_extraction_inputs = _module.gather_extraction_inputs +get_changed_files = _module.get_changed_files +get_commit_messages = _module.get_commit_messages +get_extraction_model = _module.get_extraction_model +get_session_diff = _module.get_session_diff +is_extraction_enabled = _module.is_extraction_enabled +parse_insights = _module.parse_insights +run_insight_extraction = _module.run_insight_extraction + +__all__ = [ + "extract_session_insights", + "gather_extraction_inputs", + "get_changed_files", + "get_commit_messages", + "get_extraction_model", + "get_session_diff", + "is_extraction_enabled", + "parse_insights", + "run_insight_extraction", +] diff --git a/auto-claude/integrations/__init__.py b/auto-claude/integrations/__init__.py new file mode 100644 index 00000000..c6c06b34 --- /dev/null +++ b/auto-claude/integrations/__init__.py @@ -0,0 +1,11 @@ +""" +Integrations Module +=================== + +External service integrations for Auto Claude. +""" + +__all__ = [ + "linear", + "graphiti", +] diff --git a/auto-claude/integrations/graphiti/__init__.py b/auto-claude/integrations/graphiti/__init__.py new file mode 100644 index 00000000..eaa0b234 --- /dev/null +++ b/auto-claude/integrations/graphiti/__init__.py @@ -0,0 +1,35 @@ +""" +Graphiti Integration +==================== + +Integration with Graphiti knowledge graph for semantic memory. +""" + +# Config imports don't require graphiti package +from .config import GraphitiConfig, validate_graphiti_config + +# Lazy imports for components that require graphiti package +__all__ = [ + "GraphitiConfig", + "validate_graphiti_config", + "GraphitiMemory", + "create_llm_client", + "create_embedder", +] + + +def __getattr__(name): + """Lazy import to avoid requiring graphiti package for config-only imports.""" + if name == "GraphitiMemory": + from .memory import GraphitiMemory + + return GraphitiMemory + elif name == "create_llm_client": + from .providers import create_llm_client + + return create_llm_client + elif name == "create_embedder": + from .providers import create_embedder + + return create_embedder + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/auto-claude/integrations/graphiti/config.py b/auto-claude/integrations/graphiti/config.py new file mode 100644 index 00000000..54b435d0 --- /dev/null +++ b/auto-claude/integrations/graphiti/config.py @@ -0,0 +1,547 @@ +""" +Graphiti Integration Configuration +================================== + +Constants, status mappings, and configuration helpers for Graphiti memory integration. +Follows the same patterns as linear_config.py for consistency. + +Multi-Provider Support (V2): +- LLM Providers: OpenAI, Anthropic, Azure OpenAI, Ollama +- Embedder Providers: OpenAI, Voyage AI, Azure OpenAI, Ollama + +Environment Variables: + # Core + GRAPHITI_ENABLED: Set to "true" to enable Graphiti integration + GRAPHITI_LLM_PROVIDER: openai|anthropic|azure_openai|ollama (default: openai) + GRAPHITI_EMBEDDER_PROVIDER: openai|voyage|azure_openai|ollama (default: openai) + + # OpenAI + OPENAI_API_KEY: Required for OpenAI provider + OPENAI_MODEL: Model for LLM (default: gpt-5-mini) + OPENAI_EMBEDDING_MODEL: Model for embeddings (default: text-embedding-3-small) + + # Anthropic (LLM only - needs separate embedder) + ANTHROPIC_API_KEY: Required for Anthropic provider + GRAPHITI_ANTHROPIC_MODEL: Model for LLM (default: claude-sonnet-4-5-latest) + + # Azure OpenAI + AZURE_OPENAI_API_KEY: Required for Azure provider + AZURE_OPENAI_BASE_URL: Azure endpoint URL + AZURE_OPENAI_LLM_DEPLOYMENT: Deployment name for LLM + AZURE_OPENAI_EMBEDDING_DEPLOYMENT: Deployment name for embeddings + + # Voyage AI (embeddings only - commonly used with Anthropic) + VOYAGE_API_KEY: Required for Voyage embedder + VOYAGE_EMBEDDING_MODEL: Model (default: voyage-3) + + # Ollama (local) + OLLAMA_BASE_URL: Ollama server URL (default: http://localhost:11434) + OLLAMA_LLM_MODEL: Model for LLM (e.g., deepseek-r1:7b) + OLLAMA_EMBEDDING_MODEL: Model for embeddings (e.g., nomic-embed-text) + OLLAMA_EMBEDDING_DIM: Embedding dimension (required for Ollama, e.g., 768) + + # FalkorDB + GRAPHITI_FALKORDB_HOST: FalkorDB host (default: localhost) + GRAPHITI_FALKORDB_PORT: FalkorDB port (default: 6380) + GRAPHITI_FALKORDB_PASSWORD: FalkorDB password (default: empty) + GRAPHITI_DATABASE: Graph database name (default: auto_claude_memory) + GRAPHITI_TELEMETRY_ENABLED: Set to "false" to disable telemetry (default: true) +""" + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Optional + +# Default configuration values +DEFAULT_FALKORDB_HOST = "localhost" +DEFAULT_FALKORDB_PORT = 6380 +DEFAULT_DATABASE = "auto_claude_memory" +DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" + +# Graphiti state marker file (stores connection info and status) +GRAPHITI_STATE_MARKER = ".graphiti_state.json" + +# Episode types for different memory categories +EPISODE_TYPE_SESSION_INSIGHT = "session_insight" +EPISODE_TYPE_CODEBASE_DISCOVERY = "codebase_discovery" +EPISODE_TYPE_PATTERN = "pattern" +EPISODE_TYPE_GOTCHA = "gotcha" +EPISODE_TYPE_TASK_OUTCOME = "task_outcome" +EPISODE_TYPE_QA_RESULT = "qa_result" +EPISODE_TYPE_HISTORICAL_CONTEXT = "historical_context" + + +class LLMProvider(str, Enum): + """Supported LLM providers for Graphiti.""" + + OPENAI = "openai" + ANTHROPIC = "anthropic" + AZURE_OPENAI = "azure_openai" + OLLAMA = "ollama" + + +class EmbedderProvider(str, Enum): + """Supported embedder providers for Graphiti.""" + + OPENAI = "openai" + VOYAGE = "voyage" + AZURE_OPENAI = "azure_openai" + OLLAMA = "ollama" + + +@dataclass +class GraphitiConfig: + """Configuration for Graphiti memory integration with multi-provider support.""" + + # Core settings + enabled: bool = False + llm_provider: str = "openai" + embedder_provider: str = "openai" + + # FalkorDB connection + falkordb_host: str = DEFAULT_FALKORDB_HOST + falkordb_port: int = DEFAULT_FALKORDB_PORT + falkordb_password: str = "" + database: str = DEFAULT_DATABASE + telemetry_enabled: bool = True + + # OpenAI settings + openai_api_key: str = "" + openai_model: str = "gpt-5-mini" + openai_embedding_model: str = "text-embedding-3-small" + + # Anthropic settings (LLM only) + anthropic_api_key: str = "" + anthropic_model: str = "claude-sonnet-4-5-latest" + + # Azure OpenAI settings + azure_openai_api_key: str = "" + azure_openai_base_url: str = "" + azure_openai_llm_deployment: str = "" + azure_openai_embedding_deployment: str = "" + + # Voyage AI settings (embeddings only) + voyage_api_key: str = "" + voyage_embedding_model: str = "voyage-3" + + # Ollama settings (local) + ollama_base_url: str = DEFAULT_OLLAMA_BASE_URL + ollama_llm_model: str = "" + ollama_embedding_model: str = "" + ollama_embedding_dim: int = 0 # Required for Ollama embeddings + + @classmethod + def from_env(cls) -> "GraphitiConfig": + """Create config from environment variables.""" + # Check if Graphiti is explicitly enabled + enabled_str = os.environ.get("GRAPHITI_ENABLED", "").lower() + enabled = enabled_str in ("true", "1", "yes") + + # Provider selection + llm_provider = os.environ.get("GRAPHITI_LLM_PROVIDER", "openai").lower() + embedder_provider = os.environ.get( + "GRAPHITI_EMBEDDER_PROVIDER", "openai" + ).lower() + + # FalkorDB connection settings + falkordb_host = os.environ.get("GRAPHITI_FALKORDB_HOST", DEFAULT_FALKORDB_HOST) + + try: + falkordb_port = int( + os.environ.get("GRAPHITI_FALKORDB_PORT", str(DEFAULT_FALKORDB_PORT)) + ) + except ValueError: + falkordb_port = DEFAULT_FALKORDB_PORT + + falkordb_password = os.environ.get("GRAPHITI_FALKORDB_PASSWORD", "") + database = os.environ.get("GRAPHITI_DATABASE", DEFAULT_DATABASE) + + # Telemetry setting + telemetry_str = os.environ.get("GRAPHITI_TELEMETRY_ENABLED", "true").lower() + telemetry_enabled = telemetry_str not in ("false", "0", "no") + + # OpenAI settings + openai_api_key = os.environ.get("OPENAI_API_KEY", "") + openai_model = os.environ.get("OPENAI_MODEL", "gpt-5-mini") + openai_embedding_model = os.environ.get( + "OPENAI_EMBEDDING_MODEL", "text-embedding-3-small" + ) + + # Anthropic settings + anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "") + anthropic_model = os.environ.get( + "GRAPHITI_ANTHROPIC_MODEL", "claude-sonnet-4-5-latest" + ) + + # Azure OpenAI settings + azure_openai_api_key = os.environ.get("AZURE_OPENAI_API_KEY", "") + azure_openai_base_url = os.environ.get("AZURE_OPENAI_BASE_URL", "") + azure_openai_llm_deployment = os.environ.get("AZURE_OPENAI_LLM_DEPLOYMENT", "") + azure_openai_embedding_deployment = os.environ.get( + "AZURE_OPENAI_EMBEDDING_DEPLOYMENT", "" + ) + + # Voyage AI settings + voyage_api_key = os.environ.get("VOYAGE_API_KEY", "") + voyage_embedding_model = os.environ.get("VOYAGE_EMBEDDING_MODEL", "voyage-3") + + # Ollama settings + ollama_base_url = os.environ.get("OLLAMA_BASE_URL", DEFAULT_OLLAMA_BASE_URL) + ollama_llm_model = os.environ.get("OLLAMA_LLM_MODEL", "") + ollama_embedding_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "") + + # Ollama embedding dimension (required for Ollama) + try: + ollama_embedding_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "0")) + except ValueError: + ollama_embedding_dim = 0 + + return cls( + enabled=enabled, + llm_provider=llm_provider, + embedder_provider=embedder_provider, + falkordb_host=falkordb_host, + falkordb_port=falkordb_port, + falkordb_password=falkordb_password, + database=database, + telemetry_enabled=telemetry_enabled, + openai_api_key=openai_api_key, + openai_model=openai_model, + openai_embedding_model=openai_embedding_model, + anthropic_api_key=anthropic_api_key, + anthropic_model=anthropic_model, + azure_openai_api_key=azure_openai_api_key, + azure_openai_base_url=azure_openai_base_url, + azure_openai_llm_deployment=azure_openai_llm_deployment, + azure_openai_embedding_deployment=azure_openai_embedding_deployment, + voyage_api_key=voyage_api_key, + voyage_embedding_model=voyage_embedding_model, + ollama_base_url=ollama_base_url, + ollama_llm_model=ollama_llm_model, + ollama_embedding_model=ollama_embedding_model, + ollama_embedding_dim=ollama_embedding_dim, + ) + + def is_valid(self) -> bool: + """ + Check if config has minimum required values for operation. + + Returns True if: + - GRAPHITI_ENABLED is true + - LLM provider is configured correctly + - Embedder provider is configured correctly + """ + if not self.enabled: + return False + + # Validate LLM provider + if not self._validate_llm_provider(): + return False + + # Validate embedder provider + if not self._validate_embedder_provider(): + return False + + return True + + def _validate_llm_provider(self) -> bool: + """Validate LLM provider configuration.""" + 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_openai_api_key + and self.azure_openai_base_url + and self.azure_openai_llm_deployment + ) + elif self.llm_provider == "ollama": + return bool(self.ollama_llm_model) + return False + + def _validate_embedder_provider(self) -> bool: + """Validate embedder provider configuration.""" + 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_openai_api_key + and self.azure_openai_base_url + and self.azure_openai_embedding_deployment + ) + elif self.embedder_provider == "ollama": + return bool(self.ollama_embedding_model and self.ollama_embedding_dim) + return False + + def get_validation_errors(self) -> list[str]: + """Get list of validation errors for current configuration.""" + errors = [] + + if not self.enabled: + errors.append("GRAPHITI_ENABLED must be set to true") + return errors + + # LLM provider validation + if self.llm_provider == "openai": + if not self.openai_api_key: + errors.append("OpenAI LLM provider requires OPENAI_API_KEY") + elif self.llm_provider == "anthropic": + if not self.anthropic_api_key: + errors.append("Anthropic LLM provider requires ANTHROPIC_API_KEY") + elif self.llm_provider == "azure_openai": + if not self.azure_openai_api_key: + errors.append("Azure OpenAI LLM provider requires AZURE_OPENAI_API_KEY") + if not self.azure_openai_base_url: + errors.append( + "Azure OpenAI LLM provider requires AZURE_OPENAI_BASE_URL" + ) + if not self.azure_openai_llm_deployment: + errors.append( + "Azure OpenAI LLM provider requires AZURE_OPENAI_LLM_DEPLOYMENT" + ) + elif self.llm_provider == "ollama": + if not self.ollama_llm_model: + errors.append("Ollama LLM provider requires OLLAMA_LLM_MODEL") + else: + errors.append(f"Unknown LLM provider: {self.llm_provider}") + + # Embedder provider validation + if self.embedder_provider == "openai": + if not self.openai_api_key: + errors.append("OpenAI embedder provider requires OPENAI_API_KEY") + elif self.embedder_provider == "voyage": + if not self.voyage_api_key: + errors.append("Voyage embedder provider requires VOYAGE_API_KEY") + elif self.embedder_provider == "azure_openai": + if not self.azure_openai_api_key: + errors.append( + "Azure OpenAI embedder provider requires AZURE_OPENAI_API_KEY" + ) + if not self.azure_openai_base_url: + errors.append( + "Azure OpenAI embedder provider requires AZURE_OPENAI_BASE_URL" + ) + if not self.azure_openai_embedding_deployment: + errors.append( + "Azure OpenAI embedder provider requires AZURE_OPENAI_EMBEDDING_DEPLOYMENT" + ) + elif self.embedder_provider == "ollama": + if not self.ollama_embedding_model: + errors.append( + "Ollama embedder provider requires OLLAMA_EMBEDDING_MODEL" + ) + if not self.ollama_embedding_dim: + errors.append("Ollama embedder provider requires OLLAMA_EMBEDDING_DIM") + else: + errors.append(f"Unknown embedder provider: {self.embedder_provider}") + + return errors + + def get_connection_uri(self) -> str: + """Get the FalkorDB connection URI.""" + if self.falkordb_password: + return f"redis://:{self.falkordb_password}@{self.falkordb_host}:{self.falkordb_port}" + return f"redis://{self.falkordb_host}:{self.falkordb_port}" + + def get_provider_summary(self) -> str: + """Get a summary of configured providers.""" + return f"LLM: {self.llm_provider}, Embedder: {self.embedder_provider}" + + +@dataclass +class GraphitiState: + """State of Graphiti integration for an auto-claude spec.""" + + initialized: bool = False + database: str | None = None + indices_built: bool = False + created_at: str | None = None + last_session: int | None = None + episode_count: int = 0 + error_log: list = field(default_factory=list) + # V2 additions + llm_provider: str | None = None + embedder_provider: str | None = None + + def to_dict(self) -> dict: + return { + "initialized": self.initialized, + "database": self.database, + "indices_built": self.indices_built, + "created_at": self.created_at, + "last_session": self.last_session, + "episode_count": self.episode_count, + "error_log": self.error_log[-10:], # Keep last 10 errors + "llm_provider": self.llm_provider, + "embedder_provider": self.embedder_provider, + } + + @classmethod + def from_dict(cls, data: dict) -> "GraphitiState": + return cls( + initialized=data.get("initialized", False), + database=data.get("database"), + indices_built=data.get("indices_built", False), + created_at=data.get("created_at"), + last_session=data.get("last_session"), + episode_count=data.get("episode_count", 0), + error_log=data.get("error_log", []), + llm_provider=data.get("llm_provider"), + embedder_provider=data.get("embedder_provider"), + ) + + def save(self, spec_dir: Path) -> None: + """Save state to the spec directory.""" + marker_file = spec_dir / GRAPHITI_STATE_MARKER + with open(marker_file, "w") as f: + json.dump(self.to_dict(), f, indent=2) + + @classmethod + def load(cls, spec_dir: Path) -> Optional["GraphitiState"]: + """Load state from the spec directory.""" + marker_file = spec_dir / GRAPHITI_STATE_MARKER + if not marker_file.exists(): + return None + + try: + with open(marker_file) as f: + return cls.from_dict(json.load(f)) + except (OSError, json.JSONDecodeError): + return None + + def record_error(self, error_msg: str) -> None: + """Record an error in the state.""" + self.error_log.append( + { + "timestamp": datetime.now().isoformat(), + "error": error_msg[:500], # Limit error message length + } + ) + # Keep only last 10 errors + self.error_log = self.error_log[-10:] + + +def is_graphiti_enabled() -> bool: + """ + Quick check if Graphiti integration is available. + + Returns True if: + - GRAPHITI_ENABLED is set to true/1/yes + - Required provider credentials are configured + """ + config = GraphitiConfig.from_env() + return config.is_valid() + + +def get_graphiti_status() -> dict: + """ + Get the current Graphiti integration status. + + Returns: + Dict with status information: + - enabled: bool + - available: bool (has required dependencies) + - host: str + - port: int + - database: str + - llm_provider: str + - embedder_provider: str + - reason: str (why unavailable if not available) + - errors: list (validation errors if any) + """ + config = GraphitiConfig.from_env() + + status = { + "enabled": config.enabled, + "available": False, + "host": config.falkordb_host, + "port": config.falkordb_port, + "database": config.database, + "llm_provider": config.llm_provider, + "embedder_provider": config.embedder_provider, + "reason": "", + "errors": [], + } + + if not config.enabled: + status["reason"] = "GRAPHITI_ENABLED not set to true" + return status + + # Get validation errors + errors = config.get_validation_errors() + if errors: + status["errors"] = errors + status["reason"] = errors[0] # First error as primary reason + return status + + status["available"] = True + return status + + +def get_available_providers() -> dict: + """ + Get list of available providers based on current environment. + + Returns: + Dict with lists of available LLM and embedder providers + """ + config = GraphitiConfig.from_env() + + available_llm = [] + available_embedder = [] + + # Check OpenAI + if config.openai_api_key: + available_llm.append("openai") + available_embedder.append("openai") + + # Check Anthropic + if config.anthropic_api_key: + available_llm.append("anthropic") + + # Check Azure OpenAI + if config.azure_openai_api_key and config.azure_openai_base_url: + if config.azure_openai_llm_deployment: + available_llm.append("azure_openai") + if config.azure_openai_embedding_deployment: + available_embedder.append("azure_openai") + + # Check Voyage + if config.voyage_api_key: + available_embedder.append("voyage") + + # Check Ollama + if config.ollama_llm_model: + available_llm.append("ollama") + if config.ollama_embedding_model and config.ollama_embedding_dim: + available_embedder.append("ollama") + + return { + "llm_providers": available_llm, + "embedder_providers": available_embedder, + } + + +def validate_graphiti_config() -> tuple[bool, list[str]]: + """ + Validate Graphiti configuration from environment. + + Returns: + Tuple of (is_valid, error_messages) + - is_valid: True if configuration is valid + - error_messages: List of validation error messages (empty if valid) + """ + config = GraphitiConfig.from_env() + + if not config.is_valid(): + errors = config.get_validation_errors() + return False, errors + + return True, [] diff --git a/auto-claude/graphiti_memory.py b/auto-claude/integrations/graphiti/memory.py similarity index 100% rename from auto-claude/graphiti_memory.py rename to auto-claude/integrations/graphiti/memory.py diff --git a/auto-claude/integrations/graphiti/providers.py b/auto-claude/integrations/graphiti/providers.py new file mode 100644 index 00000000..87affdec --- /dev/null +++ b/auto-claude/integrations/graphiti/providers.py @@ -0,0 +1,70 @@ +""" +Graphiti Multi-Provider Entry Point +==================================== + +Main entry point for Graphiti provider functionality. +This module re-exports all functionality from the graphiti_providers package. + +The actual implementation has been refactored into a package structure: +- graphiti_providers/exceptions.py - Provider exceptions +- graphiti_providers/models.py - Embedding dimensions and constants +- graphiti_providers/llm_providers/ - LLM provider implementations +- graphiti_providers/embedder_providers/ - Embedder provider implementations +- graphiti_providers/cross_encoder.py - Cross-encoder/reranker +- graphiti_providers/validators.py - Validation and health checks +- graphiti_providers/utils.py - Utility functions +- graphiti_providers/factory.py - Factory functions + +For backward compatibility, this module re-exports all public APIs. + +Usage: + from graphiti_providers import create_llm_client, create_embedder + from graphiti_config import GraphitiConfig + + config = GraphitiConfig.from_env() + llm_client = create_llm_client(config) + embedder = create_embedder(config) +""" + +# Re-export all public APIs from the package +from graphiti_providers import ( + # Exceptions + ProviderError, + ProviderNotInstalled, + # Factory functions + create_llm_client, + create_embedder, + create_cross_encoder, + # Models + EMBEDDING_DIMENSIONS, + get_expected_embedding_dim, + # Validators + validate_embedding_config, + test_llm_connection, + test_embedder_connection, + test_ollama_connection, + # Utilities + is_graphiti_enabled, + get_graph_hints, +) + +__all__ = [ + # Exceptions + "ProviderError", + "ProviderNotInstalled", + # Factory functions + "create_llm_client", + "create_embedder", + "create_cross_encoder", + # Models + "EMBEDDING_DIMENSIONS", + "get_expected_embedding_dim", + # Validators + "validate_embedding_config", + "test_llm_connection", + "test_embedder_connection", + "test_ollama_connection", + # Utilities + "is_graphiti_enabled", + "get_graph_hints", +] diff --git a/auto-claude/graphiti_providers/__init__.py b/auto-claude/integrations/graphiti/providers_pkg/__init__.py similarity index 100% rename from auto-claude/graphiti_providers/__init__.py rename to auto-claude/integrations/graphiti/providers_pkg/__init__.py diff --git a/auto-claude/graphiti_providers/cross_encoder.py b/auto-claude/integrations/graphiti/providers_pkg/cross_encoder.py similarity index 100% rename from auto-claude/graphiti_providers/cross_encoder.py rename to auto-claude/integrations/graphiti/providers_pkg/cross_encoder.py diff --git a/auto-claude/graphiti_providers/embedder_providers/__init__.py b/auto-claude/integrations/graphiti/providers_pkg/embedder_providers/__init__.py similarity index 100% rename from auto-claude/graphiti_providers/embedder_providers/__init__.py rename to auto-claude/integrations/graphiti/providers_pkg/embedder_providers/__init__.py diff --git a/auto-claude/graphiti_providers/embedder_providers/azure_openai_embedder.py b/auto-claude/integrations/graphiti/providers_pkg/embedder_providers/azure_openai_embedder.py similarity index 100% rename from auto-claude/graphiti_providers/embedder_providers/azure_openai_embedder.py rename to auto-claude/integrations/graphiti/providers_pkg/embedder_providers/azure_openai_embedder.py diff --git a/auto-claude/graphiti_providers/embedder_providers/ollama_embedder.py b/auto-claude/integrations/graphiti/providers_pkg/embedder_providers/ollama_embedder.py similarity index 100% rename from auto-claude/graphiti_providers/embedder_providers/ollama_embedder.py rename to auto-claude/integrations/graphiti/providers_pkg/embedder_providers/ollama_embedder.py diff --git a/auto-claude/graphiti_providers/embedder_providers/openai_embedder.py b/auto-claude/integrations/graphiti/providers_pkg/embedder_providers/openai_embedder.py similarity index 100% rename from auto-claude/graphiti_providers/embedder_providers/openai_embedder.py rename to auto-claude/integrations/graphiti/providers_pkg/embedder_providers/openai_embedder.py diff --git a/auto-claude/graphiti_providers/embedder_providers/voyage_embedder.py b/auto-claude/integrations/graphiti/providers_pkg/embedder_providers/voyage_embedder.py similarity index 100% rename from auto-claude/graphiti_providers/embedder_providers/voyage_embedder.py rename to auto-claude/integrations/graphiti/providers_pkg/embedder_providers/voyage_embedder.py diff --git a/auto-claude/graphiti_providers/exceptions.py b/auto-claude/integrations/graphiti/providers_pkg/exceptions.py similarity index 100% rename from auto-claude/graphiti_providers/exceptions.py rename to auto-claude/integrations/graphiti/providers_pkg/exceptions.py diff --git a/auto-claude/graphiti_providers/factory.py b/auto-claude/integrations/graphiti/providers_pkg/factory.py similarity index 100% rename from auto-claude/graphiti_providers/factory.py rename to auto-claude/integrations/graphiti/providers_pkg/factory.py diff --git a/auto-claude/graphiti_providers/llm_providers/__init__.py b/auto-claude/integrations/graphiti/providers_pkg/llm_providers/__init__.py similarity index 100% rename from auto-claude/graphiti_providers/llm_providers/__init__.py rename to auto-claude/integrations/graphiti/providers_pkg/llm_providers/__init__.py diff --git a/auto-claude/graphiti_providers/llm_providers/anthropic_llm.py b/auto-claude/integrations/graphiti/providers_pkg/llm_providers/anthropic_llm.py similarity index 100% rename from auto-claude/graphiti_providers/llm_providers/anthropic_llm.py rename to auto-claude/integrations/graphiti/providers_pkg/llm_providers/anthropic_llm.py diff --git a/auto-claude/graphiti_providers/llm_providers/azure_openai_llm.py b/auto-claude/integrations/graphiti/providers_pkg/llm_providers/azure_openai_llm.py similarity index 100% rename from auto-claude/graphiti_providers/llm_providers/azure_openai_llm.py rename to auto-claude/integrations/graphiti/providers_pkg/llm_providers/azure_openai_llm.py diff --git a/auto-claude/graphiti_providers/llm_providers/ollama_llm.py b/auto-claude/integrations/graphiti/providers_pkg/llm_providers/ollama_llm.py similarity index 100% rename from auto-claude/graphiti_providers/llm_providers/ollama_llm.py rename to auto-claude/integrations/graphiti/providers_pkg/llm_providers/ollama_llm.py diff --git a/auto-claude/graphiti_providers/llm_providers/openai_llm.py b/auto-claude/integrations/graphiti/providers_pkg/llm_providers/openai_llm.py similarity index 100% rename from auto-claude/graphiti_providers/llm_providers/openai_llm.py rename to auto-claude/integrations/graphiti/providers_pkg/llm_providers/openai_llm.py diff --git a/auto-claude/graphiti_providers/models.py b/auto-claude/integrations/graphiti/providers_pkg/models.py similarity index 100% rename from auto-claude/graphiti_providers/models.py rename to auto-claude/integrations/graphiti/providers_pkg/models.py diff --git a/auto-claude/graphiti_providers/utils.py b/auto-claude/integrations/graphiti/providers_pkg/utils.py similarity index 100% rename from auto-claude/graphiti_providers/utils.py rename to auto-claude/integrations/graphiti/providers_pkg/utils.py diff --git a/auto-claude/graphiti_providers/validators.py b/auto-claude/integrations/graphiti/providers_pkg/validators.py similarity index 100% rename from auto-claude/graphiti_providers/validators.py rename to auto-claude/integrations/graphiti/providers_pkg/validators.py diff --git a/auto-claude/graphiti/__init__.py b/auto-claude/integrations/graphiti/queries_pkg/__init__.py similarity index 100% rename from auto-claude/graphiti/__init__.py rename to auto-claude/integrations/graphiti/queries_pkg/__init__.py diff --git a/auto-claude/graphiti/client.py b/auto-claude/integrations/graphiti/queries_pkg/client.py similarity index 100% rename from auto-claude/graphiti/client.py rename to auto-claude/integrations/graphiti/queries_pkg/client.py diff --git a/auto-claude/graphiti/graphiti.py b/auto-claude/integrations/graphiti/queries_pkg/graphiti.py similarity index 100% rename from auto-claude/graphiti/graphiti.py rename to auto-claude/integrations/graphiti/queries_pkg/graphiti.py diff --git a/auto-claude/graphiti/queries.py b/auto-claude/integrations/graphiti/queries_pkg/queries.py similarity index 90% rename from auto-claude/graphiti/queries.py rename to auto-claude/integrations/graphiti/queries_pkg/queries.py index 52f43364..3ec5db70 100644 --- a/auto-claude/graphiti/queries.py +++ b/auto-claude/integrations/graphiti/queries_pkg/queries.py @@ -291,7 +291,11 @@ class GraphitiQueries: ) saved_count += 1 except Exception as e: - logger.debug(f"Failed to save file insight: {e}") + if "duplicate_facts" in str(e): + logger.debug(f"Graphiti deduplication warning (non-fatal): {e}") + saved_count += 1 + else: + logger.debug(f"Failed to save file insight: {e}") # 2. Save patterns for pattern in insights.get("patterns_discovered", []): @@ -330,7 +334,11 @@ class GraphitiQueries: ) saved_count += 1 except Exception as e: - logger.debug(f"Failed to save pattern: {e}") + if "duplicate_facts" in str(e): + logger.debug(f"Graphiti deduplication warning (non-fatal): {e}") + saved_count += 1 + else: + logger.debug(f"Failed to save pattern: {e}") # 3. Save gotchas for gotcha in insights.get("gotchas_discovered", []): @@ -367,7 +375,11 @@ class GraphitiQueries: ) saved_count += 1 except Exception as e: - logger.debug(f"Failed to save gotcha: {e}") + if "duplicate_facts" in str(e): + logger.debug(f"Graphiti deduplication warning (non-fatal): {e}") + saved_count += 1 + else: + logger.debug(f"Failed to save gotcha: {e}") # 4. Save approach outcome outcome = insights.get("approach_outcome", {}) @@ -400,7 +412,13 @@ class GraphitiQueries: ) saved_count += 1 except Exception as e: - logger.debug(f"Failed to save task outcome: {e}") + # Graphiti deduplication can fail with "invalid duplicate_facts idx" + # This is a known issue in graphiti-core - episode is still partially saved + if "duplicate_facts" in str(e): + logger.debug(f"Graphiti deduplication warning (non-fatal): {e}") + saved_count += 1 # Episode likely saved, just dedup failed + else: + logger.debug(f"Failed to save task outcome: {e}") # 5. Save recommendations recommendations = insights.get("recommendations", []) @@ -427,7 +445,11 @@ class GraphitiQueries: ) saved_count += 1 except Exception as e: - logger.debug(f"Failed to save recommendations: {e}") + if "duplicate_facts" in str(e): + logger.debug(f"Graphiti deduplication warning (non-fatal): {e}") + saved_count += 1 + else: + logger.debug(f"Failed to save recommendations: {e}") logger.info( f"Saved {saved_count}/{total_count} structured insights to Graphiti " diff --git a/auto-claude/graphiti/schema.py b/auto-claude/integrations/graphiti/queries_pkg/schema.py similarity index 100% rename from auto-claude/graphiti/schema.py rename to auto-claude/integrations/graphiti/queries_pkg/schema.py diff --git a/auto-claude/graphiti/search.py b/auto-claude/integrations/graphiti/queries_pkg/search.py similarity index 100% rename from auto-claude/graphiti/search.py rename to auto-claude/integrations/graphiti/queries_pkg/search.py diff --git a/auto-claude/test_graphiti_memory.py b/auto-claude/integrations/graphiti/test_graphiti_memory.py similarity index 100% rename from auto-claude/test_graphiti_memory.py rename to auto-claude/integrations/graphiti/test_graphiti_memory.py diff --git a/auto-claude/integrations/linear/__init__.py b/auto-claude/integrations/linear/__init__.py new file mode 100644 index 00000000..5bf91085 --- /dev/null +++ b/auto-claude/integrations/linear/__init__.py @@ -0,0 +1,42 @@ +""" +Linear Integration +================== + +Integration with Linear issue tracking. +""" + +from .config import LinearConfig +from .integration import LinearManager +from .updater import ( + LinearTaskState, + is_linear_enabled, + get_linear_api_key, + create_linear_task, + update_linear_status, + STATUS_TODO, + STATUS_IN_PROGRESS, + STATUS_IN_REVIEW, + STATUS_DONE, + STATUS_CANCELED, +) + +# Aliases for backward compatibility +LinearIntegration = LinearManager +LinearUpdater = LinearTaskState # Alias - old code may expect this name + +__all__ = [ + "LinearConfig", + "LinearManager", + "LinearIntegration", + "LinearTaskState", + "LinearUpdater", + "is_linear_enabled", + "get_linear_api_key", + "create_linear_task", + "update_linear_status", + "STATUS_TODO", + "STATUS_IN_PROGRESS", + "STATUS_IN_REVIEW", + "STATUS_DONE", + "STATUS_CANCELED", +] diff --git a/auto-claude/integrations/linear/config.py b/auto-claude/integrations/linear/config.py new file mode 100644 index 00000000..25bd149f --- /dev/null +++ b/auto-claude/integrations/linear/config.py @@ -0,0 +1,342 @@ +""" +Linear Integration Configuration +================================ + +Constants, status mappings, and configuration helpers for Linear integration. +Mirrors the approach from Linear-Coding-Agent-Harness. +""" + +import json +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +# Linear Status Constants (map to Linear workflow states) +STATUS_TODO = "Todo" +STATUS_IN_PROGRESS = "In Progress" +STATUS_DONE = "Done" +STATUS_BLOCKED = "Blocked" # For stuck subtasks +STATUS_CANCELED = "Canceled" + +# Linear Priority Constants (1=Urgent, 4=Low, 0=No priority) +PRIORITY_URGENT = 1 # Core infrastructure, blockers +PRIORITY_HIGH = 2 # Primary features, dependencies +PRIORITY_MEDIUM = 3 # Secondary features +PRIORITY_LOW = 4 # Polish, nice-to-haves +PRIORITY_NONE = 0 # No priority set + +# Subtask status to Linear status mapping +SUBTASK_TO_LINEAR_STATUS = { + "pending": STATUS_TODO, + "in_progress": STATUS_IN_PROGRESS, + "completed": STATUS_DONE, + "blocked": STATUS_BLOCKED, + "failed": STATUS_BLOCKED, # Map failures to Blocked for visibility + "stuck": STATUS_BLOCKED, +} + +# Linear labels for categorization +LABELS = { + "phase": "phase", # Phase label prefix (e.g., "phase-1") + "service": "service", # Service label prefix (e.g., "service-backend") + "stuck": "stuck", # Mark stuck subtasks + "auto_build": "auto-claude", # All auto-claude issues + "needs_review": "needs-review", +} + +# Linear project marker file (stores team/project IDs) +LINEAR_PROJECT_MARKER = ".linear_project.json" + +# Meta issue for session tracking +META_ISSUE_TITLE = "[META] Build Progress Tracker" + + +@dataclass +class LinearConfig: + """Configuration for Linear integration.""" + + api_key: str + team_id: str | None = None + project_id: str | None = None + project_name: str | None = None + meta_issue_id: str | None = None + enabled: bool = True + + @classmethod + def from_env(cls) -> "LinearConfig": + """Create config from environment variables.""" + api_key = os.environ.get("LINEAR_API_KEY", "") + + return cls( + api_key=api_key, + team_id=os.environ.get("LINEAR_TEAM_ID"), + project_id=os.environ.get("LINEAR_PROJECT_ID"), + enabled=bool(api_key), + ) + + def is_valid(self) -> bool: + """Check if config has minimum required values.""" + return bool(self.api_key) + + +@dataclass +class LinearProjectState: + """State of a Linear project for an auto-claude spec.""" + + initialized: bool = False + team_id: str | None = None + project_id: str | None = None + project_name: str | None = None + meta_issue_id: str | None = None + total_issues: int = 0 + created_at: str | None = None + issue_mapping: dict = None # subtask_id -> issue_id mapping + + def __post_init__(self): + if self.issue_mapping is None: + self.issue_mapping = {} + + def to_dict(self) -> dict: + return { + "initialized": self.initialized, + "team_id": self.team_id, + "project_id": self.project_id, + "project_name": self.project_name, + "meta_issue_id": self.meta_issue_id, + "total_issues": self.total_issues, + "created_at": self.created_at, + "issue_mapping": self.issue_mapping, + } + + @classmethod + def from_dict(cls, data: dict) -> "LinearProjectState": + return cls( + initialized=data.get("initialized", False), + team_id=data.get("team_id"), + project_id=data.get("project_id"), + project_name=data.get("project_name"), + meta_issue_id=data.get("meta_issue_id"), + total_issues=data.get("total_issues", 0), + created_at=data.get("created_at"), + issue_mapping=data.get("issue_mapping", {}), + ) + + def save(self, spec_dir: Path) -> None: + """Save state to the spec directory.""" + marker_file = spec_dir / LINEAR_PROJECT_MARKER + with open(marker_file, "w") as f: + json.dump(self.to_dict(), f, indent=2) + + @classmethod + def load(cls, spec_dir: Path) -> Optional["LinearProjectState"]: + """Load state from the spec directory.""" + marker_file = spec_dir / LINEAR_PROJECT_MARKER + if not marker_file.exists(): + return None + + try: + with open(marker_file) as f: + return cls.from_dict(json.load(f)) + except (OSError, json.JSONDecodeError): + return None + + +def get_linear_status(subtask_status: str) -> str: + """ + Map subtask status to Linear status. + + Args: + subtask_status: Status from implementation_plan.json + + Returns: + Corresponding Linear status string + """ + return SUBTASK_TO_LINEAR_STATUS.get(subtask_status, STATUS_TODO) + + +def get_priority_for_phase(phase_num: int, total_phases: int) -> int: + """ + Determine Linear priority based on phase number. + + Early phases are higher priority (they're dependencies). + + Args: + phase_num: Phase number (1-indexed) + total_phases: Total number of phases + + Returns: + Linear priority value (1-4) + """ + if total_phases <= 1: + return PRIORITY_HIGH + + # First quarter of phases = Urgent + # Second quarter = High + # Third quarter = Medium + # Fourth quarter = Low + position = phase_num / total_phases + + if position <= 0.25: + return PRIORITY_URGENT + elif position <= 0.5: + return PRIORITY_HIGH + elif position <= 0.75: + return PRIORITY_MEDIUM + else: + return PRIORITY_LOW + + +def format_subtask_description(subtask: dict, phase: dict = None) -> str: + """ + Format a subtask as a Linear issue description. + + Args: + subtask: Subtask dict from implementation_plan.json + phase: Optional phase dict for context + + Returns: + Markdown-formatted description + """ + lines = [] + + # Description + if subtask.get("description"): + lines.append(f"## Description\n{subtask['description']}\n") + + # Service + if subtask.get("service"): + lines.append(f"**Service:** {subtask['service']}") + elif subtask.get("all_services"): + lines.append("**Scope:** All services (integration)") + + # Phase info + if phase: + lines.append(f"**Phase:** {phase.get('name', phase.get('id', 'Unknown'))}") + + # Files to modify + if subtask.get("files_to_modify"): + lines.append("\n## Files to Modify") + for f in subtask["files_to_modify"]: + lines.append(f"- `{f}`") + + # Files to create + if subtask.get("files_to_create"): + lines.append("\n## Files to Create") + for f in subtask["files_to_create"]: + lines.append(f"- `{f}`") + + # Patterns to follow + if subtask.get("patterns_from"): + lines.append("\n## Reference Patterns") + for f in subtask["patterns_from"]: + lines.append(f"- `{f}`") + + # Verification + if subtask.get("verification"): + v = subtask["verification"] + lines.append("\n## Verification") + lines.append(f"**Type:** {v.get('type', 'none')}") + if v.get("run"): + lines.append(f"**Command:** `{v['run']}`") + if v.get("url"): + lines.append(f"**URL:** {v['url']}") + if v.get("scenario"): + lines.append(f"**Scenario:** {v['scenario']}") + + # Auto-build metadata + lines.append("\n---") + lines.append("*This issue was created by the Auto-Build Framework*") + + return "\n".join(lines) + + +def format_session_comment( + session_num: int, + subtask_id: str, + success: bool, + approach: str = "", + error: str = "", + git_commit: str = "", +) -> str: + """ + Format a session result as a Linear comment. + + Args: + session_num: Session number + subtask_id: Subtask being worked on + success: Whether the session succeeded + approach: What was attempted + error: Error message if failed + git_commit: Git commit hash if any + + Returns: + Markdown-formatted comment + """ + status_emoji = "✅" if success else "❌" + lines = [ + f"## Session #{session_num} {status_emoji}", + f"**Subtask:** `{subtask_id}`", + f"**Status:** {'Completed' if success else 'In Progress'}", + f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ] + + if approach: + lines.append(f"\n**Approach:** {approach}") + + if git_commit: + lines.append(f"\n**Commit:** `{git_commit[:8]}`") + + if error: + lines.append(f"\n**Error:**\n```\n{error[:500]}\n```") + + return "\n".join(lines) + + +def format_stuck_subtask_comment( + subtask_id: str, + attempt_count: int, + attempts: list[dict], + reason: str = "", +) -> str: + """ + Format a detailed comment for stuck subtasks. + + Args: + subtask_id: Stuck subtask ID + attempt_count: Number of attempts + attempts: List of attempt records + reason: Why it's stuck + + Returns: + Markdown-formatted comment for escalation + """ + lines = [ + "## ⚠️ Subtask Marked as STUCK", + f"**Subtask:** `{subtask_id}`", + f"**Attempts:** {attempt_count}", + f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + ] + + if reason: + lines.append(f"\n**Reason:** {reason}") + + # Add attempt history + if attempts: + lines.append("\n### Attempt History") + for i, attempt in enumerate(attempts[-5:], 1): # Last 5 attempts + status = "✅" if attempt.get("success") else "❌" + lines.append(f"\n**Attempt {i}:** {status}") + if attempt.get("approach"): + lines.append(f"- Approach: {attempt['approach'][:200]}") + if attempt.get("error"): + lines.append(f"- Error: {attempt['error'][:200]}") + + lines.append("\n### Recommended Actions") + lines.append("1. Review the approach and error patterns above") + lines.append("2. Check for missing dependencies or configuration") + lines.append("3. Consider manual intervention or different approach") + lines.append("4. Update HUMAN_INPUT.md with guidance for the agent") + + return "\n".join(lines) diff --git a/auto-claude/integrations/linear/integration.py b/auto-claude/integrations/linear/integration.py new file mode 100644 index 00000000..a31b98f2 --- /dev/null +++ b/auto-claude/integrations/linear/integration.py @@ -0,0 +1,553 @@ +""" +Linear Integration Manager +========================== + +Manages synchronization between Auto-Build subtasks and Linear issues. +Provides real-time visibility into build progress through Linear. + +The integration is OPTIONAL - if LINEAR_API_KEY is not set, all operations +gracefully no-op and the build continues with local tracking only. + +Key Features: +- Subtask → Issue mapping (sync implementation_plan.json to Linear) +- Session attempt recording (comments on issues) +- Stuck subtask escalation (move to Blocked, add detailed comments) +- Progress tracking via META issue +""" + +import json +import os +from datetime import datetime +from pathlib import Path + +from .config import ( + LABELS, + STATUS_BLOCKED, + LinearConfig, + LinearProjectState, + format_session_comment, + format_stuck_subtask_comment, + format_subtask_description, + get_linear_status, + get_priority_for_phase, +) + + +class LinearManager: + """ + Manages Linear integration for an Auto-Build spec. + + This class provides a high-level interface for: + - Creating/syncing issues from implementation_plan.json + - Recording session attempts and results + - Escalating stuck subtasks + - Tracking overall progress + + All operations are idempotent and gracefully handle Linear being unavailable. + """ + + def __init__(self, spec_dir: Path, project_dir: Path): + """ + Initialize Linear manager. + + Args: + spec_dir: Spec directory (contains implementation_plan.json) + project_dir: Project root directory + """ + self.spec_dir = spec_dir + self.project_dir = project_dir + self.config = LinearConfig.from_env() + self.state: LinearProjectState | None = None + self._mcp_available = False + + # Load existing state if available + self.state = LinearProjectState.load(spec_dir) + + # Check if Linear MCP tools are available + self._check_mcp_availability() + + def _check_mcp_availability(self) -> None: + """Check if Linear MCP tools are available in the environment.""" + # In agent context, MCP tools are available via claude-code + # We'll assume they're available if LINEAR_API_KEY is set + self._mcp_available = self.config.is_valid() + + @property + def is_enabled(self) -> bool: + """Check if Linear integration is enabled and available.""" + return self.config.is_valid() and self._mcp_available + + @property + def is_initialized(self) -> bool: + """Check if Linear project has been initialized for this spec.""" + return self.state is not None and self.state.initialized + + def get_issue_id(self, subtask_id: str) -> str | None: + """ + Get the Linear issue ID for a subtask. + + Args: + subtask_id: Subtask ID from implementation_plan.json + + Returns: + Linear issue ID or None if not mapped + """ + if not self.state: + return None + return self.state.issue_mapping.get(subtask_id) + + def set_issue_id(self, subtask_id: str, issue_id: str) -> None: + """ + Store the mapping between a subtask and its Linear issue. + + Args: + subtask_id: Subtask ID from implementation_plan.json + issue_id: Linear issue ID + """ + if not self.state: + self.state = LinearProjectState() + + self.state.issue_mapping[subtask_id] = issue_id + self.state.save(self.spec_dir) + + def initialize_project(self, team_id: str, project_name: str) -> bool: + """ + Initialize a Linear project for this spec. + + This should be called by the agent during the planner session + to set up the Linear project and create initial issues. + + Args: + team_id: Linear team ID + project_name: Name for the Linear project + + Returns: + True if successful + """ + if not self.is_enabled: + print("Linear integration not enabled (LINEAR_API_KEY not set)") + return False + + # Create initial state + self.state = LinearProjectState( + initialized=True, + team_id=team_id, + project_name=project_name, + created_at=datetime.now().isoformat(), + ) + + self.state.save(self.spec_dir) + return True + + def update_project_id(self, project_id: str) -> None: + """Update the Linear project ID after creation.""" + if self.state: + self.state.project_id = project_id + self.state.save(self.spec_dir) + + def update_meta_issue_id(self, meta_issue_id: str) -> None: + """Update the META issue ID after creation.""" + if self.state: + self.state.meta_issue_id = meta_issue_id + self.state.save(self.spec_dir) + + def load_implementation_plan(self) -> dict | None: + """Load the implementation plan from spec directory.""" + plan_file = self.spec_dir / "implementation_plan.json" + if not plan_file.exists(): + return None + + try: + with open(plan_file) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return None + + def get_subtasks_for_sync(self) -> list[dict]: + """ + Get all subtasks that need Linear issues. + + Returns: + List of subtask dicts with phase context + """ + plan = self.load_implementation_plan() + if not plan: + return [] + + subtasks = [] + phases = plan.get("phases", []) + total_phases = len(phases) + + for phase in phases: + phase_num = phase.get("phase", 1) + phase_name = phase.get("name", f"Phase {phase_num}") + + for subtask in phase.get("subtasks", []): + subtasks.append( + { + **subtask, + "phase_num": phase_num, + "phase_name": phase_name, + "total_phases": total_phases, + "phase_depends_on": phase.get("depends_on", []), + } + ) + + return subtasks + + def generate_issue_data(self, subtask: dict) -> dict: + """ + Generate Linear issue data from a subtask. + + Args: + subtask: Subtask dict with phase context + + Returns: + Dict suitable for Linear create_issue + """ + phase = { + "name": subtask.get("phase_name"), + "id": subtask.get("phase_num"), + } + + # Determine priority based on phase position + priority = get_priority_for_phase( + subtask.get("phase_num", 1), subtask.get("total_phases", 1) + ) + + # Build labels list + labels = [LABELS["auto_build"]] + if subtask.get("service"): + labels.append(f"{LABELS['service']}-{subtask['service']}") + if subtask.get("phase_num"): + labels.append(f"{LABELS['phase']}-{subtask['phase_num']}") + + return { + "title": f"[{subtask.get('id', 'subtask')}] {subtask.get('description', 'Implement subtask')[:100]}", + "description": format_subtask_description(subtask, phase), + "priority": priority, + "labels": labels, + "status": get_linear_status(subtask.get("status", "pending")), + } + + def record_session_result( + self, + subtask_id: str, + session_num: int, + success: bool, + approach: str = "", + error: str = "", + git_commit: str = "", + ) -> str: + """ + Record a session result as a Linear comment. + + This is called by post_session_processing in agent.py. + + Args: + subtask_id: Subtask being worked on + session_num: Session number + success: Whether the session succeeded + approach: What was attempted + error: Error message if failed + git_commit: Git commit hash if any + + Returns: + Formatted comment body (for logging even if Linear unavailable) + """ + comment = format_session_comment( + session_num=session_num, + subtask_id=subtask_id, + success=success, + approach=approach, + error=error, + git_commit=git_commit, + ) + + # Note: Actual Linear API call will be done by the agent + # This method prepares the data and returns it + return comment + + def prepare_status_update(self, subtask_id: str, new_status: str) -> dict: + """ + Prepare data for a Linear issue status update. + + Args: + subtask_id: Subtask ID + new_status: New subtask status (pending, in_progress, completed, etc.) + + Returns: + Dict with issue_id and linear_status for the update + """ + issue_id = self.get_issue_id(subtask_id) + linear_status = get_linear_status(new_status) + + return { + "issue_id": issue_id, + "status": linear_status, + "subtask_id": subtask_id, + } + + def prepare_stuck_escalation( + self, + subtask_id: str, + attempt_count: int, + attempts: list[dict], + reason: str = "", + ) -> dict: + """ + Prepare data for escalating a stuck subtask. + + This creates the comment body and status update data. + + Args: + subtask_id: Stuck subtask ID + attempt_count: Number of attempts + attempts: List of attempt records + reason: Why it's stuck + + Returns: + Dict with issue_id, comment, labels for escalation + """ + issue_id = self.get_issue_id(subtask_id) + comment = format_stuck_subtask_comment( + subtask_id=subtask_id, + attempt_count=attempt_count, + attempts=attempts, + reason=reason, + ) + + return { + "issue_id": issue_id, + "subtask_id": subtask_id, + "status": STATUS_BLOCKED, + "comment": comment, + "labels": [LABELS["stuck"], LABELS["needs_review"]], + } + + def get_progress_summary(self) -> dict: + """ + Get a summary of Linear integration progress. + + Returns: + Dict with progress statistics + """ + plan = self.load_implementation_plan() + if not plan: + return { + "enabled": self.is_enabled, + "initialized": False, + "total_subtasks": 0, + "mapped_subtasks": 0, + } + + subtasks = self.get_subtasks_for_sync() + mapped = sum(1 for s in subtasks if self.get_issue_id(s.get("id", ""))) + + return { + "enabled": self.is_enabled, + "initialized": self.is_initialized, + "team_id": self.state.team_id if self.state else None, + "project_id": self.state.project_id if self.state else None, + "project_name": self.state.project_name if self.state else None, + "meta_issue_id": self.state.meta_issue_id if self.state else None, + "total_subtasks": len(subtasks), + "mapped_subtasks": mapped, + } + + def get_linear_context_for_prompt(self) -> str: + """ + Generate Linear context section for agent prompts. + + This is included in the subtask prompt to give the agent + awareness of Linear integration status. + + Returns: + Markdown-formatted context string + """ + if not self.is_enabled: + return "" + + summary = self.get_progress_summary() + + if not summary["initialized"]: + return """ +## Linear Integration + +Linear integration is enabled but not yet initialized. +During the planner session, create a Linear project and sync issues. + +Available Linear MCP tools: +- `mcp__linear-server__list_teams` - List available teams +- `mcp__linear-server__create_project` - Create a new project +- `mcp__linear-server__create_issue` - Create issues for subtasks +- `mcp__linear-server__update_issue` - Update issue status +- `mcp__linear-server__create_comment` - Add session comments +""" + + lines = [ + "## Linear Integration", + "", + f"**Project:** {summary['project_name']}", + f"**Issues:** {summary['mapped_subtasks']}/{summary['total_subtasks']} subtasks mapped", + "", + "When working on a subtask:", + "1. Update issue status to 'In Progress' at start", + "2. Add comments with progress/blockers", + "3. Update status to 'Done' when subtask completes", + "4. If stuck, status will be set to 'Blocked' automatically", + ] + + return "\n".join(lines) + + def save_state(self) -> None: + """Save the current state to disk.""" + if self.state: + self.state.save(self.spec_dir) + + +# Utility functions for integration with other modules + + +def get_linear_manager(spec_dir: Path, project_dir: Path) -> LinearManager: + """ + Get a LinearManager instance for the given spec. + + This is the main entry point for other modules. + + Args: + spec_dir: Spec directory + project_dir: Project root directory + + Returns: + LinearManager instance + """ + return LinearManager(spec_dir, project_dir) + + +def is_linear_enabled() -> bool: + """Quick check if Linear integration is available.""" + return bool(os.environ.get("LINEAR_API_KEY")) + + +def prepare_planner_linear_instructions(spec_dir: Path) -> str: + """ + Generate Linear setup instructions for the planner agent. + + This is included in the planner prompt when Linear is enabled. + + Args: + spec_dir: Spec directory + + Returns: + Markdown instructions for Linear setup + """ + if not is_linear_enabled(): + return "" + + return """ +## Linear Integration Setup + +Linear integration is ENABLED. After creating the implementation plan: + +### Step 1: Find the Team +``` +Use mcp__linear-server__list_teams to find your team ID +``` + +### Step 2: Create the Project +``` +Use mcp__linear-server__create_project with: +- team: Your team ID +- name: The feature/spec name +- description: Brief summary from spec.md +``` +Save the project ID to .linear_project.json + +### Step 3: Create Issues for Each Subtask +For each subtask in implementation_plan.json: +``` +Use mcp__linear-server__create_issue with: +- team: Your team ID +- project: The project ID +- title: "[subtask-id] Description" +- description: Formatted subtask details +- priority: Based on phase (1=urgent for early phases, 4=low for polish) +- labels: ["auto-claude", "phase-N", "service-NAME"] +``` +Save the subtask_id -> issue_id mapping to .linear_project.json + +### Step 4: Create META Issue +``` +Use mcp__linear-server__create_issue with: +- title: "[META] Build Progress Tracker" +- description: "Session summaries and overall progress tracking" +``` +This issue receives session summary comments. + +### Important Notes +- Update .linear_project.json after each Linear operation +- The JSON structure should include: + - initialized: true + - team_id: "..." + - project_id: "..." + - meta_issue_id: "..." + - issue_mapping: { "subtask-1-1": "LIN-123", ... } +""" + + +def prepare_coder_linear_instructions( + spec_dir: Path, + subtask_id: str, +) -> str: + """ + Generate Linear instructions for the coding agent. + + Args: + spec_dir: Spec directory + subtask_id: Current subtask being worked on + + Returns: + Markdown instructions for Linear updates + """ + if not is_linear_enabled(): + return "" + + manager = LinearManager(spec_dir, spec_dir.parent.parent) # Approximate project_dir + + if not manager.is_initialized: + return "" + + issue_id = manager.get_issue_id(subtask_id) + if not issue_id: + return "" + + return f""" +## Linear Updates + +This subtask is linked to Linear issue: `{issue_id}` + +### At Session Start +Update the issue status to "In Progress": +``` +mcp__linear-server__update_issue(id="{issue_id}", state="In Progress") +``` + +### During Work +Add comments for significant progress or blockers: +``` +mcp__linear-server__create_comment(issueId="{issue_id}", body="...") +``` + +### On Completion +Update status to "Done": +``` +mcp__linear-server__update_issue(id="{issue_id}", state="Done") +``` + +### Session Summary +At session end, add a comment to the META issue with: +- What was accomplished +- Any blockers or issues found +- Recommendations for next session +""" diff --git a/auto-claude/integrations/linear/updater.py b/auto-claude/integrations/linear/updater.py new file mode 100644 index 00000000..d60e7f25 --- /dev/null +++ b/auto-claude/integrations/linear/updater.py @@ -0,0 +1,442 @@ +""" +Linear Updater - Python-Orchestrated Linear Updates +==================================================== + +Provides reliable Linear updates via focused mini-agent calls. +Instead of relying on agents to remember Linear updates in long prompts, +the Python orchestrator triggers small, focused agents at key transitions. + +Design Principles: +- ONE task per spec (not one issue per subtask) +- Python orchestrator controls when updates happen +- Small prompts that can't lose context +- Graceful degradation if Linear unavailable + +Status Flow: + Todo -> In Progress -> In Review -> (human) -> Done + | | | + | | +-- QA approved, awaiting human merge + | +-- Planner/Coder working + +-- Task created from spec +""" + +import json +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +# Linear status constants (matching Valma AI team setup) +STATUS_TODO = "Todo" +STATUS_IN_PROGRESS = "In Progress" +STATUS_IN_REVIEW = "In Review" # Custom status for QA phase +STATUS_DONE = "Done" +STATUS_CANCELED = "Canceled" + +# State file name +LINEAR_TASK_FILE = ".linear_task.json" + +# Linear MCP tools needed for updates +LINEAR_TOOLS = [ + "mcp__linear-server__list_teams", + "mcp__linear-server__create_issue", + "mcp__linear-server__update_issue", + "mcp__linear-server__create_comment", + "mcp__linear-server__list_issue_statuses", +] + + +@dataclass +class LinearTaskState: + """State of a Linear task for an auto-claude spec.""" + + task_id: str | None = None + task_title: str | None = None + team_id: str | None = None + status: str = STATUS_TODO + created_at: str | None = None + + def to_dict(self) -> dict: + return { + "task_id": self.task_id, + "task_title": self.task_title, + "team_id": self.team_id, + "status": self.status, + "created_at": self.created_at, + } + + @classmethod + def from_dict(cls, data: dict) -> "LinearTaskState": + return cls( + task_id=data.get("task_id"), + task_title=data.get("task_title"), + team_id=data.get("team_id"), + status=data.get("status", STATUS_TODO), + created_at=data.get("created_at"), + ) + + def save(self, spec_dir: Path) -> None: + """Save state to the spec directory.""" + state_file = spec_dir / LINEAR_TASK_FILE + with open(state_file, "w") as f: + json.dump(self.to_dict(), f, indent=2) + + @classmethod + def load(cls, spec_dir: Path) -> Optional["LinearTaskState"]: + """Load state from the spec directory.""" + state_file = spec_dir / LINEAR_TASK_FILE + if not state_file.exists(): + return None + + try: + with open(state_file) as f: + return cls.from_dict(json.load(f)) + except (OSError, json.JSONDecodeError): + return None + + +def is_linear_enabled() -> bool: + """Check if Linear integration is available.""" + return bool(os.environ.get("LINEAR_API_KEY")) + + +def get_linear_api_key() -> str: + """Get the Linear API key from environment.""" + return os.environ.get("LINEAR_API_KEY", "") + + +def _create_linear_client() -> ClaudeSDKClient: + """ + Create a minimal Claude client with only Linear MCP tools. + Used for focused mini-agent calls. + """ + oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + if not oauth_token: + raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set") + + linear_api_key = get_linear_api_key() + if not linear_api_key: + raise ValueError("LINEAR_API_KEY not set") + + return ClaudeSDKClient( + options=ClaudeAgentOptions( + model="claude-haiku-4-5", # Fast & cheap model for simple API calls + system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.", + allowed_tools=LINEAR_TOOLS, + mcp_servers={ + "linear": { + "type": "http", + "url": "https://mcp.linear.app/mcp", + "headers": {"Authorization": f"Bearer {linear_api_key}"}, + } + }, + max_turns=10, # Should complete in 1-3 turns + ) + ) + + +async def _run_linear_agent(prompt: str) -> str | None: + """ + Run a focused mini-agent for a Linear operation. + + Args: + prompt: The focused prompt for the Linear operation + + Returns: + The response text, or None if failed + """ + try: + client = _create_linear_client() + + async with client: + await client.query(prompt) + + response_text = "" + async for msg in client.receive_response(): + msg_type = type(msg).__name__ + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + if block_type == "TextBlock" and hasattr(block, "text"): + response_text += block.text + + return response_text + + except Exception as e: + print(f"Linear update failed: {e}") + return None + + +async def create_linear_task( + spec_dir: Path, + title: str, + description: str | None = None, +) -> LinearTaskState | None: + """ + Create a new Linear task for a spec. + + Called by spec_runner.py after requirements gathering. + + Args: + spec_dir: Spec directory to save state + title: Task title (the task name from user) + description: Optional task description + + Returns: + LinearTaskState if successful, None if failed + """ + if not is_linear_enabled(): + return None + + # Check if task already exists + existing = LinearTaskState.load(spec_dir) + if existing and existing.task_id: + print(f"Linear task already exists: {existing.task_id}") + return existing + + desc_part = f'\n - description: "{description}"' if description else "" + + prompt = f"""Create a Linear task with these details: + +1. First, use mcp__linear-server__list_teams to find the team ID +2. Then, use mcp__linear-server__create_issue with: + - teamId: [the team ID from step 1] + - title: "{title}"{desc_part} + +After creating the issue, tell me: +- The issue ID (like "VAL-123") +- The team ID you used + +Format your final response as: +TASK_ID: [the issue ID] +TEAM_ID: [the team ID] +""" + + response = await _run_linear_agent(prompt) + if not response: + return None + + # Parse response for task_id and team_id + task_id = None + team_id = None + + for line in response.split("\n"): + line = line.strip() + if line.startswith("TASK_ID:"): + task_id = line.replace("TASK_ID:", "").strip() + elif line.startswith("TEAM_ID:"): + team_id = line.replace("TEAM_ID:", "").strip() + + if not task_id: + print(f"Failed to parse task ID from response: {response[:200]}") + return None + + # Create and save state + state = LinearTaskState( + task_id=task_id, + task_title=title, + team_id=team_id, + status=STATUS_TODO, + created_at=datetime.now().isoformat(), + ) + state.save(spec_dir) + + print(f"Created Linear task: {task_id}") + return state + + +async def update_linear_status( + spec_dir: Path, + new_status: str, +) -> bool: + """ + Update the Linear task status. + + Args: + spec_dir: Spec directory with .linear_task.json + new_status: New status (STATUS_TODO, STATUS_IN_PROGRESS, STATUS_IN_REVIEW, STATUS_DONE) + + Returns: + True if successful, False otherwise + """ + if not is_linear_enabled(): + return False + + state = LinearTaskState.load(spec_dir) + if not state or not state.task_id: + print("No Linear task found for this spec") + return False + + # Don't update if already at this status + if state.status == new_status: + return True + + prompt = f"""Update Linear issue status: + +1. First, use mcp__linear-server__list_issue_statuses with teamId: "{state.team_id}" to find the state ID for "{new_status}" +2. Then, use mcp__linear-server__update_issue with: + - issueId: "{state.task_id}" + - stateId: [the state ID for "{new_status}" from step 1] + +Confirm when done. +""" + + response = await _run_linear_agent(prompt) + if response: + state.status = new_status + state.save(spec_dir) + print(f"Updated Linear task {state.task_id} to: {new_status}") + return True + + return False + + +async def add_linear_comment( + spec_dir: Path, + comment: str, +) -> bool: + """ + Add a comment to the Linear task. + + Args: + spec_dir: Spec directory with .linear_task.json + comment: Comment text to add + + Returns: + True if successful, False otherwise + """ + if not is_linear_enabled(): + return False + + state = LinearTaskState.load(spec_dir) + if not state or not state.task_id: + print("No Linear task found for this spec") + return False + + # Escape any quotes in the comment + safe_comment = comment.replace('"', '\\"').replace("\n", "\\n") + + prompt = f"""Add a comment to Linear issue: + +Use mcp__linear-server__create_comment with: +- issueId: "{state.task_id}" +- body: "{safe_comment}" + +Confirm when done. +""" + + response = await _run_linear_agent(prompt) + if response: + print(f"Added comment to Linear task {state.task_id}") + return True + + return False + + +# === Convenience functions for specific transitions === + + +async def linear_task_started(spec_dir: Path) -> bool: + """ + Mark task as started (In Progress). + Called when planner session begins. + """ + success = await update_linear_status(spec_dir, STATUS_IN_PROGRESS) + if success: + await add_linear_comment(spec_dir, "Build started - planning phase initiated") + return success + + +async def linear_subtask_completed( + spec_dir: Path, + subtask_id: str, + completed_count: int, + total_count: int, +) -> bool: + """ + Record subtask completion as a comment. + Called after each successful coder session. + """ + comment = f"Completed {subtask_id} ({completed_count}/{total_count} subtasks done)" + return await add_linear_comment(spec_dir, comment) + + +async def linear_subtask_failed( + spec_dir: Path, + subtask_id: str, + attempt: int, + error_summary: str, +) -> bool: + """ + Record subtask failure as a comment. + Called after failed coder session. + """ + comment = f"Subtask {subtask_id} failed (attempt {attempt}): {error_summary[:200]}" + return await add_linear_comment(spec_dir, comment) + + +async def linear_build_complete(spec_dir: Path) -> bool: + """ + Record build completion, moving to QA. + Called when all subtasks are completed. + """ + comment = "All subtasks completed - moving to QA validation" + return await add_linear_comment(spec_dir, comment) + + +async def linear_qa_started(spec_dir: Path) -> bool: + """ + Mark task as In Review for QA phase. + Called when QA validation loop starts. + """ + success = await update_linear_status(spec_dir, STATUS_IN_REVIEW) + if success: + await add_linear_comment(spec_dir, "QA validation started") + return success + + +async def linear_qa_approved(spec_dir: Path) -> bool: + """ + Record QA approval (stays In Review for human). + Called when QA approves the build. + """ + comment = "QA approved - awaiting human review for merge" + return await add_linear_comment(spec_dir, comment) + + +async def linear_qa_rejected( + spec_dir: Path, + issues_count: int, + iteration: int, +) -> bool: + """ + Record QA rejection. + Called when QA rejects the build. + """ + comment = f"QA iteration {iteration}: Found {issues_count} issues - applying fixes" + return await add_linear_comment(spec_dir, comment) + + +async def linear_qa_max_iterations(spec_dir: Path, iterations: int) -> bool: + """ + Record QA max iterations reached. + Called when QA loop exhausts retries. + """ + comment = f"QA reached max iterations ({iterations}) - needs human intervention" + return await add_linear_comment(spec_dir, comment) + + +async def linear_task_stuck( + spec_dir: Path, + subtask_id: str, + attempt_count: int, +) -> bool: + """ + Record that a subtask is stuck. + Called when subtask exceeds retry limit. + """ + comment = f"Subtask {subtask_id} is STUCK after {attempt_count} attempts - needs human review" + return await add_linear_comment(spec_dir, comment) diff --git a/auto-claude/linear_config.py b/auto-claude/linear_config.py index 25bd149f..1684a1a9 100644 --- a/auto-claude/linear_config.py +++ b/auto-claude/linear_config.py @@ -1,342 +1,2 @@ -""" -Linear Integration Configuration -================================ - -Constants, status mappings, and configuration helpers for Linear integration. -Mirrors the approach from Linear-Coding-Agent-Harness. -""" - -import json -import os -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Optional - -# Linear Status Constants (map to Linear workflow states) -STATUS_TODO = "Todo" -STATUS_IN_PROGRESS = "In Progress" -STATUS_DONE = "Done" -STATUS_BLOCKED = "Blocked" # For stuck subtasks -STATUS_CANCELED = "Canceled" - -# Linear Priority Constants (1=Urgent, 4=Low, 0=No priority) -PRIORITY_URGENT = 1 # Core infrastructure, blockers -PRIORITY_HIGH = 2 # Primary features, dependencies -PRIORITY_MEDIUM = 3 # Secondary features -PRIORITY_LOW = 4 # Polish, nice-to-haves -PRIORITY_NONE = 0 # No priority set - -# Subtask status to Linear status mapping -SUBTASK_TO_LINEAR_STATUS = { - "pending": STATUS_TODO, - "in_progress": STATUS_IN_PROGRESS, - "completed": STATUS_DONE, - "blocked": STATUS_BLOCKED, - "failed": STATUS_BLOCKED, # Map failures to Blocked for visibility - "stuck": STATUS_BLOCKED, -} - -# Linear labels for categorization -LABELS = { - "phase": "phase", # Phase label prefix (e.g., "phase-1") - "service": "service", # Service label prefix (e.g., "service-backend") - "stuck": "stuck", # Mark stuck subtasks - "auto_build": "auto-claude", # All auto-claude issues - "needs_review": "needs-review", -} - -# Linear project marker file (stores team/project IDs) -LINEAR_PROJECT_MARKER = ".linear_project.json" - -# Meta issue for session tracking -META_ISSUE_TITLE = "[META] Build Progress Tracker" - - -@dataclass -class LinearConfig: - """Configuration for Linear integration.""" - - api_key: str - team_id: str | None = None - project_id: str | None = None - project_name: str | None = None - meta_issue_id: str | None = None - enabled: bool = True - - @classmethod - def from_env(cls) -> "LinearConfig": - """Create config from environment variables.""" - api_key = os.environ.get("LINEAR_API_KEY", "") - - return cls( - api_key=api_key, - team_id=os.environ.get("LINEAR_TEAM_ID"), - project_id=os.environ.get("LINEAR_PROJECT_ID"), - enabled=bool(api_key), - ) - - def is_valid(self) -> bool: - """Check if config has minimum required values.""" - return bool(self.api_key) - - -@dataclass -class LinearProjectState: - """State of a Linear project for an auto-claude spec.""" - - initialized: bool = False - team_id: str | None = None - project_id: str | None = None - project_name: str | None = None - meta_issue_id: str | None = None - total_issues: int = 0 - created_at: str | None = None - issue_mapping: dict = None # subtask_id -> issue_id mapping - - def __post_init__(self): - if self.issue_mapping is None: - self.issue_mapping = {} - - def to_dict(self) -> dict: - return { - "initialized": self.initialized, - "team_id": self.team_id, - "project_id": self.project_id, - "project_name": self.project_name, - "meta_issue_id": self.meta_issue_id, - "total_issues": self.total_issues, - "created_at": self.created_at, - "issue_mapping": self.issue_mapping, - } - - @classmethod - def from_dict(cls, data: dict) -> "LinearProjectState": - return cls( - initialized=data.get("initialized", False), - team_id=data.get("team_id"), - project_id=data.get("project_id"), - project_name=data.get("project_name"), - meta_issue_id=data.get("meta_issue_id"), - total_issues=data.get("total_issues", 0), - created_at=data.get("created_at"), - issue_mapping=data.get("issue_mapping", {}), - ) - - def save(self, spec_dir: Path) -> None: - """Save state to the spec directory.""" - marker_file = spec_dir / LINEAR_PROJECT_MARKER - with open(marker_file, "w") as f: - json.dump(self.to_dict(), f, indent=2) - - @classmethod - def load(cls, spec_dir: Path) -> Optional["LinearProjectState"]: - """Load state from the spec directory.""" - marker_file = spec_dir / LINEAR_PROJECT_MARKER - if not marker_file.exists(): - return None - - try: - with open(marker_file) as f: - return cls.from_dict(json.load(f)) - except (OSError, json.JSONDecodeError): - return None - - -def get_linear_status(subtask_status: str) -> str: - """ - Map subtask status to Linear status. - - Args: - subtask_status: Status from implementation_plan.json - - Returns: - Corresponding Linear status string - """ - return SUBTASK_TO_LINEAR_STATUS.get(subtask_status, STATUS_TODO) - - -def get_priority_for_phase(phase_num: int, total_phases: int) -> int: - """ - Determine Linear priority based on phase number. - - Early phases are higher priority (they're dependencies). - - Args: - phase_num: Phase number (1-indexed) - total_phases: Total number of phases - - Returns: - Linear priority value (1-4) - """ - if total_phases <= 1: - return PRIORITY_HIGH - - # First quarter of phases = Urgent - # Second quarter = High - # Third quarter = Medium - # Fourth quarter = Low - position = phase_num / total_phases - - if position <= 0.25: - return PRIORITY_URGENT - elif position <= 0.5: - return PRIORITY_HIGH - elif position <= 0.75: - return PRIORITY_MEDIUM - else: - return PRIORITY_LOW - - -def format_subtask_description(subtask: dict, phase: dict = None) -> str: - """ - Format a subtask as a Linear issue description. - - Args: - subtask: Subtask dict from implementation_plan.json - phase: Optional phase dict for context - - Returns: - Markdown-formatted description - """ - lines = [] - - # Description - if subtask.get("description"): - lines.append(f"## Description\n{subtask['description']}\n") - - # Service - if subtask.get("service"): - lines.append(f"**Service:** {subtask['service']}") - elif subtask.get("all_services"): - lines.append("**Scope:** All services (integration)") - - # Phase info - if phase: - lines.append(f"**Phase:** {phase.get('name', phase.get('id', 'Unknown'))}") - - # Files to modify - if subtask.get("files_to_modify"): - lines.append("\n## Files to Modify") - for f in subtask["files_to_modify"]: - lines.append(f"- `{f}`") - - # Files to create - if subtask.get("files_to_create"): - lines.append("\n## Files to Create") - for f in subtask["files_to_create"]: - lines.append(f"- `{f}`") - - # Patterns to follow - if subtask.get("patterns_from"): - lines.append("\n## Reference Patterns") - for f in subtask["patterns_from"]: - lines.append(f"- `{f}`") - - # Verification - if subtask.get("verification"): - v = subtask["verification"] - lines.append("\n## Verification") - lines.append(f"**Type:** {v.get('type', 'none')}") - if v.get("run"): - lines.append(f"**Command:** `{v['run']}`") - if v.get("url"): - lines.append(f"**URL:** {v['url']}") - if v.get("scenario"): - lines.append(f"**Scenario:** {v['scenario']}") - - # Auto-build metadata - lines.append("\n---") - lines.append("*This issue was created by the Auto-Build Framework*") - - return "\n".join(lines) - - -def format_session_comment( - session_num: int, - subtask_id: str, - success: bool, - approach: str = "", - error: str = "", - git_commit: str = "", -) -> str: - """ - Format a session result as a Linear comment. - - Args: - session_num: Session number - subtask_id: Subtask being worked on - success: Whether the session succeeded - approach: What was attempted - error: Error message if failed - git_commit: Git commit hash if any - - Returns: - Markdown-formatted comment - """ - status_emoji = "✅" if success else "❌" - lines = [ - f"## Session #{session_num} {status_emoji}", - f"**Subtask:** `{subtask_id}`", - f"**Status:** {'Completed' if success else 'In Progress'}", - f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - ] - - if approach: - lines.append(f"\n**Approach:** {approach}") - - if git_commit: - lines.append(f"\n**Commit:** `{git_commit[:8]}`") - - if error: - lines.append(f"\n**Error:**\n```\n{error[:500]}\n```") - - return "\n".join(lines) - - -def format_stuck_subtask_comment( - subtask_id: str, - attempt_count: int, - attempts: list[dict], - reason: str = "", -) -> str: - """ - Format a detailed comment for stuck subtasks. - - Args: - subtask_id: Stuck subtask ID - attempt_count: Number of attempts - attempts: List of attempt records - reason: Why it's stuck - - Returns: - Markdown-formatted comment for escalation - """ - lines = [ - "## ⚠️ Subtask Marked as STUCK", - f"**Subtask:** `{subtask_id}`", - f"**Attempts:** {attempt_count}", - f"**Time:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - ] - - if reason: - lines.append(f"\n**Reason:** {reason}") - - # Add attempt history - if attempts: - lines.append("\n### Attempt History") - for i, attempt in enumerate(attempts[-5:], 1): # Last 5 attempts - status = "✅" if attempt.get("success") else "❌" - lines.append(f"\n**Attempt {i}:** {status}") - if attempt.get("approach"): - lines.append(f"- Approach: {attempt['approach'][:200]}") - if attempt.get("error"): - lines.append(f"- Error: {attempt['error'][:200]}") - - lines.append("\n### Recommended Actions") - lines.append("1. Review the approach and error patterns above") - lines.append("2. Check for missing dependencies or configuration") - lines.append("3. Consider manual intervention or different approach") - lines.append("4. Update HUMAN_INPUT.md with guidance for the agent") - - return "\n".join(lines) +"""Backward compatibility shim - import from integrations.linear.config instead.""" +from integrations.linear.config import * diff --git a/auto-claude/linear_integration.py b/auto-claude/linear_integration.py index 2bd18182..180f0b61 100644 --- a/auto-claude/linear_integration.py +++ b/auto-claude/linear_integration.py @@ -1,553 +1,2 @@ -""" -Linear Integration Manager -========================== - -Manages synchronization between Auto-Build subtasks and Linear issues. -Provides real-time visibility into build progress through Linear. - -The integration is OPTIONAL - if LINEAR_API_KEY is not set, all operations -gracefully no-op and the build continues with local tracking only. - -Key Features: -- Subtask → Issue mapping (sync implementation_plan.json to Linear) -- Session attempt recording (comments on issues) -- Stuck subtask escalation (move to Blocked, add detailed comments) -- Progress tracking via META issue -""" - -import json -import os -from datetime import datetime -from pathlib import Path - -from linear_config import ( - LABELS, - STATUS_BLOCKED, - LinearConfig, - LinearProjectState, - format_session_comment, - format_stuck_subtask_comment, - format_subtask_description, - get_linear_status, - get_priority_for_phase, -) - - -class LinearManager: - """ - Manages Linear integration for an Auto-Build spec. - - This class provides a high-level interface for: - - Creating/syncing issues from implementation_plan.json - - Recording session attempts and results - - Escalating stuck subtasks - - Tracking overall progress - - All operations are idempotent and gracefully handle Linear being unavailable. - """ - - def __init__(self, spec_dir: Path, project_dir: Path): - """ - Initialize Linear manager. - - Args: - spec_dir: Spec directory (contains implementation_plan.json) - project_dir: Project root directory - """ - self.spec_dir = spec_dir - self.project_dir = project_dir - self.config = LinearConfig.from_env() - self.state: LinearProjectState | None = None - self._mcp_available = False - - # Load existing state if available - self.state = LinearProjectState.load(spec_dir) - - # Check if Linear MCP tools are available - self._check_mcp_availability() - - def _check_mcp_availability(self) -> None: - """Check if Linear MCP tools are available in the environment.""" - # In agent context, MCP tools are available via claude-code - # We'll assume they're available if LINEAR_API_KEY is set - self._mcp_available = self.config.is_valid() - - @property - def is_enabled(self) -> bool: - """Check if Linear integration is enabled and available.""" - return self.config.is_valid() and self._mcp_available - - @property - def is_initialized(self) -> bool: - """Check if Linear project has been initialized for this spec.""" - return self.state is not None and self.state.initialized - - def get_issue_id(self, subtask_id: str) -> str | None: - """ - Get the Linear issue ID for a subtask. - - Args: - subtask_id: Subtask ID from implementation_plan.json - - Returns: - Linear issue ID or None if not mapped - """ - if not self.state: - return None - return self.state.issue_mapping.get(subtask_id) - - def set_issue_id(self, subtask_id: str, issue_id: str) -> None: - """ - Store the mapping between a subtask and its Linear issue. - - Args: - subtask_id: Subtask ID from implementation_plan.json - issue_id: Linear issue ID - """ - if not self.state: - self.state = LinearProjectState() - - self.state.issue_mapping[subtask_id] = issue_id - self.state.save(self.spec_dir) - - def initialize_project(self, team_id: str, project_name: str) -> bool: - """ - Initialize a Linear project for this spec. - - This should be called by the agent during the planner session - to set up the Linear project and create initial issues. - - Args: - team_id: Linear team ID - project_name: Name for the Linear project - - Returns: - True if successful - """ - if not self.is_enabled: - print("Linear integration not enabled (LINEAR_API_KEY not set)") - return False - - # Create initial state - self.state = LinearProjectState( - initialized=True, - team_id=team_id, - project_name=project_name, - created_at=datetime.now().isoformat(), - ) - - self.state.save(self.spec_dir) - return True - - def update_project_id(self, project_id: str) -> None: - """Update the Linear project ID after creation.""" - if self.state: - self.state.project_id = project_id - self.state.save(self.spec_dir) - - def update_meta_issue_id(self, meta_issue_id: str) -> None: - """Update the META issue ID after creation.""" - if self.state: - self.state.meta_issue_id = meta_issue_id - self.state.save(self.spec_dir) - - def load_implementation_plan(self) -> dict | None: - """Load the implementation plan from spec directory.""" - plan_file = self.spec_dir / "implementation_plan.json" - if not plan_file.exists(): - return None - - try: - with open(plan_file) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return None - - def get_subtasks_for_sync(self) -> list[dict]: - """ - Get all subtasks that need Linear issues. - - Returns: - List of subtask dicts with phase context - """ - plan = self.load_implementation_plan() - if not plan: - return [] - - subtasks = [] - phases = plan.get("phases", []) - total_phases = len(phases) - - for phase in phases: - phase_num = phase.get("phase", 1) - phase_name = phase.get("name", f"Phase {phase_num}") - - for subtask in phase.get("subtasks", []): - subtasks.append( - { - **subtask, - "phase_num": phase_num, - "phase_name": phase_name, - "total_phases": total_phases, - "phase_depends_on": phase.get("depends_on", []), - } - ) - - return subtasks - - def generate_issue_data(self, subtask: dict) -> dict: - """ - Generate Linear issue data from a subtask. - - Args: - subtask: Subtask dict with phase context - - Returns: - Dict suitable for Linear create_issue - """ - phase = { - "name": subtask.get("phase_name"), - "id": subtask.get("phase_num"), - } - - # Determine priority based on phase position - priority = get_priority_for_phase( - subtask.get("phase_num", 1), subtask.get("total_phases", 1) - ) - - # Build labels list - labels = [LABELS["auto_build"]] - if subtask.get("service"): - labels.append(f"{LABELS['service']}-{subtask['service']}") - if subtask.get("phase_num"): - labels.append(f"{LABELS['phase']}-{subtask['phase_num']}") - - return { - "title": f"[{subtask.get('id', 'subtask')}] {subtask.get('description', 'Implement subtask')[:100]}", - "description": format_subtask_description(subtask, phase), - "priority": priority, - "labels": labels, - "status": get_linear_status(subtask.get("status", "pending")), - } - - def record_session_result( - self, - subtask_id: str, - session_num: int, - success: bool, - approach: str = "", - error: str = "", - git_commit: str = "", - ) -> str: - """ - Record a session result as a Linear comment. - - This is called by post_session_processing in agent.py. - - Args: - subtask_id: Subtask being worked on - session_num: Session number - success: Whether the session succeeded - approach: What was attempted - error: Error message if failed - git_commit: Git commit hash if any - - Returns: - Formatted comment body (for logging even if Linear unavailable) - """ - comment = format_session_comment( - session_num=session_num, - subtask_id=subtask_id, - success=success, - approach=approach, - error=error, - git_commit=git_commit, - ) - - # Note: Actual Linear API call will be done by the agent - # This method prepares the data and returns it - return comment - - def prepare_status_update(self, subtask_id: str, new_status: str) -> dict: - """ - Prepare data for a Linear issue status update. - - Args: - subtask_id: Subtask ID - new_status: New subtask status (pending, in_progress, completed, etc.) - - Returns: - Dict with issue_id and linear_status for the update - """ - issue_id = self.get_issue_id(subtask_id) - linear_status = get_linear_status(new_status) - - return { - "issue_id": issue_id, - "status": linear_status, - "subtask_id": subtask_id, - } - - def prepare_stuck_escalation( - self, - subtask_id: str, - attempt_count: int, - attempts: list[dict], - reason: str = "", - ) -> dict: - """ - Prepare data for escalating a stuck subtask. - - This creates the comment body and status update data. - - Args: - subtask_id: Stuck subtask ID - attempt_count: Number of attempts - attempts: List of attempt records - reason: Why it's stuck - - Returns: - Dict with issue_id, comment, labels for escalation - """ - issue_id = self.get_issue_id(subtask_id) - comment = format_stuck_subtask_comment( - subtask_id=subtask_id, - attempt_count=attempt_count, - attempts=attempts, - reason=reason, - ) - - return { - "issue_id": issue_id, - "subtask_id": subtask_id, - "status": STATUS_BLOCKED, - "comment": comment, - "labels": [LABELS["stuck"], LABELS["needs_review"]], - } - - def get_progress_summary(self) -> dict: - """ - Get a summary of Linear integration progress. - - Returns: - Dict with progress statistics - """ - plan = self.load_implementation_plan() - if not plan: - return { - "enabled": self.is_enabled, - "initialized": False, - "total_subtasks": 0, - "mapped_subtasks": 0, - } - - subtasks = self.get_subtasks_for_sync() - mapped = sum(1 for s in subtasks if self.get_issue_id(s.get("id", ""))) - - return { - "enabled": self.is_enabled, - "initialized": self.is_initialized, - "team_id": self.state.team_id if self.state else None, - "project_id": self.state.project_id if self.state else None, - "project_name": self.state.project_name if self.state else None, - "meta_issue_id": self.state.meta_issue_id if self.state else None, - "total_subtasks": len(subtasks), - "mapped_subtasks": mapped, - } - - def get_linear_context_for_prompt(self) -> str: - """ - Generate Linear context section for agent prompts. - - This is included in the subtask prompt to give the agent - awareness of Linear integration status. - - Returns: - Markdown-formatted context string - """ - if not self.is_enabled: - return "" - - summary = self.get_progress_summary() - - if not summary["initialized"]: - return """ -## Linear Integration - -Linear integration is enabled but not yet initialized. -During the planner session, create a Linear project and sync issues. - -Available Linear MCP tools: -- `mcp__linear-server__list_teams` - List available teams -- `mcp__linear-server__create_project` - Create a new project -- `mcp__linear-server__create_issue` - Create issues for subtasks -- `mcp__linear-server__update_issue` - Update issue status -- `mcp__linear-server__create_comment` - Add session comments -""" - - lines = [ - "## Linear Integration", - "", - f"**Project:** {summary['project_name']}", - f"**Issues:** {summary['mapped_subtasks']}/{summary['total_subtasks']} subtasks mapped", - "", - "When working on a subtask:", - "1. Update issue status to 'In Progress' at start", - "2. Add comments with progress/blockers", - "3. Update status to 'Done' when subtask completes", - "4. If stuck, status will be set to 'Blocked' automatically", - ] - - return "\n".join(lines) - - def save_state(self) -> None: - """Save the current state to disk.""" - if self.state: - self.state.save(self.spec_dir) - - -# Utility functions for integration with other modules - - -def get_linear_manager(spec_dir: Path, project_dir: Path) -> LinearManager: - """ - Get a LinearManager instance for the given spec. - - This is the main entry point for other modules. - - Args: - spec_dir: Spec directory - project_dir: Project root directory - - Returns: - LinearManager instance - """ - return LinearManager(spec_dir, project_dir) - - -def is_linear_enabled() -> bool: - """Quick check if Linear integration is available.""" - return bool(os.environ.get("LINEAR_API_KEY")) - - -def prepare_planner_linear_instructions(spec_dir: Path) -> str: - """ - Generate Linear setup instructions for the planner agent. - - This is included in the planner prompt when Linear is enabled. - - Args: - spec_dir: Spec directory - - Returns: - Markdown instructions for Linear setup - """ - if not is_linear_enabled(): - return "" - - return """ -## Linear Integration Setup - -Linear integration is ENABLED. After creating the implementation plan: - -### Step 1: Find the Team -``` -Use mcp__linear-server__list_teams to find your team ID -``` - -### Step 2: Create the Project -``` -Use mcp__linear-server__create_project with: -- team: Your team ID -- name: The feature/spec name -- description: Brief summary from spec.md -``` -Save the project ID to .linear_project.json - -### Step 3: Create Issues for Each Subtask -For each subtask in implementation_plan.json: -``` -Use mcp__linear-server__create_issue with: -- team: Your team ID -- project: The project ID -- title: "[subtask-id] Description" -- description: Formatted subtask details -- priority: Based on phase (1=urgent for early phases, 4=low for polish) -- labels: ["auto-claude", "phase-N", "service-NAME"] -``` -Save the subtask_id -> issue_id mapping to .linear_project.json - -### Step 4: Create META Issue -``` -Use mcp__linear-server__create_issue with: -- title: "[META] Build Progress Tracker" -- description: "Session summaries and overall progress tracking" -``` -This issue receives session summary comments. - -### Important Notes -- Update .linear_project.json after each Linear operation -- The JSON structure should include: - - initialized: true - - team_id: "..." - - project_id: "..." - - meta_issue_id: "..." - - issue_mapping: { "subtask-1-1": "LIN-123", ... } -""" - - -def prepare_coder_linear_instructions( - spec_dir: Path, - subtask_id: str, -) -> str: - """ - Generate Linear instructions for the coding agent. - - Args: - spec_dir: Spec directory - subtask_id: Current subtask being worked on - - Returns: - Markdown instructions for Linear updates - """ - if not is_linear_enabled(): - return "" - - manager = LinearManager(spec_dir, spec_dir.parent.parent) # Approximate project_dir - - if not manager.is_initialized: - return "" - - issue_id = manager.get_issue_id(subtask_id) - if not issue_id: - return "" - - return f""" -## Linear Updates - -This subtask is linked to Linear issue: `{issue_id}` - -### At Session Start -Update the issue status to "In Progress": -``` -mcp__linear-server__update_issue(id="{issue_id}", state="In Progress") -``` - -### During Work -Add comments for significant progress or blockers: -``` -mcp__linear-server__create_comment(issueId="{issue_id}", body="...") -``` - -### On Completion -Update status to "Done": -``` -mcp__linear-server__update_issue(id="{issue_id}", state="Done") -``` - -### Session Summary -At session end, add a comment to the META issue with: -- What was accomplished -- Any blockers or issues found -- Recommendations for next session -""" +"""Backward compatibility shim - import from integrations.linear.integration instead.""" +from integrations.linear.integration import * diff --git a/auto-claude/linear_updater.py b/auto-claude/linear_updater.py index d60e7f25..870fdb7a 100644 --- a/auto-claude/linear_updater.py +++ b/auto-claude/linear_updater.py @@ -1,442 +1,2 @@ -""" -Linear Updater - Python-Orchestrated Linear Updates -==================================================== - -Provides reliable Linear updates via focused mini-agent calls. -Instead of relying on agents to remember Linear updates in long prompts, -the Python orchestrator triggers small, focused agents at key transitions. - -Design Principles: -- ONE task per spec (not one issue per subtask) -- Python orchestrator controls when updates happen -- Small prompts that can't lose context -- Graceful degradation if Linear unavailable - -Status Flow: - Todo -> In Progress -> In Review -> (human) -> Done - | | | - | | +-- QA approved, awaiting human merge - | +-- Planner/Coder working - +-- Task created from spec -""" - -import json -import os -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Optional - -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient - -# Linear status constants (matching Valma AI team setup) -STATUS_TODO = "Todo" -STATUS_IN_PROGRESS = "In Progress" -STATUS_IN_REVIEW = "In Review" # Custom status for QA phase -STATUS_DONE = "Done" -STATUS_CANCELED = "Canceled" - -# State file name -LINEAR_TASK_FILE = ".linear_task.json" - -# Linear MCP tools needed for updates -LINEAR_TOOLS = [ - "mcp__linear-server__list_teams", - "mcp__linear-server__create_issue", - "mcp__linear-server__update_issue", - "mcp__linear-server__create_comment", - "mcp__linear-server__list_issue_statuses", -] - - -@dataclass -class LinearTaskState: - """State of a Linear task for an auto-claude spec.""" - - task_id: str | None = None - task_title: str | None = None - team_id: str | None = None - status: str = STATUS_TODO - created_at: str | None = None - - def to_dict(self) -> dict: - return { - "task_id": self.task_id, - "task_title": self.task_title, - "team_id": self.team_id, - "status": self.status, - "created_at": self.created_at, - } - - @classmethod - def from_dict(cls, data: dict) -> "LinearTaskState": - return cls( - task_id=data.get("task_id"), - task_title=data.get("task_title"), - team_id=data.get("team_id"), - status=data.get("status", STATUS_TODO), - created_at=data.get("created_at"), - ) - - def save(self, spec_dir: Path) -> None: - """Save state to the spec directory.""" - state_file = spec_dir / LINEAR_TASK_FILE - with open(state_file, "w") as f: - json.dump(self.to_dict(), f, indent=2) - - @classmethod - def load(cls, spec_dir: Path) -> Optional["LinearTaskState"]: - """Load state from the spec directory.""" - state_file = spec_dir / LINEAR_TASK_FILE - if not state_file.exists(): - return None - - try: - with open(state_file) as f: - return cls.from_dict(json.load(f)) - except (OSError, json.JSONDecodeError): - return None - - -def is_linear_enabled() -> bool: - """Check if Linear integration is available.""" - return bool(os.environ.get("LINEAR_API_KEY")) - - -def get_linear_api_key() -> str: - """Get the Linear API key from environment.""" - return os.environ.get("LINEAR_API_KEY", "") - - -def _create_linear_client() -> ClaudeSDKClient: - """ - Create a minimal Claude client with only Linear MCP tools. - Used for focused mini-agent calls. - """ - oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not oauth_token: - raise ValueError("CLAUDE_CODE_OAUTH_TOKEN not set") - - linear_api_key = get_linear_api_key() - if not linear_api_key: - raise ValueError("LINEAR_API_KEY not set") - - return ClaudeSDKClient( - options=ClaudeAgentOptions( - model="claude-haiku-4-5", # Fast & cheap model for simple API calls - system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.", - allowed_tools=LINEAR_TOOLS, - mcp_servers={ - "linear": { - "type": "http", - "url": "https://mcp.linear.app/mcp", - "headers": {"Authorization": f"Bearer {linear_api_key}"}, - } - }, - max_turns=10, # Should complete in 1-3 turns - ) - ) - - -async def _run_linear_agent(prompt: str) -> str | None: - """ - Run a focused mini-agent for a Linear operation. - - Args: - prompt: The focused prompt for the Linear operation - - Returns: - The response text, or None if failed - """ - try: - client = _create_linear_client() - - async with client: - await client.query(prompt) - - response_text = "" - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - if block_type == "TextBlock" and hasattr(block, "text"): - response_text += block.text - - return response_text - - except Exception as e: - print(f"Linear update failed: {e}") - return None - - -async def create_linear_task( - spec_dir: Path, - title: str, - description: str | None = None, -) -> LinearTaskState | None: - """ - Create a new Linear task for a spec. - - Called by spec_runner.py after requirements gathering. - - Args: - spec_dir: Spec directory to save state - title: Task title (the task name from user) - description: Optional task description - - Returns: - LinearTaskState if successful, None if failed - """ - if not is_linear_enabled(): - return None - - # Check if task already exists - existing = LinearTaskState.load(spec_dir) - if existing and existing.task_id: - print(f"Linear task already exists: {existing.task_id}") - return existing - - desc_part = f'\n - description: "{description}"' if description else "" - - prompt = f"""Create a Linear task with these details: - -1. First, use mcp__linear-server__list_teams to find the team ID -2. Then, use mcp__linear-server__create_issue with: - - teamId: [the team ID from step 1] - - title: "{title}"{desc_part} - -After creating the issue, tell me: -- The issue ID (like "VAL-123") -- The team ID you used - -Format your final response as: -TASK_ID: [the issue ID] -TEAM_ID: [the team ID] -""" - - response = await _run_linear_agent(prompt) - if not response: - return None - - # Parse response for task_id and team_id - task_id = None - team_id = None - - for line in response.split("\n"): - line = line.strip() - if line.startswith("TASK_ID:"): - task_id = line.replace("TASK_ID:", "").strip() - elif line.startswith("TEAM_ID:"): - team_id = line.replace("TEAM_ID:", "").strip() - - if not task_id: - print(f"Failed to parse task ID from response: {response[:200]}") - return None - - # Create and save state - state = LinearTaskState( - task_id=task_id, - task_title=title, - team_id=team_id, - status=STATUS_TODO, - created_at=datetime.now().isoformat(), - ) - state.save(spec_dir) - - print(f"Created Linear task: {task_id}") - return state - - -async def update_linear_status( - spec_dir: Path, - new_status: str, -) -> bool: - """ - Update the Linear task status. - - Args: - spec_dir: Spec directory with .linear_task.json - new_status: New status (STATUS_TODO, STATUS_IN_PROGRESS, STATUS_IN_REVIEW, STATUS_DONE) - - Returns: - True if successful, False otherwise - """ - if not is_linear_enabled(): - return False - - state = LinearTaskState.load(spec_dir) - if not state or not state.task_id: - print("No Linear task found for this spec") - return False - - # Don't update if already at this status - if state.status == new_status: - return True - - prompt = f"""Update Linear issue status: - -1. First, use mcp__linear-server__list_issue_statuses with teamId: "{state.team_id}" to find the state ID for "{new_status}" -2. Then, use mcp__linear-server__update_issue with: - - issueId: "{state.task_id}" - - stateId: [the state ID for "{new_status}" from step 1] - -Confirm when done. -""" - - response = await _run_linear_agent(prompt) - if response: - state.status = new_status - state.save(spec_dir) - print(f"Updated Linear task {state.task_id} to: {new_status}") - return True - - return False - - -async def add_linear_comment( - spec_dir: Path, - comment: str, -) -> bool: - """ - Add a comment to the Linear task. - - Args: - spec_dir: Spec directory with .linear_task.json - comment: Comment text to add - - Returns: - True if successful, False otherwise - """ - if not is_linear_enabled(): - return False - - state = LinearTaskState.load(spec_dir) - if not state or not state.task_id: - print("No Linear task found for this spec") - return False - - # Escape any quotes in the comment - safe_comment = comment.replace('"', '\\"').replace("\n", "\\n") - - prompt = f"""Add a comment to Linear issue: - -Use mcp__linear-server__create_comment with: -- issueId: "{state.task_id}" -- body: "{safe_comment}" - -Confirm when done. -""" - - response = await _run_linear_agent(prompt) - if response: - print(f"Added comment to Linear task {state.task_id}") - return True - - return False - - -# === Convenience functions for specific transitions === - - -async def linear_task_started(spec_dir: Path) -> bool: - """ - Mark task as started (In Progress). - Called when planner session begins. - """ - success = await update_linear_status(spec_dir, STATUS_IN_PROGRESS) - if success: - await add_linear_comment(spec_dir, "Build started - planning phase initiated") - return success - - -async def linear_subtask_completed( - spec_dir: Path, - subtask_id: str, - completed_count: int, - total_count: int, -) -> bool: - """ - Record subtask completion as a comment. - Called after each successful coder session. - """ - comment = f"Completed {subtask_id} ({completed_count}/{total_count} subtasks done)" - return await add_linear_comment(spec_dir, comment) - - -async def linear_subtask_failed( - spec_dir: Path, - subtask_id: str, - attempt: int, - error_summary: str, -) -> bool: - """ - Record subtask failure as a comment. - Called after failed coder session. - """ - comment = f"Subtask {subtask_id} failed (attempt {attempt}): {error_summary[:200]}" - return await add_linear_comment(spec_dir, comment) - - -async def linear_build_complete(spec_dir: Path) -> bool: - """ - Record build completion, moving to QA. - Called when all subtasks are completed. - """ - comment = "All subtasks completed - moving to QA validation" - return await add_linear_comment(spec_dir, comment) - - -async def linear_qa_started(spec_dir: Path) -> bool: - """ - Mark task as In Review for QA phase. - Called when QA validation loop starts. - """ - success = await update_linear_status(spec_dir, STATUS_IN_REVIEW) - if success: - await add_linear_comment(spec_dir, "QA validation started") - return success - - -async def linear_qa_approved(spec_dir: Path) -> bool: - """ - Record QA approval (stays In Review for human). - Called when QA approves the build. - """ - comment = "QA approved - awaiting human review for merge" - return await add_linear_comment(spec_dir, comment) - - -async def linear_qa_rejected( - spec_dir: Path, - issues_count: int, - iteration: int, -) -> bool: - """ - Record QA rejection. - Called when QA rejects the build. - """ - comment = f"QA iteration {iteration}: Found {issues_count} issues - applying fixes" - return await add_linear_comment(spec_dir, comment) - - -async def linear_qa_max_iterations(spec_dir: Path, iterations: int) -> bool: - """ - Record QA max iterations reached. - Called when QA loop exhausts retries. - """ - comment = f"QA reached max iterations ({iterations}) - needs human intervention" - return await add_linear_comment(spec_dir, comment) - - -async def linear_task_stuck( - spec_dir: Path, - subtask_id: str, - attempt_count: int, -) -> bool: - """ - Record that a subtask is stuck. - Called when subtask exceeds retry limit. - """ - comment = f"Subtask {subtask_id} is STUCK after {attempt_count} attempts - needs human review" - return await add_linear_comment(spec_dir, comment) +"""Backward compatibility shim - import from integrations.linear.updater instead.""" +from integrations.linear.updater import * diff --git a/auto-claude/memory.py b/auto-claude/memory/main.py similarity index 100% rename from auto-claude/memory.py rename to auto-claude/memory/main.py diff --git a/auto-claude/merge/file_evolution/storage.py b/auto-claude/merge/file_evolution/storage.py index e5700408..a3538cd2 100644 --- a/auto-claude/merge/file_evolution/storage.py +++ b/auto-claude/merge/file_evolution/storage.py @@ -175,7 +175,10 @@ class EvolutionStorage: path = Path(file_path) if path.is_absolute(): try: - return str(path.relative_to(self.project_dir)) + # Resolve both paths to handle symlinks (e.g., /var -> /private/var on macOS) + resolved_path = path.resolve() + return str(resolved_path.relative_to(self.project_dir)) except ValueError: + # Path is not under project_dir, return as-is return str(path) return str(path) diff --git a/auto-claude/planner.py b/auto-claude/planner_lib/main.py similarity index 100% rename from auto-claude/planner.py rename to auto-claude/planner_lib/main.py diff --git a/auto-claude/prediction.py b/auto-claude/prediction/main.py similarity index 100% rename from auto-claude/prediction.py rename to auto-claude/prediction/main.py diff --git a/auto-claude/prediction_old.py b/auto-claude/prediction_old.py deleted file mode 100644 index fad813be..00000000 --- a/auto-claude/prediction_old.py +++ /dev/null @@ -1,710 +0,0 @@ -#!/usr/bin/env python3 -""" -Predictive Bug Prevention -========================== - -Generates pre-implementation checklists to prevent common bugs BEFORE they happen. -Uses historical data from memory system and pattern analysis to predict likely issues. - -The key insight: Most bugs are predictable based on: -1. Type of work (API, frontend, database, etc.) -2. Past failures in similar subtasks -3. Known gotchas in this codebase -4. Missing integration points - -Usage: - from prediction import BugPredictor - - predictor = BugPredictor(spec_dir) - checklist = predictor.generate_checklist(subtask) - markdown = predictor.format_checklist_markdown(checklist) -""" - -import json -import re -from dataclasses import dataclass, field -from pathlib import Path - - -@dataclass -class PredictedIssue: - """A potential issue that might occur during implementation.""" - - category: str # "integration", "pattern", "edge_case", "security", "performance" - description: str - likelihood: str # "high", "medium", "low" - prevention: str # How to avoid it - - def to_dict(self) -> dict: - return { - "category": self.category, - "description": self.description, - "likelihood": self.likelihood, - "prevention": self.prevention, - } - - -@dataclass -class PreImplementationChecklist: - """Complete checklist for a subtask before implementation.""" - - subtask_id: str - subtask_description: str - predicted_issues: list[PredictedIssue] = field(default_factory=list) - patterns_to_follow: list[str] = field(default_factory=list) - files_to_reference: list[str] = field(default_factory=list) - common_mistakes: list[str] = field(default_factory=list) - verification_reminders: list[str] = field(default_factory=list) - - -class BugPredictor: - """Predicts likely bugs and generates pre-implementation checklists.""" - - def __init__(self, spec_dir: Path): - """ - Initialize the bug predictor. - - Args: - spec_dir: Path to the spec directory (e.g., auto-claude/specs/001-feature/) - """ - self.spec_dir = Path(spec_dir) - self.memory_dir = self.spec_dir / "memory" - self.gotchas_file = self.memory_dir / "gotchas.md" - self.patterns_file = self.memory_dir / "patterns.md" - self.history_file = self.memory_dir / "attempt_history.json" - - # Common issue patterns by work type - self.COMMON_ISSUES = self._get_common_issues() - - def _get_common_issues(self) -> dict[str, list[PredictedIssue]]: - """Get common issue patterns by work type.""" - return { - "api_endpoint": [ - PredictedIssue( - "integration", - "CORS configuration missing or incorrect", - "high", - "Check existing CORS setup in similar endpoints and ensure new routes are included", - ), - PredictedIssue( - "security", - "Authentication middleware not applied", - "high", - "Verify auth decorator is applied if endpoint requires authentication", - ), - PredictedIssue( - "pattern", - "Response format doesn't match API conventions", - "medium", - 'Check existing endpoints for response structure (e.g., {"data": ..., "error": ...})', - ), - PredictedIssue( - "edge_case", - "Missing input validation", - "high", - "Add validation for all user inputs to prevent invalid data and SQL injection", - ), - PredictedIssue( - "edge_case", - "Error handling not comprehensive", - "medium", - "Handle edge cases: missing fields, invalid types, database errors, etc.", - ), - ], - "database_model": [ - PredictedIssue( - "integration", - "Database migration not created or run", - "high", - "Create migration after model changes and run db upgrade before testing", - ), - PredictedIssue( - "pattern", - "Field naming doesn't match conventions", - "medium", - "Check existing models for naming style (snake_case, timestamps, etc.)", - ), - PredictedIssue( - "edge_case", - "Missing indexes on frequently queried fields", - "low", - "Add indexes for foreign keys and fields used in WHERE clauses", - ), - PredictedIssue( - "pattern", - "Relationship configuration incorrect", - "medium", - "Check existing relationships for backref and cascade patterns", - ), - ], - "frontend_component": [ - PredictedIssue( - "integration", - "API client not used correctly", - "high", - "Use existing ApiClient or hook pattern, don't call fetch() directly", - ), - PredictedIssue( - "pattern", - "State management doesn't follow conventions", - "medium", - "Follow existing hook patterns (useState, useEffect, custom hooks)", - ), - PredictedIssue( - "edge_case", - "Loading and error states not handled", - "high", - "Show loading indicator during async operations and display errors to users", - ), - PredictedIssue( - "pattern", - "Styling doesn't match design system", - "low", - "Use existing CSS classes or styled components from the design system", - ), - PredictedIssue( - "edge_case", - "Form validation missing", - "medium", - "Add client-side validation before submission and show helpful error messages", - ), - ], - "celery_task": [ - PredictedIssue( - "integration", - "Task not registered with Celery app", - "high", - "Import task in celery app initialization or __init__.py", - ), - PredictedIssue( - "pattern", - "Arguments not JSON-serializable", - "high", - "Use only JSON-serializable arguments (no objects, use IDs instead)", - ), - PredictedIssue( - "edge_case", - "Retry logic not implemented", - "medium", - "Add retry decorator for network/external service failures", - ), - PredictedIssue( - "integration", - "Task not called from correct location", - "medium", - "Call with .delay() or .apply_async() after database commit", - ), - ], - "authentication": [ - PredictedIssue( - "security", - "Password not hashed", - "high", - "Use bcrypt or similar for password hashing, never store plaintext", - ), - PredictedIssue( - "security", - "Token not validated properly", - "high", - "Verify token signature and expiration on every request", - ), - PredictedIssue( - "security", - "Session not invalidated on logout", - "medium", - "Clear session/token on logout and after password changes", - ), - ], - "database_query": [ - PredictedIssue( - "performance", - "N+1 query problem", - "medium", - "Use eager loading (joinedload/selectinload) for relationships", - ), - PredictedIssue( - "security", - "SQL injection vulnerability", - "high", - "Use parameterized queries, never concatenate user input into SQL", - ), - PredictedIssue( - "edge_case", - "Large result sets not paginated", - "medium", - "Add pagination for queries that could return many results", - ), - ], - "file_upload": [ - PredictedIssue( - "security", - "File type not validated", - "high", - "Validate file extension and MIME type, don't trust user input", - ), - PredictedIssue( - "security", - "File size not limited", - "high", - "Set maximum file size to prevent DoS attacks", - ), - PredictedIssue( - "edge_case", - "Uploaded files not cleaned up on error", - "low", - "Use try/finally or context managers to ensure cleanup", - ), - ], - } - - def load_known_gotchas(self) -> list[str]: - """Load gotchas from previous sessions.""" - if not self.gotchas_file.exists(): - return [] - - gotchas = [] - content = self.gotchas_file.read_text() - - # Parse markdown list items - for line in content.split("\n"): - line = line.strip() - if line.startswith("-") or line.startswith("*"): - gotcha = line.lstrip("-*").strip() - if gotcha: - gotchas.append(gotcha) - - return gotchas - - def load_known_patterns(self) -> list[str]: - """Load successful patterns from previous sessions.""" - if not self.patterns_file.exists(): - return [] - - patterns = [] - content = self.patterns_file.read_text() - - # Parse markdown sections - current_pattern = None - for line in content.split("\n"): - line = line.strip() - if line.startswith("##"): - # Pattern heading - current_pattern = line.lstrip("#").strip() - elif line and current_pattern: - # Pattern detail - if line.startswith("-") or line.startswith("*"): - detail = line.lstrip("-*").strip() - patterns.append(f"{current_pattern}: {detail}") - - return patterns - - def load_attempt_history(self) -> list[dict]: - """Load historical subtask attempts.""" - if not self.history_file.exists(): - return [] - - try: - with open(self.history_file) as f: - history = json.load(f) - return history.get("attempts", []) - except (OSError, json.JSONDecodeError): - return [] - - def _detect_work_type(self, subtask: dict) -> list[str]: - """ - Detect what type of work this subtask involves. - - Returns a list of work types (e.g., ["api_endpoint", "database_model"]) - """ - work_types = [] - - description = subtask.get("description", "").lower() - files = subtask.get("files_to_modify", []) + subtask.get("files_to_create", []) - service = subtask.get("service", "").lower() - - # API endpoint detection - if any( - kw in description - for kw in ["endpoint", "api", "route", "request", "response"] - ): - work_types.append("api_endpoint") - if any("routes" in f or "api" in f for f in files): - work_types.append("api_endpoint") - - # Database model detection - if any( - kw in description for kw in ["model", "database", "migration", "schema"] - ): - work_types.append("database_model") - if any("models" in f or "migration" in f for f in files): - work_types.append("database_model") - - # Frontend component detection - if service in ["frontend", "web", "ui"]: - work_types.append("frontend_component") - if any(f.endswith((".tsx", ".jsx", ".vue", ".svelte")) for f in files): - work_types.append("frontend_component") - - # Celery task detection - if "celery" in description or "task" in description or "worker" in service: - work_types.append("celery_task") - if any("task" in f for f in files): - work_types.append("celery_task") - - # Authentication detection - if any( - kw in description - for kw in ["auth", "login", "password", "token", "session"] - ): - work_types.append("authentication") - - # Database query detection - if any(kw in description for kw in ["query", "search", "filter", "fetch"]): - work_types.append("database_query") - - # File upload detection - if any(kw in description for kw in ["upload", "file", "image", "attachment"]): - work_types.append("file_upload") - - return work_types - - def analyze_subtask_risks(self, subtask: dict) -> list[PredictedIssue]: - """ - Predict likely issues for a subtask based on work type and history. - - Args: - subtask: Subtask dictionary with keys like description, files_to_modify, etc. - - Returns: - List of predicted issues - """ - issues = [] - - # Get work types - work_types = self._detect_work_type(subtask) - - # Add common issues for detected work types - for work_type in work_types: - if work_type in self.COMMON_ISSUES: - issues.extend(self.COMMON_ISSUES[work_type]) - - # Add issues from similar past failures - similar_failures = self.get_similar_past_failures(subtask) - for failure in similar_failures: - failure_reason = failure.get("failure_reason", "") - if failure_reason: - issues.append( - PredictedIssue( - "pattern", - f"Similar subtask failed: {failure_reason}", - "high", - "Review the failed attempt in memory/attempt_history.json", - ) - ) - - # Deduplicate by description - seen = set() - unique_issues = [] - for issue in issues: - if issue.description not in seen: - seen.add(issue.description) - unique_issues.append(issue) - - # Sort by likelihood (high first) - likelihood_order = {"high": 0, "medium": 1, "low": 2} - unique_issues.sort(key=lambda i: likelihood_order.get(i.likelihood, 3)) - - # Return top 7 most relevant - return unique_issues[:7] - - def get_similar_past_failures(self, subtask: dict) -> list[dict]: - """ - Find subtasks similar to this one that failed before. - - Args: - subtask: Current subtask to analyze - - Returns: - List of similar failed attempts from history - """ - history = self.load_attempt_history() - if not history: - return [] - - subtask_desc = subtask.get("description", "").lower() - subtask_files = set( - subtask.get("files_to_modify", []) + subtask.get("files_to_create", []) - ) - - similar = [] - for attempt in history: - # Only look at failures - if attempt.get("status") != "failed": - continue - - # Check similarity - attempt_desc = attempt.get("subtask_description", "").lower() - attempt_files = set(attempt.get("files_modified", [])) - - # Calculate similarity score - score = 0 - - # Description keyword overlap - subtask_keywords = set(re.findall(r"\w+", subtask_desc)) - attempt_keywords = set(re.findall(r"\w+", attempt_desc)) - common_keywords = subtask_keywords & attempt_keywords - if common_keywords: - score += len(common_keywords) - - # File overlap - common_files = subtask_files & attempt_files - if common_files: - score += len(common_files) * 3 # Files are stronger signal - - if score > 2: # Threshold for similarity - similar.append( - { - "subtask_id": attempt.get("subtask_id"), - "description": attempt.get("subtask_description"), - "failure_reason": attempt.get("error_message", "Unknown error"), - "similarity_score": score, - } - ) - - # Sort by similarity - similar.sort(key=lambda x: x["similarity_score"], reverse=True) - return similar[:3] # Top 3 similar failures - - def generate_checklist(self, subtask: dict) -> PreImplementationChecklist: - """ - Generate a complete pre-implementation checklist for a subtask. - - Args: - subtask: Subtask dictionary from implementation_plan.json - - Returns: - PreImplementationChecklist ready for formatting - """ - checklist = PreImplementationChecklist( - subtask_id=subtask.get("id", "unknown"), - subtask_description=subtask.get("description", ""), - ) - - # Predict issues - checklist.predicted_issues = self.analyze_subtask_risks(subtask) - - # Load patterns to follow - known_patterns = self.load_known_patterns() - # Filter to most relevant patterns based on subtask - work_types = self._detect_work_type(subtask) - relevant_patterns = [] - for pattern in known_patterns: - pattern_lower = pattern.lower() - # Check if pattern mentions any work type - if any(wt.replace("_", " ") in pattern_lower for wt in work_types): - relevant_patterns.append(pattern) - # Or if it mentions any file being modified - elif any( - f.split("/")[-1] in pattern_lower - for f in subtask.get("files_to_modify", []) - ): - relevant_patterns.append(pattern) - - checklist.patterns_to_follow = relevant_patterns[:5] # Top 5 - - # Files to reference (from subtask's patterns_from) - checklist.files_to_reference = subtask.get("patterns_from", []) - - # Common mistakes (gotchas from memory) - gotchas = self.load_known_gotchas() - # Filter to relevant gotchas - relevant_gotchas = [] - for gotcha in gotchas: - gotcha_lower = gotcha.lower() - # Check relevance to current subtask - if any( - kw in gotcha_lower - for kw in subtask.get("description", "").lower().split() - ): - relevant_gotchas.append(gotcha) - elif any(wt.replace("_", " ") in gotcha_lower for wt in work_types): - relevant_gotchas.append(gotcha) - - checklist.common_mistakes = relevant_gotchas[:5] # Top 5 - - # Verification reminders - verification = subtask.get("verification", {}) - if verification: - ver_type = verification.get("type") - if ver_type == "api": - checklist.verification_reminders.append( - f"Test API endpoint: {verification.get('method', 'GET')} {verification.get('url', '')}" - ) - elif ver_type == "browser": - checklist.verification_reminders.append( - f"Test in browser: {verification.get('scenario', 'Check functionality')}" - ) - elif ver_type == "command": - checklist.verification_reminders.append( - f"Run command: {verification.get('run', '')}" - ) - - return checklist - - def format_checklist_markdown(self, checklist: PreImplementationChecklist) -> str: - """ - Format checklist as markdown for agent consumption. - - Args: - checklist: PreImplementationChecklist to format - - Returns: - Markdown-formatted checklist string - """ - lines = [] - - lines.append( - f"## Pre-Implementation Checklist: {checklist.subtask_description}" - ) - lines.append("") - - # Predicted issues - if checklist.predicted_issues: - lines.append("### Predicted Issues (based on similar work)") - lines.append("") - lines.append("| Issue | Likelihood | Prevention |") - lines.append("|-------|------------|------------|") - - for issue in checklist.predicted_issues: - # Escape pipe characters in content - desc = issue.description.replace("|", "\\|") - prev = issue.prevention.replace("|", "\\|") - lines.append(f"| {desc} | {issue.likelihood.capitalize()} | {prev} |") - - lines.append("") - - # Patterns to follow - if checklist.patterns_to_follow: - lines.append("### Patterns to Follow") - lines.append("") - lines.append("From previous sessions and codebase analysis:") - for pattern in checklist.patterns_to_follow: - lines.append(f"- {pattern}") - lines.append("") - - # Known gotchas - if checklist.common_mistakes: - lines.append("### Known Gotchas in This Codebase") - lines.append("") - lines.append("From memory/gotchas.md:") - for gotcha in checklist.common_mistakes: - lines.append(f"- [ ] {gotcha}") - lines.append("") - - # Files to reference - if checklist.files_to_reference: - lines.append("### Files to Reference") - lines.append("") - for file_path in checklist.files_to_reference: - # Extract filename and suggest what to look for - filename = file_path.split("/")[-1] - lines.append( - f"- `{file_path}` - Check for similar patterns and code style" - ) - lines.append("") - - # Verification reminders - if checklist.verification_reminders: - lines.append("### Verification Reminders") - lines.append("") - for reminder in checklist.verification_reminders: - lines.append(f"- [ ] {reminder}") - lines.append("") - - # Pre-implementation checklist - lines.append("### Before You Start Implementing") - lines.append("") - lines.append("- [ ] I have read and understood all predicted issues above") - lines.append( - "- [ ] I have reviewed the reference files to understand existing patterns" - ) - lines.append("- [ ] I know how to prevent the high-likelihood issues") - lines.append("- [ ] I understand the verification requirements") - lines.append("") - - return "\n".join(lines) - - -def generate_subtask_checklist(spec_dir: Path, subtask: dict) -> str: - """ - Convenience function to generate and format a checklist for a subtask. - - Args: - spec_dir: Path to spec directory - subtask: Subtask dictionary - - Returns: - Markdown-formatted checklist - """ - predictor = BugPredictor(spec_dir) - checklist = predictor.generate_checklist(subtask) - return predictor.format_checklist_markdown(checklist) - - -# CLI for testing -if __name__ == "__main__": - import sys - - if len(sys.argv) < 2: - print("Usage: python prediction.py [--demo]") - print(" python prediction.py auto-claude/specs/001-feature/") - sys.exit(1) - - spec_dir = Path(sys.argv[1]) - - if "--demo" in sys.argv: - # Demo with sample subtask - demo_subtask = { - "id": "avatar-endpoint", - "description": "POST /api/users/avatar endpoint for uploading user avatars", - "service": "backend", - "files_to_modify": ["app/routes/users.py"], - "files_to_create": [], - "patterns_from": ["app/routes/profile.py"], - "verification": { - "type": "api", - "method": "POST", - "url": "/api/users/avatar", - "expect_status": 200, - }, - } - - checklist_md = generate_subtask_checklist(spec_dir, demo_subtask) - print(checklist_md) - else: - # Load from implementation plan - plan_file = spec_dir / "implementation_plan.json" - if not plan_file.exists(): - print(f"Error: No implementation_plan.json found in {spec_dir}") - sys.exit(1) - - with open(plan_file) as f: - plan = json.load(f) - - # Find first pending subtask - subtask = None - for phase in plan.get("phases", []): - for c in phase.get("subtasks", []): - if c.get("status") == "pending": - subtask = c - break - if subtask: - break - - if not subtask: - print("No pending subtasks found") - sys.exit(0) - - # Generate checklist - checklist_md = generate_subtask_checklist(spec_dir, subtask) - print(checklist_md) diff --git a/auto-claude/progress.py b/auto-claude/progress.py index 1e416045..329c2847 100644 --- a/auto-claude/progress.py +++ b/auto-claude/progress.py @@ -1,467 +1,2 @@ -""" -Progress Tracking Utilities -=========================== - -Functions for tracking and displaying progress of the autonomous coding agent. -Uses subtask-based implementation plans (implementation_plan.json). - -Enhanced with colored output, icons, and better visual formatting. -""" - -import json -from pathlib import Path - -from ui import ( - Icons, - bold, - box, - highlight, - icon, - muted, - print_phase_status, - print_status, - progress_bar, - success, - warning, -) - - -def count_subtasks(spec_dir: Path) -> tuple[int, int]: - """ - Count completed and total subtasks in implementation_plan.json. - - Args: - spec_dir: Directory containing implementation_plan.json - - Returns: - (completed_count, total_count) - """ - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return 0, 0 - - try: - with open(plan_file) as f: - plan = json.load(f) - - total = 0 - completed = 0 - - for phase in plan.get("phases", []): - for subtask in phase.get("subtasks", []): - total += 1 - if subtask.get("status") == "completed": - completed += 1 - - return completed, total - except (OSError, json.JSONDecodeError): - return 0, 0 - - -def count_subtasks_detailed(spec_dir: Path) -> dict: - """ - Count subtasks by status. - - Returns: - Dict with completed, in_progress, pending, failed counts - """ - plan_file = spec_dir / "implementation_plan.json" - - result = { - "completed": 0, - "in_progress": 0, - "pending": 0, - "failed": 0, - "total": 0, - } - - if not plan_file.exists(): - return result - - try: - with open(plan_file) as f: - plan = json.load(f) - - for phase in plan.get("phases", []): - for subtask in phase.get("subtasks", []): - result["total"] += 1 - status = subtask.get("status", "pending") - if status in result: - result[status] += 1 - else: - result["pending"] += 1 - - return result - except (OSError, json.JSONDecodeError): - return result - - -def is_build_complete(spec_dir: Path) -> bool: - """ - Check if all subtasks are completed. - - Args: - spec_dir: Directory containing implementation_plan.json - - Returns: - True if all subtasks complete, False otherwise - """ - completed, total = count_subtasks(spec_dir) - return total > 0 and completed == total - - -def get_progress_percentage(spec_dir: Path) -> float: - """ - Get the progress as a percentage. - - Args: - spec_dir: Directory containing implementation_plan.json - - Returns: - Percentage of subtasks completed (0-100) - """ - completed, total = count_subtasks(spec_dir) - if total == 0: - return 0.0 - return (completed / total) * 100 - - -def print_session_header( - session_num: int, - is_planner: bool, - subtask_id: str = None, - subtask_desc: str = None, - phase_name: str = None, - attempt: int = 1, -) -> None: - """Print a formatted header for the session.""" - session_type = "PLANNER AGENT" if is_planner else "CODING AGENT" - session_icon = Icons.GEAR if is_planner else Icons.LIGHTNING - - content = [ - bold(f"{icon(session_icon)} SESSION {session_num}: {session_type}"), - ] - - if subtask_id: - content.append("") - subtask_line = f"{icon(Icons.SUBTASK)} Subtask: {highlight(subtask_id)}" - if subtask_desc: - # Truncate long descriptions - desc = subtask_desc[:50] + "..." if len(subtask_desc) > 50 else subtask_desc - subtask_line += f" - {desc}" - content.append(subtask_line) - - if phase_name: - content.append(f"{icon(Icons.PHASE)} Phase: {phase_name}") - - if attempt > 1: - content.append(warning(f"{icon(Icons.WARNING)} Attempt: {attempt}")) - - print() - print(box(content, width=70, style="heavy")) - print() - - -def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None: - """Print a summary of current progress with enhanced formatting.""" - completed, total = count_subtasks(spec_dir) - - if total > 0: - print() - # Progress bar - print(f"Progress: {progress_bar(completed, total, width=40)}") - - # Status message - if completed == total: - print_status("BUILD COMPLETE - All subtasks completed!", "success") - else: - remaining = total - completed - print_status(f"{remaining} subtasks remaining", "info") - - # Phase summary - try: - with open(spec_dir / "implementation_plan.json") as f: - plan = json.load(f) - - print("\nPhases:") - for phase in plan.get("phases", []): - phase_subtasks = phase.get("subtasks", []) - phase_completed = sum( - 1 for s in phase_subtasks if s.get("status") == "completed" - ) - phase_total = len(phase_subtasks) - phase_name = phase.get("name", phase.get("id", "Unknown")) - - if phase_completed == phase_total: - status = "complete" - elif phase_completed > 0 or any( - s.get("status") == "in_progress" for s in phase_subtasks - ): - status = "in_progress" - else: - # Check if blocked by dependencies - deps = phase.get("depends_on", []) - all_deps_complete = True - for dep_id in deps: - for p in plan.get("phases", []): - if p.get("id") == dep_id or p.get("phase") == dep_id: - p_subtasks = p.get("subtasks", []) - if not all( - s.get("status") == "completed" for s in p_subtasks - ): - all_deps_complete = False - break - status = "pending" if all_deps_complete else "blocked" - - print_phase_status(phase_name, phase_completed, phase_total, status) - - # Show next subtask if requested - if show_next and completed < total: - next_subtask = get_next_subtask(spec_dir) - if next_subtask: - print() - next_id = next_subtask.get("id", "unknown") - next_desc = next_subtask.get("description", "") - if len(next_desc) > 60: - next_desc = next_desc[:57] + "..." - print( - f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}" - ) - - except (OSError, json.JSONDecodeError): - pass - else: - print() - print_status("No implementation subtasks yet - planner needs to run", "pending") - - -def print_build_complete_banner(spec_dir: Path) -> None: - """Print a completion banner.""" - content = [ - success(f"{icon(Icons.SUCCESS)} BUILD COMPLETE!"), - "", - "All subtasks have been implemented successfully.", - "", - muted("Next steps:"), - f" 1. Review the {highlight('auto-claude/*')} branch", - " 2. Run manual tests", - " 3. Create a PR and merge to main", - ] - - print() - print(box(content, width=70, style="heavy")) - print() - - -def print_paused_banner( - spec_dir: Path, - spec_name: str, - has_worktree: bool = False, -) -> None: - """Print a paused banner with resume instructions.""" - completed, total = count_subtasks(spec_dir) - - content = [ - warning(f"{icon(Icons.PAUSE)} BUILD PAUSED"), - "", - f"Progress saved: {completed}/{total} subtasks complete", - ] - - if has_worktree: - content.append("") - content.append(muted("Your build is in a separate workspace and is safe.")) - - print() - print(box(content, width=70, style="heavy")) - - -def get_plan_summary(spec_dir: Path) -> dict: - """ - Get a detailed summary of implementation plan status. - - Args: - spec_dir: Directory containing implementation_plan.json - - Returns: - Dictionary with plan statistics - """ - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return { - "workflow_type": None, - "total_phases": 0, - "total_subtasks": 0, - "completed_subtasks": 0, - "pending_subtasks": 0, - "in_progress_subtasks": 0, - "failed_subtasks": 0, - "phases": [], - } - - try: - with open(plan_file) as f: - plan = json.load(f) - - summary = { - "workflow_type": plan.get("workflow_type"), - "total_phases": len(plan.get("phases", [])), - "total_subtasks": 0, - "completed_subtasks": 0, - "pending_subtasks": 0, - "in_progress_subtasks": 0, - "failed_subtasks": 0, - "phases": [], - } - - for phase in plan.get("phases", []): - phase_info = { - "id": phase.get("id"), - "phase": phase.get("phase"), - "name": phase.get("name"), - "depends_on": phase.get("depends_on", []), - "subtasks": [], - "completed": 0, - "total": 0, - } - - for subtask in phase.get("subtasks", []): - status = subtask.get("status", "pending") - summary["total_subtasks"] += 1 - phase_info["total"] += 1 - - if status == "completed": - summary["completed_subtasks"] += 1 - phase_info["completed"] += 1 - elif status == "in_progress": - summary["in_progress_subtasks"] += 1 - elif status == "failed": - summary["failed_subtasks"] += 1 - else: - summary["pending_subtasks"] += 1 - - phase_info["subtasks"].append( - { - "id": subtask.get("id"), - "description": subtask.get("description"), - "status": status, - "service": subtask.get("service"), - } - ) - - summary["phases"].append(phase_info) - - return summary - - except (OSError, json.JSONDecodeError): - return { - "workflow_type": None, - "total_phases": 0, - "total_subtasks": 0, - "completed_subtasks": 0, - "pending_subtasks": 0, - "in_progress_subtasks": 0, - "failed_subtasks": 0, - "phases": [], - } - - -def get_current_phase(spec_dir: Path) -> dict | None: - """Get the current phase being worked on.""" - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return None - - try: - with open(plan_file) as f: - plan = json.load(f) - - for phase in plan.get("phases", []): - subtasks = phase.get("subtasks", []) - # Phase is current if it has incomplete subtasks and dependencies are met - has_incomplete = any(s.get("status") != "completed" for s in subtasks) - if has_incomplete: - return { - "id": phase.get("id"), - "phase": phase.get("phase"), - "name": phase.get("name"), - "completed": sum( - 1 for s in subtasks if s.get("status") == "completed" - ), - "total": len(subtasks), - } - - return None - - except (OSError, json.JSONDecodeError): - return None - - -def get_next_subtask(spec_dir: Path) -> dict | None: - """ - Find the next subtask to work on, respecting phase dependencies. - - Args: - spec_dir: Directory containing implementation_plan.json - - Returns: - The next subtask dict to work on, or None if all complete - """ - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return None - - try: - with open(plan_file) as f: - plan = json.load(f) - - phases = plan.get("phases", []) - - # Build a map of phase completion - phase_complete = {} - for phase in phases: - phase_id = phase.get("id") or phase.get("phase") - subtasks = phase.get("subtasks", []) - phase_complete[phase_id] = all( - s.get("status") == "completed" for s in subtasks - ) - - # Find next available subtask - for phase in phases: - phase_id = phase.get("id") or phase.get("phase") - depends_on = phase.get("depends_on", []) - - # Check if dependencies are satisfied - deps_satisfied = all(phase_complete.get(dep, False) for dep in depends_on) - if not deps_satisfied: - continue - - # Find first pending subtask in this phase - for subtask in phase.get("subtasks", []): - if subtask.get("status") == "pending": - return { - "phase_id": phase_id, - "phase_name": phase.get("name"), - "phase_num": phase.get("phase"), - **subtask, - } - - return None - - except (OSError, json.JSONDecodeError): - return None - - -def format_duration(seconds: float) -> str: - """Format a duration in human-readable form.""" - if seconds < 60: - return f"{seconds:.0f}s" - elif seconds < 3600: - minutes = seconds / 60 - return f"{minutes:.1f}m" - else: - hours = seconds / 3600 - return f"{hours:.1f}h" +"""Backward compatibility shim - import from core.progress instead.""" +from core.progress import * diff --git a/auto-claude/prompt_generator.py b/auto-claude/prompt_generator.py index 15d2bc9b..da412aa6 100644 --- a/auto-claude/prompt_generator.py +++ b/auto-claude/prompt_generator.py @@ -1,378 +1,2 @@ -""" -Prompt Generator -================ - -Generates minimal, focused prompts for each subtask. -Instead of a 900-line mega-prompt, each subtask gets a tailored ~100-line prompt -with only the context it needs. - -This approach: -- Reduces token usage by ~80% -- Keeps the agent focused on ONE task -- Moves bookkeeping to Python orchestration -""" - -import json -from pathlib import Path - - -def get_relative_spec_path(spec_dir: Path, project_dir: Path) -> str: - """ - Get the spec directory path relative to the project/working directory. - - This ensures the AI gets a usable path regardless of absolute locations. - - Args: - spec_dir: Absolute path to spec directory - project_dir: Absolute path to project/working directory - - Returns: - Relative path string (e.g., "./auto-claude/specs/003-new-spec") - """ - try: - # Try to make path relative to project_dir - relative = spec_dir.relative_to(project_dir) - return f"./{relative}" - except ValueError: - # If spec_dir is not under project_dir, return the name only - # This shouldn't happen if workspace.py correctly copies spec files - return f"./auto-claude/specs/{spec_dir.name}" - - -def generate_environment_context(project_dir: Path, spec_dir: Path) -> str: - """ - Generate environment context header for prompts. - - This explicitly tells the AI where it is working, preventing path confusion. - - Args: - project_dir: The working directory for the AI - spec_dir: The spec directory (may be absolute or relative) - - Returns: - Markdown string with environment context - """ - relative_spec = get_relative_spec_path(spec_dir, project_dir) - - return f"""## YOUR ENVIRONMENT - -**Working Directory:** `{project_dir}` -**Spec Location:** `{relative_spec}/` - -Your filesystem is restricted to your working directory. All file paths should be -relative to this location. Do NOT use absolute paths. - -**Important Files:** -- Spec: `{relative_spec}/spec.md` -- Plan: `{relative_spec}/implementation_plan.json` -- Progress: `{relative_spec}/build-progress.txt` -- Context: `{relative_spec}/context.json` - ---- - -""" - - -def generate_subtask_prompt( - spec_dir: Path, - project_dir: Path, - subtask: dict, - phase: dict, - attempt_count: int = 0, - recovery_hints: list[str] | None = None, -) -> str: - """ - Generate a minimal, focused prompt for implementing a single subtask. - - Args: - spec_dir: Directory containing spec files - project_dir: Root project directory (working directory) - subtask: The subtask to implement - phase: The phase containing this subtask - attempt_count: Number of previous attempts (for retry context) - recovery_hints: Hints from previous failed attempts - - Returns: - A focused prompt string (~100 lines instead of 900) - """ - subtask_id = subtask.get("id", "unknown") - description = subtask.get("description", "No description") - service = subtask.get("service", "all") - files_to_modify = subtask.get("files_to_modify", []) - files_to_create = subtask.get("files_to_create", []) - patterns_from = subtask.get("patterns_from", []) - verification = subtask.get("verification", {}) - - # Get relative spec path - relative_spec = get_relative_spec_path(spec_dir, project_dir) - - # Build the prompt - sections = [] - - # Environment context first - sections.append(generate_environment_context(project_dir, spec_dir)) - - # Header - sections.append(f"""# Subtask Implementation Task - -**Subtask ID:** `{subtask_id}` -**Phase:** {phase.get("name", phase.get("id", "Unknown"))} -**Service:** {service} - -## Description - -{description} -""") - - # Recovery context if this is a retry - if attempt_count > 0: - sections.append(f""" -## ⚠️ RETRY ATTEMPT ({attempt_count + 1}) - -This subtask has been attempted {attempt_count} time(s) before without success. -You MUST use a DIFFERENT approach than previous attempts. -""") - if recovery_hints: - sections.append("**Previous attempt insights:**") - for hint in recovery_hints: - sections.append(f"- {hint}") - sections.append("") - - # Files section - sections.append("## Files\n") - - if files_to_modify: - sections.append("**Files to Modify:**") - for f in files_to_modify: - sections.append(f"- `{f}`") - sections.append("") - - if files_to_create: - sections.append("**Files to Create:**") - for f in files_to_create: - sections.append(f"- `{f}`") - sections.append("") - - if patterns_from: - sections.append("**Pattern Files (study these first):**") - for f in patterns_from: - sections.append(f"- `{f}`") - sections.append("") - - # Verification - sections.append("## Verification\n") - v_type = verification.get("type", "manual") - - if v_type == "command": - sections.append(f"""Run this command to verify: -```bash -{verification.get("command", 'echo "No command specified"')} -``` -Expected: {verification.get("expected", "Success")} -""") - elif v_type == "api": - method = verification.get("method", "GET") - url = verification.get("url", "http://localhost") - body = verification.get("body", {}) - expected_status = verification.get("expected_status", 200) - sections.append(f"""Test the API endpoint: -```bash -curl -X {method} {url} -H "Content-Type: application/json" {f"-d '{json.dumps(body)}'" if body else ""} -``` -Expected status: {expected_status} -""") - elif v_type == "browser": - url = verification.get("url", "http://localhost:3000") - checks = verification.get("checks", []) - sections.append(f"""Open in browser: {url} - -Verify:""") - for check in checks: - sections.append(f"- [ ] {check}") - sections.append("") - elif v_type == "e2e": - steps = verification.get("steps", []) - sections.append("End-to-end verification steps:") - for i, step in enumerate(steps, 1): - sections.append(f"{i}. {step}") - sections.append("") - else: - instructions = verification.get("instructions", "Manual verification required") - sections.append(f"**Manual Verification:**\n{instructions}\n") - - # Instructions - sections.append(f"""## Instructions - -1. **Read the pattern files** to understand code style and conventions -2. **Read the files to modify** (if any) to understand current implementation -3. **Implement the subtask** following the patterns exactly -4. **Run verification** and fix any issues -5. **Commit your changes:** - ```bash - git add . - git commit -m "auto-claude: {subtask_id} - {description[:50]}" - ``` -6. **Update the plan** - set this subtask's status to "completed" in implementation_plan.json - -## Quality Checklist - -Before marking complete, verify: -- [ ] Follows patterns from reference files -- [ ] No console.log/print debugging statements -- [ ] Error handling in place -- [ ] Verification passes -- [ ] Clean commit with descriptive message - -## Important - -- Focus ONLY on this subtask - don't modify unrelated code -- If verification fails, FIX IT before committing -- If you encounter a blocker, document it in build-progress.txt -""") - - # Note: Linear updates are now handled by Python orchestrator via linear_updater.py - # Agents no longer need to call Linear MCP tools directly - - return "\n".join(sections) - - -def generate_planner_prompt(spec_dir: Path, project_dir: Path | None = None) -> str: - """ - Generate the planner prompt (used only once at start). - This is a simplified version that focuses on plan creation. - - Args: - spec_dir: Directory containing spec.md - project_dir: Working directory (for relative paths) - - Returns: - Planner prompt string - """ - # Load the full planner prompt from file - prompts_dir = Path(__file__).parent / "prompts" - planner_file = prompts_dir / "planner.md" - - if planner_file.exists(): - prompt = planner_file.read_text() - else: - prompt = ( - "Read spec.md and create implementation_plan.json with phases and subtasks." - ) - - # Use project_dir for relative paths, or infer from spec_dir - if project_dir is None: - # Infer: spec_dir is typically project/auto-claude/specs/XXX - project_dir = spec_dir.parent.parent.parent - - # Get relative path for spec directory - relative_spec = get_relative_spec_path(spec_dir, project_dir) - - # Build header with environment context - header = generate_environment_context(project_dir, spec_dir) - - # Add spec-specific instructions - header += f"""## SPEC LOCATION - -Your spec file is located at: `{relative_spec}/spec.md` - -Store all build artifacts in this spec directory: -- `{relative_spec}/implementation_plan.json` - Subtask-based implementation plan -- `{relative_spec}/build-progress.txt` - Progress notes -- `{relative_spec}/init.sh` - Environment setup script - -The project root is your current working directory. Implement code in the project root, -not in the spec directory. - ---- - -""" - # Note: Linear task creation and updates are now handled by Python orchestrator - # via linear_updater.py - agents no longer need Linear instructions in prompts - - return header + prompt - - -def load_subtask_context( - spec_dir: Path, - project_dir: Path, - subtask: dict, - max_file_lines: int = 200, -) -> dict: - """ - Load minimal context needed for a subtask. - - Args: - spec_dir: Spec directory - project_dir: Project root - subtask: The subtask being implemented - max_file_lines: Maximum lines to include per file - - Returns: - Dict with file contents and relevant context - """ - context = { - "patterns": {}, - "files_to_modify": {}, - "spec_excerpt": None, - } - - # Load pattern files (truncated) - for pattern_path in subtask.get("patterns_from", []): - full_path = project_dir / pattern_path - if full_path.exists(): - try: - lines = full_path.read_text().split("\n") - if len(lines) > max_file_lines: - content = "\n".join(lines[:max_file_lines]) - content += ( - f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)" - ) - else: - content = "\n".join(lines) - context["patterns"][pattern_path] = content - except Exception: - context["patterns"][pattern_path] = "(Could not read file)" - - # Load files to modify (truncated) - for file_path in subtask.get("files_to_modify", []): - full_path = project_dir / file_path - if full_path.exists(): - try: - lines = full_path.read_text().split("\n") - if len(lines) > max_file_lines: - content = "\n".join(lines[:max_file_lines]) - content += ( - f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)" - ) - else: - content = "\n".join(lines) - context["files_to_modify"][file_path] = content - except Exception: - context["files_to_modify"][file_path] = "(Could not read file)" - - return context - - -def format_context_for_prompt(context: dict) -> str: - """ - Format loaded context into a prompt section. - - Args: - context: Dict from load_subtask_context - - Returns: - Formatted string to append to prompt - """ - sections = [] - - if context.get("patterns"): - sections.append("## Reference Files (Patterns to Follow)\n") - for path, content in context["patterns"].items(): - sections.append(f"### `{path}`\n```\n{content}\n```\n") - - if context.get("files_to_modify"): - sections.append("## Current File Contents (To Modify)\n") - for path, content in context["files_to_modify"].items(): - sections.append(f"### `{path}`\n```\n{content}\n```\n") - - return "\n".join(sections) +"""Backward compatibility shim - import from prompts_pkg.prompt_generator instead.""" +from prompts_pkg.prompt_generator import * diff --git a/auto-claude/prompts.py b/auto-claude/prompts.py index fcda78fa..e1e21b2c 100644 --- a/auto-claude/prompts.py +++ b/auto-claude/prompts.py @@ -1,263 +1,2 @@ -""" -Prompt Loading Utilities -======================== - -Functions for loading agent prompts from markdown files. -""" - -import json -from pathlib import Path - -# Directory containing prompt files -PROMPTS_DIR = Path(__file__).parent / "prompts" - - -def get_planner_prompt(spec_dir: Path) -> str: - """ - Load the planner agent prompt with spec path injected. - The planner creates subtask-based implementation plans. - - Args: - spec_dir: Directory containing the spec.md file - - Returns: - The planner prompt content with spec path - """ - prompt_file = PROMPTS_DIR / "planner.md" - - if not prompt_file.exists(): - raise FileNotFoundError( - f"Planner prompt not found at {prompt_file}\n" - "Make sure the auto-claude/prompts/planner.md file exists." - ) - - prompt = prompt_file.read_text() - - # Inject spec directory information at the beginning - spec_context = f"""## SPEC LOCATION - -Your spec file is located at: `{spec_dir}/spec.md` - -Store all build artifacts in this spec directory: -- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan -- `{spec_dir}/build-progress.txt` - Progress notes -- `{spec_dir}/init.sh` - Environment setup script - -The project root is the parent of auto-claude/. Implement code in the project root, not in the spec directory. - ---- - -""" - return spec_context + prompt - - -def get_coding_prompt(spec_dir: Path) -> str: - """ - Load the coding agent prompt with spec path injected. - - Args: - spec_dir: Directory containing the spec.md and implementation_plan.json - - Returns: - The coding agent prompt content with spec path - """ - prompt_file = PROMPTS_DIR / "coder.md" - - if not prompt_file.exists(): - raise FileNotFoundError( - f"Coding prompt not found at {prompt_file}\n" - "Make sure the auto-claude/prompts/coder.md file exists." - ) - - prompt = prompt_file.read_text() - - spec_context = f"""## SPEC LOCATION - -Your spec and progress files are located at: -- Spec: `{spec_dir}/spec.md` -- Implementation plan: `{spec_dir}/implementation_plan.json` -- Progress notes: `{spec_dir}/build-progress.txt` -- Recovery context: `{spec_dir}/memory/attempt_history.json` - -The project root is the parent of auto-claude/. All code goes in the project root, not in the spec directory. - ---- - -""" - - # Check for recovery context (stuck subtasks, retry hints) - recovery_context = _get_recovery_context(spec_dir) - if recovery_context: - spec_context += recovery_context - - # Check for human input file - human_input_file = spec_dir / "HUMAN_INPUT.md" - if human_input_file.exists(): - human_input = human_input_file.read_text().strip() - if human_input: - spec_context += f"""## HUMAN INPUT (READ THIS FIRST!) - -The human has left you instructions. READ AND FOLLOW THESE CAREFULLY: - -{human_input} - -After addressing this input, you may delete or clear the HUMAN_INPUT.md file. - ---- - -""" - - return spec_context + prompt - - -def _get_recovery_context(spec_dir: Path) -> str: - """ - Get recovery context if there are failed attempts or stuck subtasks. - - Args: - spec_dir: Spec directory containing memory/ - - Returns: - Recovery context string or empty string - """ - import json - - attempt_history_file = spec_dir / "memory" / "attempt_history.json" - - if not attempt_history_file.exists(): - return "" - - try: - with open(attempt_history_file) as f: - history = json.load(f) - - # Check for stuck subtasks - stuck_subtasks = history.get("stuck_subtasks", []) - if stuck_subtasks: - context = """## ⚠️ RECOVERY ALERT - STUCK SUBTASKS DETECTED - -Some subtasks have been attempted multiple times without success. These subtasks need: -- A COMPLETELY DIFFERENT approach -- Possibly simpler implementation -- Or escalation to human if infeasible - -Stuck subtasks: -""" - for stuck in stuck_subtasks: - context += f"- {stuck['subtask_id']}: {stuck['reason']} ({stuck['attempt_count']} attempts)\n" - - context += "\nBefore working on any subtask, check memory/attempt_history.json for previous attempts!\n\n---\n\n" - return context - - # Check for subtasks with multiple attempts - subtasks_with_retries = [] - for subtask_id, subtask_data in history.get("subtasks", {}).items(): - attempts = subtask_data.get("attempts", []) - if len(attempts) > 1 and subtask_data.get("status") != "completed": - subtasks_with_retries.append((subtask_id, len(attempts))) - - if subtasks_with_retries: - context = """## ⚠️ RECOVERY CONTEXT - RETRY AWARENESS - -Some subtasks have been attempted before. When working on these: -1. READ memory/attempt_history.json for the specific subtask -2. See what approaches were tried -3. Use a DIFFERENT approach - -Subtasks with previous attempts: -""" - for subtask_id, attempt_count in subtasks_with_retries: - context += f"- {subtask_id}: {attempt_count} attempts\n" - - context += "\n---\n\n" - return context - - return "" - - except (OSError, json.JSONDecodeError): - return "" - - -def get_followup_planner_prompt(spec_dir: Path) -> str: - """ - Load the follow-up planner agent prompt with spec path and key files injected. - The follow-up planner adds new subtasks to an existing completed implementation plan. - - Args: - spec_dir: Directory containing the completed spec and implementation_plan.json - - Returns: - The follow-up planner prompt content with paths injected - """ - prompt_file = PROMPTS_DIR / "followup_planner.md" - - if not prompt_file.exists(): - raise FileNotFoundError( - f"Follow-up planner prompt not found at {prompt_file}\n" - "Make sure the auto-claude/prompts/followup_planner.md file exists." - ) - - prompt = prompt_file.read_text() - - # Inject spec directory information at the beginning - spec_context = f"""## SPEC LOCATION (FOLLOW-UP MODE) - -You are adding follow-up work to a **completed** spec. - -**Key files in this spec directory:** -- Spec: `{spec_dir}/spec.md` -- Follow-up request: `{spec_dir}/FOLLOWUP_REQUEST.md` (READ THIS FIRST!) -- Implementation plan: `{spec_dir}/implementation_plan.json` (APPEND to this, don't replace) -- Progress notes: `{spec_dir}/build-progress.txt` -- Context: `{spec_dir}/context.json` -- Memory: `{spec_dir}/memory/` - -**Important paths:** -- Spec directory: `{spec_dir}` -- Project root: Parent of auto-claude/ (where code should be implemented) - -**Your task:** -1. Read `{spec_dir}/FOLLOWUP_REQUEST.md` to understand what to add -2. Read `{spec_dir}/implementation_plan.json` to see existing phases/subtasks -3. ADD new phase(s) with pending subtasks to the existing plan -4. PRESERVE all existing subtasks and their statuses - ---- - -""" - return spec_context + prompt - - -def is_first_run(spec_dir: Path) -> bool: - """ - Check if this is the first run (no valid implementation plan with subtasks exists yet). - - The spec runner may create a skeleton implementation_plan.json with empty phases. - This function checks for actual phases with subtasks, not just file existence. - - Args: - spec_dir: Directory containing spec files - - Returns: - True if implementation_plan.json doesn't exist or has no subtasks - """ - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return True - - try: - with open(plan_file) as f: - plan = json.load(f) - - # Check if there are any phases with subtasks - phases = plan.get("phases", []) - if not phases: - return True - - # Check if any phase has subtasks - total_subtasks = sum(len(phase.get("subtasks", [])) for phase in phases) - return total_subtasks == 0 - except (OSError, json.JSONDecodeError): - # If we can't read the file, treat as first run - return True +"""Backward compatibility shim - import from prompts_pkg.prompts instead.""" +from prompts_pkg.prompts import * diff --git a/auto-claude/prompts_pkg/__init__.py b/auto-claude/prompts_pkg/__init__.py new file mode 100644 index 00000000..0047579d --- /dev/null +++ b/auto-claude/prompts_pkg/__init__.py @@ -0,0 +1,39 @@ +""" +Prompts Module +============== + +Prompt generation and templates for AI interactions. +""" + +# Import all functions from prompt_generator +from .prompt_generator import ( + get_relative_spec_path, + generate_environment_context, + generate_subtask_prompt, + generate_planner_prompt, + load_subtask_context, + format_context_for_prompt, +) + +# Import all functions from prompts +from .prompts import ( + get_planner_prompt, + get_coding_prompt, + get_followup_planner_prompt, + is_first_run, +) + +__all__ = [ + # prompt_generator functions + "get_relative_spec_path", + "generate_environment_context", + "generate_subtask_prompt", + "generate_planner_prompt", + "load_subtask_context", + "format_context_for_prompt", + # prompts functions + "get_planner_prompt", + "get_coding_prompt", + "get_followup_planner_prompt", + "is_first_run", +] diff --git a/auto-claude/prompts_pkg/prompt_generator.py b/auto-claude/prompts_pkg/prompt_generator.py new file mode 100644 index 00000000..15d2bc9b --- /dev/null +++ b/auto-claude/prompts_pkg/prompt_generator.py @@ -0,0 +1,378 @@ +""" +Prompt Generator +================ + +Generates minimal, focused prompts for each subtask. +Instead of a 900-line mega-prompt, each subtask gets a tailored ~100-line prompt +with only the context it needs. + +This approach: +- Reduces token usage by ~80% +- Keeps the agent focused on ONE task +- Moves bookkeeping to Python orchestration +""" + +import json +from pathlib import Path + + +def get_relative_spec_path(spec_dir: Path, project_dir: Path) -> str: + """ + Get the spec directory path relative to the project/working directory. + + This ensures the AI gets a usable path regardless of absolute locations. + + Args: + spec_dir: Absolute path to spec directory + project_dir: Absolute path to project/working directory + + Returns: + Relative path string (e.g., "./auto-claude/specs/003-new-spec") + """ + try: + # Try to make path relative to project_dir + relative = spec_dir.relative_to(project_dir) + return f"./{relative}" + except ValueError: + # If spec_dir is not under project_dir, return the name only + # This shouldn't happen if workspace.py correctly copies spec files + return f"./auto-claude/specs/{spec_dir.name}" + + +def generate_environment_context(project_dir: Path, spec_dir: Path) -> str: + """ + Generate environment context header for prompts. + + This explicitly tells the AI where it is working, preventing path confusion. + + Args: + project_dir: The working directory for the AI + spec_dir: The spec directory (may be absolute or relative) + + Returns: + Markdown string with environment context + """ + relative_spec = get_relative_spec_path(spec_dir, project_dir) + + return f"""## YOUR ENVIRONMENT + +**Working Directory:** `{project_dir}` +**Spec Location:** `{relative_spec}/` + +Your filesystem is restricted to your working directory. All file paths should be +relative to this location. Do NOT use absolute paths. + +**Important Files:** +- Spec: `{relative_spec}/spec.md` +- Plan: `{relative_spec}/implementation_plan.json` +- Progress: `{relative_spec}/build-progress.txt` +- Context: `{relative_spec}/context.json` + +--- + +""" + + +def generate_subtask_prompt( + spec_dir: Path, + project_dir: Path, + subtask: dict, + phase: dict, + attempt_count: int = 0, + recovery_hints: list[str] | None = None, +) -> str: + """ + Generate a minimal, focused prompt for implementing a single subtask. + + Args: + spec_dir: Directory containing spec files + project_dir: Root project directory (working directory) + subtask: The subtask to implement + phase: The phase containing this subtask + attempt_count: Number of previous attempts (for retry context) + recovery_hints: Hints from previous failed attempts + + Returns: + A focused prompt string (~100 lines instead of 900) + """ + subtask_id = subtask.get("id", "unknown") + description = subtask.get("description", "No description") + service = subtask.get("service", "all") + files_to_modify = subtask.get("files_to_modify", []) + files_to_create = subtask.get("files_to_create", []) + patterns_from = subtask.get("patterns_from", []) + verification = subtask.get("verification", {}) + + # Get relative spec path + relative_spec = get_relative_spec_path(spec_dir, project_dir) + + # Build the prompt + sections = [] + + # Environment context first + sections.append(generate_environment_context(project_dir, spec_dir)) + + # Header + sections.append(f"""# Subtask Implementation Task + +**Subtask ID:** `{subtask_id}` +**Phase:** {phase.get("name", phase.get("id", "Unknown"))} +**Service:** {service} + +## Description + +{description} +""") + + # Recovery context if this is a retry + if attempt_count > 0: + sections.append(f""" +## ⚠️ RETRY ATTEMPT ({attempt_count + 1}) + +This subtask has been attempted {attempt_count} time(s) before without success. +You MUST use a DIFFERENT approach than previous attempts. +""") + if recovery_hints: + sections.append("**Previous attempt insights:**") + for hint in recovery_hints: + sections.append(f"- {hint}") + sections.append("") + + # Files section + sections.append("## Files\n") + + if files_to_modify: + sections.append("**Files to Modify:**") + for f in files_to_modify: + sections.append(f"- `{f}`") + sections.append("") + + if files_to_create: + sections.append("**Files to Create:**") + for f in files_to_create: + sections.append(f"- `{f}`") + sections.append("") + + if patterns_from: + sections.append("**Pattern Files (study these first):**") + for f in patterns_from: + sections.append(f"- `{f}`") + sections.append("") + + # Verification + sections.append("## Verification\n") + v_type = verification.get("type", "manual") + + if v_type == "command": + sections.append(f"""Run this command to verify: +```bash +{verification.get("command", 'echo "No command specified"')} +``` +Expected: {verification.get("expected", "Success")} +""") + elif v_type == "api": + method = verification.get("method", "GET") + url = verification.get("url", "http://localhost") + body = verification.get("body", {}) + expected_status = verification.get("expected_status", 200) + sections.append(f"""Test the API endpoint: +```bash +curl -X {method} {url} -H "Content-Type: application/json" {f"-d '{json.dumps(body)}'" if body else ""} +``` +Expected status: {expected_status} +""") + elif v_type == "browser": + url = verification.get("url", "http://localhost:3000") + checks = verification.get("checks", []) + sections.append(f"""Open in browser: {url} + +Verify:""") + for check in checks: + sections.append(f"- [ ] {check}") + sections.append("") + elif v_type == "e2e": + steps = verification.get("steps", []) + sections.append("End-to-end verification steps:") + for i, step in enumerate(steps, 1): + sections.append(f"{i}. {step}") + sections.append("") + else: + instructions = verification.get("instructions", "Manual verification required") + sections.append(f"**Manual Verification:**\n{instructions}\n") + + # Instructions + sections.append(f"""## Instructions + +1. **Read the pattern files** to understand code style and conventions +2. **Read the files to modify** (if any) to understand current implementation +3. **Implement the subtask** following the patterns exactly +4. **Run verification** and fix any issues +5. **Commit your changes:** + ```bash + git add . + git commit -m "auto-claude: {subtask_id} - {description[:50]}" + ``` +6. **Update the plan** - set this subtask's status to "completed" in implementation_plan.json + +## Quality Checklist + +Before marking complete, verify: +- [ ] Follows patterns from reference files +- [ ] No console.log/print debugging statements +- [ ] Error handling in place +- [ ] Verification passes +- [ ] Clean commit with descriptive message + +## Important + +- Focus ONLY on this subtask - don't modify unrelated code +- If verification fails, FIX IT before committing +- If you encounter a blocker, document it in build-progress.txt +""") + + # Note: Linear updates are now handled by Python orchestrator via linear_updater.py + # Agents no longer need to call Linear MCP tools directly + + return "\n".join(sections) + + +def generate_planner_prompt(spec_dir: Path, project_dir: Path | None = None) -> str: + """ + Generate the planner prompt (used only once at start). + This is a simplified version that focuses on plan creation. + + Args: + spec_dir: Directory containing spec.md + project_dir: Working directory (for relative paths) + + Returns: + Planner prompt string + """ + # Load the full planner prompt from file + prompts_dir = Path(__file__).parent / "prompts" + planner_file = prompts_dir / "planner.md" + + if planner_file.exists(): + prompt = planner_file.read_text() + else: + prompt = ( + "Read spec.md and create implementation_plan.json with phases and subtasks." + ) + + # Use project_dir for relative paths, or infer from spec_dir + if project_dir is None: + # Infer: spec_dir is typically project/auto-claude/specs/XXX + project_dir = spec_dir.parent.parent.parent + + # Get relative path for spec directory + relative_spec = get_relative_spec_path(spec_dir, project_dir) + + # Build header with environment context + header = generate_environment_context(project_dir, spec_dir) + + # Add spec-specific instructions + header += f"""## SPEC LOCATION + +Your spec file is located at: `{relative_spec}/spec.md` + +Store all build artifacts in this spec directory: +- `{relative_spec}/implementation_plan.json` - Subtask-based implementation plan +- `{relative_spec}/build-progress.txt` - Progress notes +- `{relative_spec}/init.sh` - Environment setup script + +The project root is your current working directory. Implement code in the project root, +not in the spec directory. + +--- + +""" + # Note: Linear task creation and updates are now handled by Python orchestrator + # via linear_updater.py - agents no longer need Linear instructions in prompts + + return header + prompt + + +def load_subtask_context( + spec_dir: Path, + project_dir: Path, + subtask: dict, + max_file_lines: int = 200, +) -> dict: + """ + Load minimal context needed for a subtask. + + Args: + spec_dir: Spec directory + project_dir: Project root + subtask: The subtask being implemented + max_file_lines: Maximum lines to include per file + + Returns: + Dict with file contents and relevant context + """ + context = { + "patterns": {}, + "files_to_modify": {}, + "spec_excerpt": None, + } + + # Load pattern files (truncated) + for pattern_path in subtask.get("patterns_from", []): + full_path = project_dir / pattern_path + if full_path.exists(): + try: + lines = full_path.read_text().split("\n") + if len(lines) > max_file_lines: + content = "\n".join(lines[:max_file_lines]) + content += ( + f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)" + ) + else: + content = "\n".join(lines) + context["patterns"][pattern_path] = content + except Exception: + context["patterns"][pattern_path] = "(Could not read file)" + + # Load files to modify (truncated) + for file_path in subtask.get("files_to_modify", []): + full_path = project_dir / file_path + if full_path.exists(): + try: + lines = full_path.read_text().split("\n") + if len(lines) > max_file_lines: + content = "\n".join(lines[:max_file_lines]) + content += ( + f"\n\n... (truncated, {len(lines) - max_file_lines} more lines)" + ) + else: + content = "\n".join(lines) + context["files_to_modify"][file_path] = content + except Exception: + context["files_to_modify"][file_path] = "(Could not read file)" + + return context + + +def format_context_for_prompt(context: dict) -> str: + """ + Format loaded context into a prompt section. + + Args: + context: Dict from load_subtask_context + + Returns: + Formatted string to append to prompt + """ + sections = [] + + if context.get("patterns"): + sections.append("## Reference Files (Patterns to Follow)\n") + for path, content in context["patterns"].items(): + sections.append(f"### `{path}`\n```\n{content}\n```\n") + + if context.get("files_to_modify"): + sections.append("## Current File Contents (To Modify)\n") + for path, content in context["files_to_modify"].items(): + sections.append(f"### `{path}`\n```\n{content}\n```\n") + + return "\n".join(sections) diff --git a/auto-claude/prompts_pkg/prompts.py b/auto-claude/prompts_pkg/prompts.py new file mode 100644 index 00000000..fcda78fa --- /dev/null +++ b/auto-claude/prompts_pkg/prompts.py @@ -0,0 +1,263 @@ +""" +Prompt Loading Utilities +======================== + +Functions for loading agent prompts from markdown files. +""" + +import json +from pathlib import Path + +# Directory containing prompt files +PROMPTS_DIR = Path(__file__).parent / "prompts" + + +def get_planner_prompt(spec_dir: Path) -> str: + """ + Load the planner agent prompt with spec path injected. + The planner creates subtask-based implementation plans. + + Args: + spec_dir: Directory containing the spec.md file + + Returns: + The planner prompt content with spec path + """ + prompt_file = PROMPTS_DIR / "planner.md" + + if not prompt_file.exists(): + raise FileNotFoundError( + f"Planner prompt not found at {prompt_file}\n" + "Make sure the auto-claude/prompts/planner.md file exists." + ) + + prompt = prompt_file.read_text() + + # Inject spec directory information at the beginning + spec_context = f"""## SPEC LOCATION + +Your spec file is located at: `{spec_dir}/spec.md` + +Store all build artifacts in this spec directory: +- `{spec_dir}/implementation_plan.json` - Subtask-based implementation plan +- `{spec_dir}/build-progress.txt` - Progress notes +- `{spec_dir}/init.sh` - Environment setup script + +The project root is the parent of auto-claude/. Implement code in the project root, not in the spec directory. + +--- + +""" + return spec_context + prompt + + +def get_coding_prompt(spec_dir: Path) -> str: + """ + Load the coding agent prompt with spec path injected. + + Args: + spec_dir: Directory containing the spec.md and implementation_plan.json + + Returns: + The coding agent prompt content with spec path + """ + prompt_file = PROMPTS_DIR / "coder.md" + + if not prompt_file.exists(): + raise FileNotFoundError( + f"Coding prompt not found at {prompt_file}\n" + "Make sure the auto-claude/prompts/coder.md file exists." + ) + + prompt = prompt_file.read_text() + + spec_context = f"""## SPEC LOCATION + +Your spec and progress files are located at: +- Spec: `{spec_dir}/spec.md` +- Implementation plan: `{spec_dir}/implementation_plan.json` +- Progress notes: `{spec_dir}/build-progress.txt` +- Recovery context: `{spec_dir}/memory/attempt_history.json` + +The project root is the parent of auto-claude/. All code goes in the project root, not in the spec directory. + +--- + +""" + + # Check for recovery context (stuck subtasks, retry hints) + recovery_context = _get_recovery_context(spec_dir) + if recovery_context: + spec_context += recovery_context + + # Check for human input file + human_input_file = spec_dir / "HUMAN_INPUT.md" + if human_input_file.exists(): + human_input = human_input_file.read_text().strip() + if human_input: + spec_context += f"""## HUMAN INPUT (READ THIS FIRST!) + +The human has left you instructions. READ AND FOLLOW THESE CAREFULLY: + +{human_input} + +After addressing this input, you may delete or clear the HUMAN_INPUT.md file. + +--- + +""" + + return spec_context + prompt + + +def _get_recovery_context(spec_dir: Path) -> str: + """ + Get recovery context if there are failed attempts or stuck subtasks. + + Args: + spec_dir: Spec directory containing memory/ + + Returns: + Recovery context string or empty string + """ + import json + + attempt_history_file = spec_dir / "memory" / "attempt_history.json" + + if not attempt_history_file.exists(): + return "" + + try: + with open(attempt_history_file) as f: + history = json.load(f) + + # Check for stuck subtasks + stuck_subtasks = history.get("stuck_subtasks", []) + if stuck_subtasks: + context = """## ⚠️ RECOVERY ALERT - STUCK SUBTASKS DETECTED + +Some subtasks have been attempted multiple times without success. These subtasks need: +- A COMPLETELY DIFFERENT approach +- Possibly simpler implementation +- Or escalation to human if infeasible + +Stuck subtasks: +""" + for stuck in stuck_subtasks: + context += f"- {stuck['subtask_id']}: {stuck['reason']} ({stuck['attempt_count']} attempts)\n" + + context += "\nBefore working on any subtask, check memory/attempt_history.json for previous attempts!\n\n---\n\n" + return context + + # Check for subtasks with multiple attempts + subtasks_with_retries = [] + for subtask_id, subtask_data in history.get("subtasks", {}).items(): + attempts = subtask_data.get("attempts", []) + if len(attempts) > 1 and subtask_data.get("status") != "completed": + subtasks_with_retries.append((subtask_id, len(attempts))) + + if subtasks_with_retries: + context = """## ⚠️ RECOVERY CONTEXT - RETRY AWARENESS + +Some subtasks have been attempted before. When working on these: +1. READ memory/attempt_history.json for the specific subtask +2. See what approaches were tried +3. Use a DIFFERENT approach + +Subtasks with previous attempts: +""" + for subtask_id, attempt_count in subtasks_with_retries: + context += f"- {subtask_id}: {attempt_count} attempts\n" + + context += "\n---\n\n" + return context + + return "" + + except (OSError, json.JSONDecodeError): + return "" + + +def get_followup_planner_prompt(spec_dir: Path) -> str: + """ + Load the follow-up planner agent prompt with spec path and key files injected. + The follow-up planner adds new subtasks to an existing completed implementation plan. + + Args: + spec_dir: Directory containing the completed spec and implementation_plan.json + + Returns: + The follow-up planner prompt content with paths injected + """ + prompt_file = PROMPTS_DIR / "followup_planner.md" + + if not prompt_file.exists(): + raise FileNotFoundError( + f"Follow-up planner prompt not found at {prompt_file}\n" + "Make sure the auto-claude/prompts/followup_planner.md file exists." + ) + + prompt = prompt_file.read_text() + + # Inject spec directory information at the beginning + spec_context = f"""## SPEC LOCATION (FOLLOW-UP MODE) + +You are adding follow-up work to a **completed** spec. + +**Key files in this spec directory:** +- Spec: `{spec_dir}/spec.md` +- Follow-up request: `{spec_dir}/FOLLOWUP_REQUEST.md` (READ THIS FIRST!) +- Implementation plan: `{spec_dir}/implementation_plan.json` (APPEND to this, don't replace) +- Progress notes: `{spec_dir}/build-progress.txt` +- Context: `{spec_dir}/context.json` +- Memory: `{spec_dir}/memory/` + +**Important paths:** +- Spec directory: `{spec_dir}` +- Project root: Parent of auto-claude/ (where code should be implemented) + +**Your task:** +1. Read `{spec_dir}/FOLLOWUP_REQUEST.md` to understand what to add +2. Read `{spec_dir}/implementation_plan.json` to see existing phases/subtasks +3. ADD new phase(s) with pending subtasks to the existing plan +4. PRESERVE all existing subtasks and their statuses + +--- + +""" + return spec_context + prompt + + +def is_first_run(spec_dir: Path) -> bool: + """ + Check if this is the first run (no valid implementation plan with subtasks exists yet). + + The spec runner may create a skeleton implementation_plan.json with empty phases. + This function checks for actual phases with subtasks, not just file existence. + + Args: + spec_dir: Directory containing spec files + + Returns: + True if implementation_plan.json doesn't exist or has no subtasks + """ + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return True + + try: + with open(plan_file) as f: + plan = json.load(f) + + # Check if there are any phases with subtasks + phases = plan.get("phases", []) + if not phases: + return True + + # Check if any phase has subtasks + total_subtasks = sum(len(phase.get("subtasks", [])) for phase in phases) + return total_subtasks == 0 + except (OSError, json.JSONDecodeError): + # If we can't read the file, treat as first run + return True diff --git a/auto-claude/qa/qa_loop.py b/auto-claude/qa/qa_loop.py new file mode 100644 index 00000000..213cd4c3 --- /dev/null +++ b/auto-claude/qa/qa_loop.py @@ -0,0 +1,97 @@ +""" +QA Validation Loop (Facade) +============================ + +This module provides backward compatibility by re-exporting the QA +validation system that has been refactored into the qa/ package. + +For new code, prefer importing directly from the qa package: + from qa import run_qa_validation_loop, should_run_qa, is_qa_approved + +Module structure: + - qa/loop.py: Main QA orchestration loop + - qa/reviewer.py: QA reviewer agent session + - qa/fixer.py: QA fixer agent session + - qa/report.py: Issue tracking, reporting, escalation + - qa/criteria.py: Acceptance criteria and status management + +Enhanced features: +- Iteration tracking with detailed history +- Recurring issue detection (3+ occurrences → human escalation) +- No-test project handling +- Integration with validation strategy and risk classification +""" + +# Re-export everything from the qa package for backward compatibility +from qa import ( + ISSUE_SIMILARITY_THRESHOLD, + # Configuration + MAX_QA_ITERATIONS, + RECURRING_ISSUE_THRESHOLD, + _issue_similarity, + _normalize_issue_key, + check_test_discovery, + create_manual_test_plan, + escalate_to_human, + # Report & tracking + get_iteration_history, + get_qa_iteration_count, + get_qa_signoff_status, + get_recurring_issue_summary, + has_recurring_issues, + is_fixes_applied, + is_no_test_project, + is_qa_approved, + is_qa_rejected, + # Criteria & status + load_implementation_plan, + load_qa_fixer_prompt, + # Agent sessions + load_qa_reviewer_prompt, + print_qa_status, + record_iteration, + run_qa_agent_session, + run_qa_fixer_session, + # Main loop + run_qa_validation_loop, + save_implementation_plan, + should_run_fixes, + should_run_qa, +) + +# Maintain original __all__ for explicit exports +__all__ = [ + # Configuration + "MAX_QA_ITERATIONS", + "RECURRING_ISSUE_THRESHOLD", + "ISSUE_SIMILARITY_THRESHOLD", + # Main loop + "run_qa_validation_loop", + # Criteria & status + "load_implementation_plan", + "save_implementation_plan", + "get_qa_signoff_status", + "is_qa_approved", + "is_qa_rejected", + "is_fixes_applied", + "get_qa_iteration_count", + "should_run_qa", + "should_run_fixes", + "print_qa_status", + # Report & tracking + "get_iteration_history", + "record_iteration", + "has_recurring_issues", + "get_recurring_issue_summary", + "escalate_to_human", + "create_manual_test_plan", + "check_test_discovery", + "is_no_test_project", + "_normalize_issue_key", + "_issue_similarity", + # Agent sessions + "load_qa_reviewer_prompt", + "run_qa_agent_session", + "load_qa_fixer_prompt", + "run_qa_fixer_session", +] diff --git a/auto-claude/qa_loop.py b/auto-claude/qa_loop.py index 213cd4c3..8d5c789a 100644 --- a/auto-claude/qa_loop.py +++ b/auto-claude/qa_loop.py @@ -1,65 +1,40 @@ -""" -QA Validation Loop (Facade) -============================ - -This module provides backward compatibility by re-exporting the QA -validation system that has been refactored into the qa/ package. - -For new code, prefer importing directly from the qa package: - from qa import run_qa_validation_loop, should_run_qa, is_qa_approved - -Module structure: - - qa/loop.py: Main QA orchestration loop - - qa/reviewer.py: QA reviewer agent session - - qa/fixer.py: QA fixer agent session - - qa/report.py: Issue tracking, reporting, escalation - - qa/criteria.py: Acceptance criteria and status management - -Enhanced features: -- Iteration tracking with detailed history -- Recurring issue detection (3+ occurrences → human escalation) -- No-test project handling -- Integration with validation strategy and risk classification -""" - -# Re-export everything from the qa package for backward compatibility +"""Backward compatibility shim - import from qa package instead.""" from qa import ( - ISSUE_SIMILARITY_THRESHOLD, # Configuration MAX_QA_ITERATIONS, RECURRING_ISSUE_THRESHOLD, - _issue_similarity, - _normalize_issue_key, - check_test_discovery, - create_manual_test_plan, - escalate_to_human, - # Report & tracking - get_iteration_history, - get_qa_iteration_count, - get_qa_signoff_status, - get_recurring_issue_summary, - has_recurring_issues, - is_fixes_applied, - is_no_test_project, - is_qa_approved, - is_qa_rejected, - # Criteria & status - load_implementation_plan, - load_qa_fixer_prompt, - # Agent sessions - load_qa_reviewer_prompt, - print_qa_status, - record_iteration, - run_qa_agent_session, - run_qa_fixer_session, + ISSUE_SIMILARITY_THRESHOLD, # Main loop run_qa_validation_loop, + # Criteria & status + load_implementation_plan, save_implementation_plan, - should_run_fixes, + get_qa_signoff_status, + is_qa_approved, + is_qa_rejected, + is_fixes_applied, + get_qa_iteration_count, should_run_qa, + should_run_fixes, + print_qa_status, + # Report & tracking + get_iteration_history, + record_iteration, + has_recurring_issues, + get_recurring_issue_summary, + escalate_to_human, + create_manual_test_plan, + check_test_discovery, + is_no_test_project, + _normalize_issue_key, + _issue_similarity, + # Agent sessions + load_qa_reviewer_prompt, + run_qa_agent_session, + load_qa_fixer_prompt, + run_qa_fixer_session, ) -# Maintain original __all__ for explicit exports __all__ = [ # Configuration "MAX_QA_ITERATIONS", diff --git a/auto-claude/recovery.py b/auto-claude/recovery.py index af6fb66c..d5fee827 100644 --- a/auto-claude/recovery.py +++ b/auto-claude/recovery.py @@ -1,606 +1,16 @@ -""" -Smart Rollback and Recovery System -=================================== - -Automatic recovery from build failures, stuck loops, and broken builds. -Enables true "walk away" automation by detecting and recovering from common failure modes. - -Key Features: -- Automatic rollback to last working state -- Circular fix detection (prevents infinite loops) -- Attempt history tracking across sessions -- Smart retry with different approaches -- Escalation to human when stuck -""" - -import json -import subprocess -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from pathlib import Path - - -class FailureType(Enum): - """Types of failures that can occur during autonomous builds.""" - - BROKEN_BUILD = "broken_build" # Code doesn't compile/run - VERIFICATION_FAILED = "verification_failed" # Subtask verification failed - CIRCULAR_FIX = "circular_fix" # Same fix attempted multiple times - CONTEXT_EXHAUSTED = "context_exhausted" # Ran out of context mid-subtask - UNKNOWN = "unknown" - - -@dataclass -class RecoveryAction: - """Action to take in response to a failure.""" - - action: str # "rollback", "retry", "skip", "escalate" - target: str # commit hash, subtask id, or message - reason: str - - -class RecoveryManager: - """ - Manages recovery from build failures. - - Responsibilities: - - Track attempt history across sessions - - Classify failures and determine recovery actions - - Rollback to working states - - Detect circular fixes (same approach repeatedly) - - Escalate stuck subtasks for human intervention - """ - - def __init__(self, spec_dir: Path, project_dir: Path): - """ - Initialize recovery manager. - - Args: - spec_dir: Spec directory containing memory/ - project_dir: Root project directory for git operations - """ - self.spec_dir = spec_dir - self.project_dir = project_dir - self.memory_dir = spec_dir / "memory" - self.attempt_history_file = self.memory_dir / "attempt_history.json" - self.build_commits_file = self.memory_dir / "build_commits.json" - - # Ensure memory directory exists - self.memory_dir.mkdir(parents=True, exist_ok=True) - - # Initialize files if they don't exist - if not self.attempt_history_file.exists(): - self._init_attempt_history() - - if not self.build_commits_file.exists(): - self._init_build_commits() - - def _init_attempt_history(self) -> None: - """Initialize the attempt history file.""" - initial_data = { - "subtasks": {}, - "stuck_subtasks": [], - "metadata": { - "created_at": datetime.now().isoformat(), - "last_updated": datetime.now().isoformat(), - }, - } - with open(self.attempt_history_file, "w") as f: - json.dump(initial_data, f, indent=2) - - def _init_build_commits(self) -> None: - """Initialize the build commits tracking file.""" - initial_data = { - "commits": [], - "last_good_commit": None, - "metadata": { - "created_at": datetime.now().isoformat(), - "last_updated": datetime.now().isoformat(), - }, - } - with open(self.build_commits_file, "w") as f: - json.dump(initial_data, f, indent=2) - - def _load_attempt_history(self) -> dict: - """Load attempt history from JSON file.""" - try: - with open(self.attempt_history_file) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - self._init_attempt_history() - with open(self.attempt_history_file) as f: - return json.load(f) - - def _save_attempt_history(self, data: dict) -> None: - """Save attempt history to JSON file.""" - data["metadata"]["last_updated"] = datetime.now().isoformat() - with open(self.attempt_history_file, "w") as f: - json.dump(data, f, indent=2) - - def _load_build_commits(self) -> dict: - """Load build commits from JSON file.""" - try: - with open(self.build_commits_file) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - self._init_build_commits() - with open(self.build_commits_file) as f: - return json.load(f) - - def _save_build_commits(self, data: dict) -> None: - """Save build commits to JSON file.""" - data["metadata"]["last_updated"] = datetime.now().isoformat() - with open(self.build_commits_file, "w") as f: - json.dump(data, f, indent=2) - - def classify_failure(self, error: str, subtask_id: str) -> FailureType: - """ - Classify what type of failure occurred. - - Args: - error: Error message or description - subtask_id: ID of the subtask that failed - - Returns: - FailureType enum value - """ - error_lower = error.lower() - - # Check for broken build indicators - build_errors = [ - "syntax error", - "compilation error", - "module not found", - "import error", - "cannot find module", - "unexpected token", - "indentation error", - "parse error", - ] - if any(be in error_lower for be in build_errors): - return FailureType.BROKEN_BUILD - - # Check for verification failures - verification_errors = [ - "verification failed", - "expected", - "assertion", - "test failed", - "status code", - ] - if any(ve in error_lower for ve in verification_errors): - return FailureType.VERIFICATION_FAILED - - # Check for context exhaustion - context_errors = ["context", "token limit", "maximum length"] - if any(ce in error_lower for ce in context_errors): - return FailureType.CONTEXT_EXHAUSTED - - # Check for circular fixes (will be determined by attempt history) - if self.is_circular_fix(subtask_id, error): - return FailureType.CIRCULAR_FIX - - return FailureType.UNKNOWN - - def get_attempt_count(self, subtask_id: str) -> int: - """ - Get how many times this subtask has been attempted. - - Args: - subtask_id: ID of the subtask - - Returns: - Number of attempts - """ - history = self._load_attempt_history() - subtask_data = history["subtasks"].get(subtask_id, {}) - return len(subtask_data.get("attempts", [])) - - def record_attempt( - self, - subtask_id: str, - session: int, - success: bool, - approach: str, - error: str | None = None, - ) -> None: - """ - Record an attempt at a subtask. - - Args: - subtask_id: ID of the subtask - session: Session number - success: Whether the attempt succeeded - approach: Description of the approach taken - error: Error message if failed - """ - history = self._load_attempt_history() - - # Initialize subtask entry if it doesn't exist - if subtask_id not in history["subtasks"]: - history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"} - - # Add the attempt - attempt = { - "session": session, - "timestamp": datetime.now().isoformat(), - "approach": approach, - "success": success, - "error": error, - } - history["subtasks"][subtask_id]["attempts"].append(attempt) - - # Update status - if success: - history["subtasks"][subtask_id]["status"] = "completed" - else: - history["subtasks"][subtask_id]["status"] = "failed" - - self._save_attempt_history(history) - - def is_circular_fix(self, subtask_id: str, current_approach: str) -> bool: - """ - Detect if we're trying the same approach repeatedly. - - Args: - subtask_id: ID of the subtask - current_approach: Description of current approach - - Returns: - True if this appears to be a circular fix attempt - """ - history = self._load_attempt_history() - subtask_data = history["subtasks"].get(subtask_id, {}) - attempts = subtask_data.get("attempts", []) - - if len(attempts) < 2: - return False - - # Check if last 3 attempts used similar approaches - # Simple similarity check: look for repeated keywords - recent_attempts = attempts[-3:] if len(attempts) >= 3 else attempts - - # Extract key terms from current approach (ignore common words) - stop_words = { - "with", - "using", - "the", - "a", - "an", - "and", - "or", - "but", - "in", - "on", - "at", - "to", - "for", - "trying", - } - current_keywords = set( - word for word in current_approach.lower().split() if word not in stop_words - ) - - similar_count = 0 - for attempt in recent_attempts: - attempt_keywords = set( - word - for word in attempt["approach"].lower().split() - if word not in stop_words - ) - - # Calculate Jaccard similarity (intersection over union) - overlap = len(current_keywords & attempt_keywords) - total = len(current_keywords | attempt_keywords) - - if total > 0: - similarity = overlap / total - # If >30% of meaningful words overlap, consider it similar - # This catches key technical terms appearing repeatedly - # (e.g., "async await" across multiple attempts) - if similarity > 0.3: - similar_count += 1 - - # If 2+ recent attempts were similar to current approach, it's circular - return similar_count >= 2 - - def determine_recovery_action( - self, failure_type: FailureType, subtask_id: str - ) -> RecoveryAction: - """ - Decide what to do based on failure type and history. - - Args: - failure_type: Type of failure that occurred - subtask_id: ID of the subtask that failed - - Returns: - RecoveryAction describing what to do - """ - attempt_count = self.get_attempt_count(subtask_id) - - if failure_type == FailureType.BROKEN_BUILD: - # Broken build: rollback to last good state - last_good = self.get_last_good_commit() - if last_good: - return RecoveryAction( - action="rollback", - target=last_good, - reason=f"Build broken in subtask {subtask_id}, rolling back to working state", - ) - else: - return RecoveryAction( - action="escalate", - target=subtask_id, - reason="Build broken and no good commit found to rollback to", - ) - - elif failure_type == FailureType.VERIFICATION_FAILED: - # Verification failed: retry with different approach if < 3 attempts - if attempt_count < 3: - return RecoveryAction( - action="retry", - target=subtask_id, - reason=f"Verification failed, retry with different approach (attempt {attempt_count + 1}/3)", - ) - else: - return RecoveryAction( - action="skip", - target=subtask_id, - reason=f"Verification failed after {attempt_count} attempts, marking as stuck", - ) - - elif failure_type == FailureType.CIRCULAR_FIX: - # Circular fix detected: skip and escalate - return RecoveryAction( - action="skip", - target=subtask_id, - reason="Circular fix detected - same approach tried multiple times", - ) - - elif failure_type == FailureType.CONTEXT_EXHAUSTED: - # Context exhausted: commit current progress and continue - return RecoveryAction( - action="continue", - target=subtask_id, - reason="Context exhausted, will commit progress and continue in next session", - ) - - else: # UNKNOWN - # Unknown error: retry once, then escalate - if attempt_count < 2: - return RecoveryAction( - action="retry", - target=subtask_id, - reason=f"Unknown error, retrying (attempt {attempt_count + 1}/2)", - ) - else: - return RecoveryAction( - action="escalate", - target=subtask_id, - reason=f"Unknown error persists after {attempt_count} attempts", - ) - - def get_last_good_commit(self) -> str | None: - """ - Find the most recent commit where build was working. - - Returns: - Commit hash or None - """ - commits = self._load_build_commits() - return commits.get("last_good_commit") - - def record_good_commit(self, commit_hash: str, subtask_id: str) -> None: - """ - Record a commit where the build was working. - - Args: - commit_hash: Git commit hash - subtask_id: Subtask that was successfully completed - """ - commits = self._load_build_commits() - - commit_record = { - "hash": commit_hash, - "subtask_id": subtask_id, - "timestamp": datetime.now().isoformat(), - } - - commits["commits"].append(commit_record) - commits["last_good_commit"] = commit_hash - - self._save_build_commits(commits) - - def rollback_to_commit(self, commit_hash: str) -> bool: - """ - Rollback to a specific commit. - - Args: - commit_hash: Git commit hash to rollback to - - Returns: - True if successful, False otherwise - """ - try: - # Use git reset --hard to rollback - result = subprocess.run( - ["git", "reset", "--hard", commit_hash], - cwd=self.project_dir, - capture_output=True, - text=True, - check=True, - ) - return True - except subprocess.CalledProcessError as e: - print(f"Error rolling back to {commit_hash}: {e.stderr}") - return False - - def mark_subtask_stuck(self, subtask_id: str, reason: str) -> None: - """ - Mark a subtask as needing human intervention. - - Args: - subtask_id: ID of the subtask - reason: Why it's stuck - """ - history = self._load_attempt_history() - - stuck_entry = { - "subtask_id": subtask_id, - "reason": reason, - "escalated_at": datetime.now().isoformat(), - "attempt_count": self.get_attempt_count(subtask_id), - } - - # Check if already in stuck list - existing = [ - s for s in history["stuck_subtasks"] if s["subtask_id"] == subtask_id - ] - if not existing: - history["stuck_subtasks"].append(stuck_entry) - - # Update subtask status - if subtask_id in history["subtasks"]: - history["subtasks"][subtask_id]["status"] = "stuck" - - self._save_attempt_history(history) - - def get_stuck_subtasks(self) -> list[dict]: - """ - Get all subtasks marked as stuck. - - Returns: - List of stuck subtask entries - """ - history = self._load_attempt_history() - return history.get("stuck_subtasks", []) - - def get_subtask_history(self, subtask_id: str) -> dict: - """ - Get the attempt history for a specific subtask. - - Args: - subtask_id: ID of the subtask - - Returns: - Subtask history dict with attempts - """ - history = self._load_attempt_history() - return history["subtasks"].get( - subtask_id, {"attempts": [], "status": "pending"} - ) - - def get_recovery_hints(self, subtask_id: str) -> list[str]: - """ - Get hints for recovery based on previous attempts. - - Args: - subtask_id: ID of the subtask - - Returns: - List of hint strings - """ - subtask_history = self.get_subtask_history(subtask_id) - attempts = subtask_history.get("attempts", []) - - if not attempts: - return ["This is the first attempt at this subtask"] - - hints = [f"Previous attempts: {len(attempts)}"] - - # Add info about what was tried - for i, attempt in enumerate(attempts[-3:], 1): - hints.append( - f"Attempt {i}: {attempt['approach']} - " - f"{'SUCCESS' if attempt['success'] else 'FAILED'}" - ) - if attempt.get("error"): - hints.append(f" Error: {attempt['error'][:100]}") - - # Add guidance - if len(attempts) >= 2: - hints.append( - "\n⚠️ IMPORTANT: Try a DIFFERENT approach than previous attempts" - ) - hints.append( - "Consider: different library, different pattern, or simpler implementation" - ) - - return hints - - def clear_stuck_subtasks(self) -> None: - """Clear all stuck subtasks (for manual resolution).""" - history = self._load_attempt_history() - history["stuck_subtasks"] = [] - self._save_attempt_history(history) - - def reset_subtask(self, subtask_id: str) -> None: - """ - Reset a subtask's attempt history. - - Args: - subtask_id: ID of the subtask to reset - """ - history = self._load_attempt_history() - - # Clear attempt history - if subtask_id in history["subtasks"]: - history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"} - - # Remove from stuck subtasks - history["stuck_subtasks"] = [ - s for s in history["stuck_subtasks"] if s["subtask_id"] != subtask_id - ] - - self._save_attempt_history(history) - - -# Utility functions for integration with agent.py - - -def check_and_recover( - spec_dir: Path, project_dir: Path, subtask_id: str, error: str | None = None -) -> RecoveryAction | None: - """ - Check if recovery is needed and return appropriate action. - - Args: - spec_dir: Spec directory - project_dir: Project directory - subtask_id: Current subtask ID - error: Error message if any - - Returns: - RecoveryAction if recovery needed, None otherwise - """ - if not error: - return None - - manager = RecoveryManager(spec_dir, project_dir) - failure_type = manager.classify_failure(error, subtask_id) - - return manager.determine_recovery_action(failure_type, subtask_id) - - -def get_recovery_context(spec_dir: Path, project_dir: Path, subtask_id: str) -> dict: - """ - Get recovery context for a subtask (for prompt generation). - - Args: - spec_dir: Spec directory - project_dir: Project directory - subtask_id: Subtask ID - - Returns: - Dict with recovery hints and history - """ - manager = RecoveryManager(spec_dir, project_dir) - - return { - "attempt_count": manager.get_attempt_count(subtask_id), - "hints": manager.get_recovery_hints(subtask_id), - "subtask_history": manager.get_subtask_history(subtask_id), - "stuck_subtasks": manager.get_stuck_subtasks(), - } +"""Backward compatibility shim - import from services.recovery instead.""" +from services.recovery import ( + RecoveryManager, + FailureType, + RecoveryAction, + check_and_recover, + get_recovery_context, +) + +__all__ = [ + "RecoveryManager", + "FailureType", + "RecoveryAction", + "check_and_recover", + "get_recovery_context", +] diff --git a/auto-claude/review.py b/auto-claude/review/main.py similarity index 100% rename from auto-claude/review.py rename to auto-claude/review/main.py diff --git a/auto-claude/risk_classifier.py b/auto-claude/risk_classifier.py index 37488c08..742a6631 100644 --- a/auto-claude/risk_classifier.py +++ b/auto-claude/risk_classifier.py @@ -1,589 +1,30 @@ -#!/usr/bin/env python3 -""" -Risk Classifier Module -====================== - -Reads the AI-generated complexity_assessment.json and provides programmatic -access to risk classification and validation recommendations. - -This module serves as the bridge between the AI complexity assessor prompt -and the rest of the validation system. - -Usage: - from risk_classifier import RiskClassifier - - classifier = RiskClassifier() - assessment = classifier.load_assessment(spec_dir) - - if classifier.should_skip_validation(spec_dir): - print("Validation can be skipped for this task") - - test_types = classifier.get_required_test_types(spec_dir) -""" - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class ScopeAnalysis: - """Analysis of task scope.""" - - estimated_files: int = 0 - estimated_services: int = 0 - is_cross_cutting: bool = False - notes: str = "" - - -@dataclass -class IntegrationAnalysis: - """Analysis of external integrations.""" - - external_services: list[str] = field(default_factory=list) - new_dependencies: list[str] = field(default_factory=list) - research_needed: bool = False - notes: str = "" - - -@dataclass -class InfrastructureAnalysis: - """Analysis of infrastructure requirements.""" - - docker_changes: bool = False - database_changes: bool = False - config_changes: bool = False - notes: str = "" - - -@dataclass -class KnowledgeAnalysis: - """Analysis of knowledge requirements.""" - - patterns_exist: bool = True - research_required: bool = False - unfamiliar_tech: list[str] = field(default_factory=list) - notes: str = "" - - -@dataclass -class RiskAnalysis: - """Analysis of task risk.""" - - level: str = "low" # low, medium, high - concerns: list[str] = field(default_factory=list) - notes: str = "" - - -@dataclass -class ComplexityAnalysis: - """Full complexity analysis from the AI assessor.""" - - scope: ScopeAnalysis = field(default_factory=ScopeAnalysis) - integrations: IntegrationAnalysis = field(default_factory=IntegrationAnalysis) - infrastructure: InfrastructureAnalysis = field( - default_factory=InfrastructureAnalysis - ) - knowledge: KnowledgeAnalysis = field(default_factory=KnowledgeAnalysis) - risk: RiskAnalysis = field(default_factory=RiskAnalysis) - - -@dataclass -class ValidationRecommendations: - """Validation recommendations from the AI assessor.""" - - risk_level: str = "medium" # trivial, low, medium, high, critical - skip_validation: bool = False - minimal_mode: bool = False - test_types_required: list[str] = field(default_factory=lambda: ["unit"]) - security_scan_required: bool = False - staging_deployment_required: bool = False - reasoning: str = "" - - -@dataclass -class AssessmentFlags: - """Flags indicating special requirements.""" - - needs_research: bool = False - needs_self_critique: bool = False - needs_infrastructure_setup: bool = False - - -@dataclass -class RiskAssessment: - """Complete risk assessment from complexity_assessment.json.""" - - complexity: str # simple, standard, complex - workflow_type: str # feature, refactor, investigation, migration, simple - confidence: float - reasoning: str - analysis: ComplexityAnalysis - recommended_phases: list[str] - flags: AssessmentFlags - validation: ValidationRecommendations - created_at: str | None = None - - @property - def risk_level(self) -> str: - """Get the risk level from validation recommendations.""" - return self.validation.risk_level - - -# ============================================================================= -# RISK CLASSIFIER -# ============================================================================= - - -class RiskClassifier: - """ - Reads AI-generated complexity_assessment.json and provides risk classification. - - The complexity_assessment.json is generated by the AI complexity assessor - agent using the complexity_assessor.md prompt. This module parses that output - and provides programmatic access to the risk classification. - """ - - def __init__(self) -> None: - """Initialize the risk classifier.""" - self._cache: dict[str, RiskAssessment] = {} - - def load_assessment(self, spec_dir: Path) -> RiskAssessment | None: - """ - Load complexity_assessment.json from spec directory. - - Args: - spec_dir: Path to the spec directory containing complexity_assessment.json - - Returns: - RiskAssessment object if file exists and is valid, None otherwise - """ - spec_dir = Path(spec_dir) - cache_key = str(spec_dir.resolve()) - - # Return cached result if available - if cache_key in self._cache: - return self._cache[cache_key] - - assessment_file = spec_dir / "complexity_assessment.json" - if not assessment_file.exists(): - return None - - try: - with open(assessment_file, encoding="utf-8") as f: - data = json.load(f) - - assessment = self._parse_assessment(data) - self._cache[cache_key] = assessment - return assessment - - except (json.JSONDecodeError, KeyError, TypeError) as e: - # Log error but don't crash - return None to allow fallback behavior - print(f"Warning: Failed to parse complexity_assessment.json: {e}") - return None - - def _parse_assessment(self, data: dict[str, Any]) -> RiskAssessment: - """Parse raw JSON data into a RiskAssessment object.""" - # Parse analysis sections - analysis_data = data.get("analysis", {}) - analysis = ComplexityAnalysis( - scope=self._parse_scope(analysis_data.get("scope", {})), - integrations=self._parse_integrations( - analysis_data.get("integrations", {}) - ), - infrastructure=self._parse_infrastructure( - analysis_data.get("infrastructure", {}) - ), - knowledge=self._parse_knowledge(analysis_data.get("knowledge", {})), - risk=self._parse_risk(analysis_data.get("risk", {})), - ) - - # Parse flags - flags_data = data.get("flags", {}) - flags = AssessmentFlags( - needs_research=flags_data.get("needs_research", False), - needs_self_critique=flags_data.get("needs_self_critique", False), - needs_infrastructure_setup=flags_data.get( - "needs_infrastructure_setup", False - ), - ) - - # Parse validation recommendations - validation_data = data.get("validation_recommendations", {}) - validation = self._parse_validation_recommendations(validation_data, analysis) - - return RiskAssessment( - complexity=data.get("complexity", "standard"), - workflow_type=data.get("workflow_type", "feature"), - confidence=float(data.get("confidence", 0.5)), - reasoning=data.get("reasoning", ""), - analysis=analysis, - recommended_phases=data.get("recommended_phases", []), - flags=flags, - validation=validation, - created_at=data.get("created_at"), - ) - - def _parse_scope(self, data: dict[str, Any]) -> ScopeAnalysis: - """Parse scope analysis section.""" - return ScopeAnalysis( - estimated_files=int(data.get("estimated_files", 0)), - estimated_services=int(data.get("estimated_services", 0)), - is_cross_cutting=bool(data.get("is_cross_cutting", False)), - notes=str(data.get("notes", "")), - ) - - def _parse_integrations(self, data: dict[str, Any]) -> IntegrationAnalysis: - """Parse integrations analysis section.""" - return IntegrationAnalysis( - external_services=list(data.get("external_services", [])), - new_dependencies=list(data.get("new_dependencies", [])), - research_needed=bool(data.get("research_needed", False)), - notes=str(data.get("notes", "")), - ) - - def _parse_infrastructure(self, data: dict[str, Any]) -> InfrastructureAnalysis: - """Parse infrastructure analysis section.""" - return InfrastructureAnalysis( - docker_changes=bool(data.get("docker_changes", False)), - database_changes=bool(data.get("database_changes", False)), - config_changes=bool(data.get("config_changes", False)), - notes=str(data.get("notes", "")), - ) - - def _parse_knowledge(self, data: dict[str, Any]) -> KnowledgeAnalysis: - """Parse knowledge analysis section.""" - return KnowledgeAnalysis( - patterns_exist=bool(data.get("patterns_exist", True)), - research_required=bool(data.get("research_required", False)), - unfamiliar_tech=list(data.get("unfamiliar_tech", [])), - notes=str(data.get("notes", "")), - ) - - def _parse_risk(self, data: dict[str, Any]) -> RiskAnalysis: - """Parse risk analysis section.""" - return RiskAnalysis( - level=str(data.get("level", "low")), - concerns=list(data.get("concerns", [])), - notes=str(data.get("notes", "")), - ) - - def _parse_validation_recommendations( - self, data: dict[str, Any], analysis: ComplexityAnalysis - ) -> ValidationRecommendations: - """ - Parse validation recommendations section. - - If validation_recommendations is not present in the JSON (older assessments), - infer appropriate values from the analysis. - """ - if data: - # New format with explicit validation recommendations - return ValidationRecommendations( - risk_level=str(data.get("risk_level", "medium")), - skip_validation=bool(data.get("skip_validation", False)), - minimal_mode=bool(data.get("minimal_mode", False)), - test_types_required=list(data.get("test_types_required", ["unit"])), - security_scan_required=bool(data.get("security_scan_required", False)), - staging_deployment_required=bool( - data.get("staging_deployment_required", False) - ), - reasoning=str(data.get("reasoning", "")), - ) - else: - # Infer from analysis (backward compatibility) - return self._infer_validation_recommendations(analysis) - - def _infer_validation_recommendations( - self, analysis: ComplexityAnalysis - ) -> ValidationRecommendations: - """ - Infer validation recommendations from analysis when not explicitly provided. - - This provides backward compatibility with older complexity assessments - that don't have the validation_recommendations section. - """ - risk_level = analysis.risk.level - - # Map old risk levels to new ones - risk_mapping = { - "low": "low", - "medium": "medium", - "high": "high", - } - normalized_risk = risk_mapping.get(risk_level, "medium") - - # Infer test types based on risk - test_types_map = { - "low": ["unit"], - "medium": ["unit", "integration"], - "high": ["unit", "integration", "e2e"], - } - test_types = test_types_map.get(normalized_risk, ["unit", "integration"]) - - # Security scan for high risk or security-related concerns - security_keywords = [ - "security", - "auth", - "password", - "credential", - "token", - "api key", - ] - has_security_concerns = any( - kw in str(analysis.risk.concerns).lower() for kw in security_keywords - ) - security_scan_required = normalized_risk == "high" or has_security_concerns - - # Staging for database or infrastructure changes - staging_required = ( - analysis.infrastructure.database_changes - and normalized_risk in ["medium", "high"] - ) - - # Minimal mode for simple changes - minimal_mode = ( - analysis.scope.estimated_files <= 2 - and analysis.scope.estimated_services <= 1 - and not analysis.integrations.external_services - ) - - return ValidationRecommendations( - risk_level=normalized_risk, - skip_validation=False, # Never skip by inference - minimal_mode=minimal_mode, - test_types_required=test_types, - security_scan_required=security_scan_required, - staging_deployment_required=staging_required, - reasoning="Inferred from complexity analysis (no explicit recommendations found)", - ) - - def should_skip_validation(self, spec_dir: Path) -> bool: - """ - Quick check if validation can be skipped entirely. - - Args: - spec_dir: Path to the spec directory - - Returns: - True if validation can be skipped (trivial changes), False otherwise - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return False # When in doubt, don't skip - - return assessment.validation.skip_validation - - def should_use_minimal_mode(self, spec_dir: Path) -> bool: - """ - Check if minimal validation mode should be used. - - Args: - spec_dir: Path to the spec directory - - Returns: - True if minimal mode is recommended, False otherwise - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return False - - return assessment.validation.minimal_mode - - def get_required_test_types(self, spec_dir: Path) -> list[str]: - """ - Get list of required test types based on risk. - - Args: - spec_dir: Path to the spec directory - - Returns: - List of test types (e.g., ["unit", "integration", "e2e"]) - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return ["unit"] # Default to unit tests - - return assessment.validation.test_types_required - - def requires_security_scan(self, spec_dir: Path) -> bool: - """ - Check if security scanning is required. - - Args: - spec_dir: Path to the spec directory - - Returns: - True if security scan is required, False otherwise - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return False - - return assessment.validation.security_scan_required - - def requires_staging_deployment(self, spec_dir: Path) -> bool: - """ - Check if staging deployment is required. - - Args: - spec_dir: Path to the spec directory - - Returns: - True if staging deployment is required, False otherwise - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return False - - return assessment.validation.staging_deployment_required - - def get_risk_level(self, spec_dir: Path) -> str: - """ - Get the risk level for the task. - - Args: - spec_dir: Path to the spec directory - - Returns: - Risk level string (trivial, low, medium, high, critical) - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return "medium" # Default to medium when unknown - - return assessment.validation.risk_level - - def get_complexity(self, spec_dir: Path) -> str: - """ - Get the complexity level for the task. - - Args: - spec_dir: Path to the spec directory - - Returns: - Complexity level string (simple, standard, complex) - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return "standard" # Default to standard when unknown - - return assessment.complexity - - def get_validation_summary(self, spec_dir: Path) -> dict[str, Any]: - """ - Get a summary of validation requirements. - - Args: - spec_dir: Path to the spec directory - - Returns: - Dictionary with validation summary - """ - assessment = self.load_assessment(spec_dir) - if not assessment: - return { - "risk_level": "unknown", - "complexity": "unknown", - "skip_validation": False, - "minimal_mode": False, - "test_types": ["unit"], - "security_scan": False, - "staging_deployment": False, - "confidence": 0.0, - } - - return { - "risk_level": assessment.validation.risk_level, - "complexity": assessment.complexity, - "skip_validation": assessment.validation.skip_validation, - "minimal_mode": assessment.validation.minimal_mode, - "test_types": assessment.validation.test_types_required, - "security_scan": assessment.validation.security_scan_required, - "staging_deployment": assessment.validation.staging_deployment_required, - "confidence": assessment.confidence, - "reasoning": assessment.validation.reasoning, - } - - def clear_cache(self) -> None: - """Clear the internal cache of loaded assessments.""" - self._cache.clear() - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def load_risk_assessment(spec_dir: Path) -> RiskAssessment | None: - """ - Convenience function to load a risk assessment. - - Args: - spec_dir: Path to the spec directory - - Returns: - RiskAssessment object or None - """ - classifier = RiskClassifier() - return classifier.load_assessment(spec_dir) - - -def get_validation_requirements(spec_dir: Path) -> dict[str, Any]: - """ - Convenience function to get validation requirements. - - Args: - spec_dir: Path to the spec directory - - Returns: - Dictionary with validation requirements - """ - classifier = RiskClassifier() - return classifier.get_validation_summary(spec_dir) - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Load and display risk assessment") - parser.add_argument( - "spec_dir", - type=Path, - help="Path to spec directory with complexity_assessment.json", - ) - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - classifier = RiskClassifier() - summary = classifier.get_validation_summary(args.spec_dir) - - if args.json: - print(json.dumps(summary, indent=2)) - else: - print(f"Risk Level: {summary['risk_level']}") - print(f"Complexity: {summary['complexity']}") - print(f"Skip Validation: {summary['skip_validation']}") - print(f"Minimal Mode: {summary['minimal_mode']}") - print(f"Test Types: {', '.join(summary['test_types'])}") - print(f"Security Scan: {summary['security_scan']}") - print(f"Staging Deployment: {summary['staging_deployment']}") - print(f"Confidence: {summary['confidence']:.2f}") - if summary.get("reasoning"): - print(f"Reasoning: {summary['reasoning']}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from analysis.risk_classifier instead.""" +from analysis.risk_classifier import ( + RiskClassifier, + RiskAssessment, + ValidationRecommendations, + ComplexityAnalysis, + ScopeAnalysis, + IntegrationAnalysis, + InfrastructureAnalysis, + KnowledgeAnalysis, + RiskAnalysis, + AssessmentFlags, + load_risk_assessment, + get_validation_requirements, +) + +__all__ = [ + "RiskClassifier", + "RiskAssessment", + "ValidationRecommendations", + "ComplexityAnalysis", + "ScopeAnalysis", + "IntegrationAnalysis", + "InfrastructureAnalysis", + "KnowledgeAnalysis", + "RiskAnalysis", + "AssessmentFlags", + "load_risk_assessment", + "get_validation_requirements", +] diff --git a/auto-claude/runners/__init__.py b/auto-claude/runners/__init__.py new file mode 100644 index 00000000..ae49fb7f --- /dev/null +++ b/auto-claude/runners/__init__.py @@ -0,0 +1,21 @@ +""" +Runners Module +============== + +Standalone runners for various Auto Claude capabilities. +Each runner can be invoked from CLI or programmatically. +""" + +from .spec_runner import main as run_spec +from .roadmap_runner import main as run_roadmap +from .ideation_runner import main as run_ideation +from .insights_runner import main as run_insights +from .ai_analyzer_runner import main as run_ai_analyzer + +__all__ = [ + "run_spec", + "run_roadmap", + "run_ideation", + "run_insights", + "run_ai_analyzer", +] diff --git a/auto-claude/ai_analyzer/EXAMPLES.md b/auto-claude/runners/ai_analyzer/EXAMPLES.md similarity index 100% rename from auto-claude/ai_analyzer/EXAMPLES.md rename to auto-claude/runners/ai_analyzer/EXAMPLES.md diff --git a/auto-claude/ai_analyzer/README.md b/auto-claude/runners/ai_analyzer/README.md similarity index 100% rename from auto-claude/ai_analyzer/README.md rename to auto-claude/runners/ai_analyzer/README.md diff --git a/auto-claude/ai_analyzer/REFACTORING.md b/auto-claude/runners/ai_analyzer/REFACTORING.md similarity index 100% rename from auto-claude/ai_analyzer/REFACTORING.md rename to auto-claude/runners/ai_analyzer/REFACTORING.md diff --git a/auto-claude/ai_analyzer/__init__.py b/auto-claude/runners/ai_analyzer/__init__.py similarity index 100% rename from auto-claude/ai_analyzer/__init__.py rename to auto-claude/runners/ai_analyzer/__init__.py diff --git a/auto-claude/ai_analyzer/analyzers.py b/auto-claude/runners/ai_analyzer/analyzers.py similarity index 100% rename from auto-claude/ai_analyzer/analyzers.py rename to auto-claude/runners/ai_analyzer/analyzers.py diff --git a/auto-claude/ai_analyzer/cache_manager.py b/auto-claude/runners/ai_analyzer/cache_manager.py similarity index 100% rename from auto-claude/ai_analyzer/cache_manager.py rename to auto-claude/runners/ai_analyzer/cache_manager.py diff --git a/auto-claude/ai_analyzer/claude_client.py b/auto-claude/runners/ai_analyzer/claude_client.py similarity index 100% rename from auto-claude/ai_analyzer/claude_client.py rename to auto-claude/runners/ai_analyzer/claude_client.py diff --git a/auto-claude/ai_analyzer/cost_estimator.py b/auto-claude/runners/ai_analyzer/cost_estimator.py similarity index 100% rename from auto-claude/ai_analyzer/cost_estimator.py rename to auto-claude/runners/ai_analyzer/cost_estimator.py diff --git a/auto-claude/ai_analyzer/models.py b/auto-claude/runners/ai_analyzer/models.py similarity index 100% rename from auto-claude/ai_analyzer/models.py rename to auto-claude/runners/ai_analyzer/models.py diff --git a/auto-claude/ai_analyzer/result_parser.py b/auto-claude/runners/ai_analyzer/result_parser.py similarity index 100% rename from auto-claude/ai_analyzer/result_parser.py rename to auto-claude/runners/ai_analyzer/result_parser.py diff --git a/auto-claude/ai_analyzer/runner.py b/auto-claude/runners/ai_analyzer/runner.py similarity index 100% rename from auto-claude/ai_analyzer/runner.py rename to auto-claude/runners/ai_analyzer/runner.py diff --git a/auto-claude/ai_analyzer/summary_printer.py b/auto-claude/runners/ai_analyzer/summary_printer.py similarity index 100% rename from auto-claude/ai_analyzer/summary_printer.py rename to auto-claude/runners/ai_analyzer/summary_printer.py diff --git a/auto-claude/ai_analyzer_runner.py b/auto-claude/runners/ai_analyzer_runner.py similarity index 100% rename from auto-claude/ai_analyzer_runner.py rename to auto-claude/runners/ai_analyzer_runner.py diff --git a/auto-claude/ideation_runner.py b/auto-claude/runners/ideation_runner.py similarity index 100% rename from auto-claude/ideation_runner.py rename to auto-claude/runners/ideation_runner.py diff --git a/auto-claude/insights_runner.py b/auto-claude/runners/insights_runner.py similarity index 100% rename from auto-claude/insights_runner.py rename to auto-claude/runners/insights_runner.py diff --git a/auto-claude/roadmap/__init__.py b/auto-claude/runners/roadmap/__init__.py similarity index 100% rename from auto-claude/roadmap/__init__.py rename to auto-claude/runners/roadmap/__init__.py diff --git a/auto-claude/roadmap/competitor_analyzer.py b/auto-claude/runners/roadmap/competitor_analyzer.py similarity index 100% rename from auto-claude/roadmap/competitor_analyzer.py rename to auto-claude/runners/roadmap/competitor_analyzer.py diff --git a/auto-claude/roadmap/executor.py b/auto-claude/runners/roadmap/executor.py similarity index 100% rename from auto-claude/roadmap/executor.py rename to auto-claude/runners/roadmap/executor.py diff --git a/auto-claude/roadmap/graph_integration.py b/auto-claude/runners/roadmap/graph_integration.py similarity index 100% rename from auto-claude/roadmap/graph_integration.py rename to auto-claude/runners/roadmap/graph_integration.py diff --git a/auto-claude/roadmap/models.py b/auto-claude/runners/roadmap/models.py similarity index 100% rename from auto-claude/roadmap/models.py rename to auto-claude/runners/roadmap/models.py diff --git a/auto-claude/roadmap/orchestrator.py b/auto-claude/runners/roadmap/orchestrator.py similarity index 100% rename from auto-claude/roadmap/orchestrator.py rename to auto-claude/runners/roadmap/orchestrator.py diff --git a/auto-claude/roadmap/phases.py b/auto-claude/runners/roadmap/phases.py similarity index 100% rename from auto-claude/roadmap/phases.py rename to auto-claude/runners/roadmap/phases.py diff --git a/auto-claude/roadmap/project_index.json b/auto-claude/runners/roadmap/project_index.json similarity index 100% rename from auto-claude/roadmap/project_index.json rename to auto-claude/runners/roadmap/project_index.json diff --git a/auto-claude/roadmap_runner.py b/auto-claude/runners/roadmap_runner.py similarity index 100% rename from auto-claude/roadmap_runner.py rename to auto-claude/runners/roadmap_runner.py diff --git a/auto-claude/spec_runner.py b/auto-claude/runners/spec_runner.py similarity index 100% rename from auto-claude/spec_runner.py rename to auto-claude/runners/spec_runner.py diff --git a/auto-claude/scan_secrets.py b/auto-claude/scan_secrets.py index ab4c3138..abb89026 100644 --- a/auto-claude/scan_secrets.py +++ b/auto-claude/scan_secrets.py @@ -1,561 +1,2 @@ -#!/usr/bin/env python3 -""" -Secret Scanning Script for Auto-Build Framework -================================================ - -Scans staged git files for potential secrets before commit. -Designed to prevent accidental exposure of API keys, tokens, and credentials. - -Usage: - python scan_secrets.py [--staged-only] [--all-files] [--path PATH] - -Exit codes: - 0 - No secrets detected - 1 - Potential secrets found (commit should be blocked) - 2 - Error occurred during scanning -""" - -import argparse -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -# ============================================================================= -# SECRET PATTERNS -# ============================================================================= - -# Generic high-entropy patterns that match common API key formats -GENERIC_PATTERNS = [ - # Generic API key patterns (32+ char alphanumeric strings assigned to variables) - ( - r'(?:api[_-]?key|apikey|api_secret|secret[_-]?key)\s*[:=]\s*["\']([a-zA-Z0-9_-]{32,})["\']', - "Generic API key assignment", - ), - # Generic token patterns - ( - r'(?:access[_-]?token|auth[_-]?token|bearer[_-]?token|token)\s*[:=]\s*["\']([a-zA-Z0-9_-]{32,})["\']', - "Generic access token", - ), - # Password patterns - ( - r'(?:password|passwd|pwd|pass)\s*[:=]\s*["\']([^"\']{8,})["\']', - "Password assignment", - ), - # Generic secret patterns - ( - r'(?:secret|client_secret|app_secret)\s*[:=]\s*["\']([a-zA-Z0-9_/+=]{16,})["\']', - "Secret assignment", - ), - # Bearer tokens in headers - (r'["\']?[Bb]earer\s+([a-zA-Z0-9_-]{20,})["\']?', "Bearer token"), - # Base64-encoded secrets (longer than typical, may be credentials) - (r'["\'][A-Za-z0-9+/]{64,}={0,2}["\']', "Potential base64-encoded secret"), -] - -# Service-specific patterns (known formats) -SERVICE_PATTERNS = [ - # OpenAI / Anthropic style keys - (r"sk-[a-zA-Z0-9]{20,}", "OpenAI/Anthropic-style API key"), - (r"sk-ant-[a-zA-Z0-9-]{20,}", "Anthropic API key"), - (r"sk-proj-[a-zA-Z0-9-]{20,}", "OpenAI project API key"), - # AWS - (r"AKIA[0-9A-Z]{16}", "AWS Access Key ID"), - ( - r'(?:aws_secret_access_key|aws_secret)\s*[:=]\s*["\']?([a-zA-Z0-9/+=]{40})["\']?', - "AWS Secret Access Key", - ), - # Google Cloud - (r"AIza[0-9A-Za-z_-]{35}", "Google API Key"), - (r'"type"\s*:\s*"service_account"', "Google Service Account JSON"), - # GitHub - (r"ghp_[a-zA-Z0-9]{36}", "GitHub Personal Access Token"), - (r"github_pat_[a-zA-Z0-9_]{22,}", "GitHub Fine-grained PAT"), - (r"gho_[a-zA-Z0-9]{36}", "GitHub OAuth Token"), - (r"ghs_[a-zA-Z0-9]{36}", "GitHub App Installation Token"), - (r"ghr_[a-zA-Z0-9]{36}", "GitHub Refresh Token"), - # Stripe - (r"sk_live_[0-9a-zA-Z]{24,}", "Stripe Live Secret Key"), - (r"sk_test_[0-9a-zA-Z]{24,}", "Stripe Test Secret Key"), - (r"pk_live_[0-9a-zA-Z]{24,}", "Stripe Live Publishable Key"), - (r"rk_live_[0-9a-zA-Z]{24,}", "Stripe Restricted Key"), - # Slack - (r"xox[baprs]-[0-9a-zA-Z-]{10,}", "Slack Token"), - (r"https://hooks\.slack\.com/services/[A-Z0-9/]+", "Slack Webhook URL"), - # Discord - (r"[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}", "Discord Bot Token"), - (r"https://discord(?:app)?\.com/api/webhooks/\d+/[\w-]+", "Discord Webhook URL"), - # Twilio - (r"SK[a-f0-9]{32}", "Twilio API Key"), - (r"AC[a-f0-9]{32}", "Twilio Account SID"), - # SendGrid - (r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "SendGrid API Key"), - # Mailchimp - (r"[a-f0-9]{32}-us\d+", "Mailchimp API Key"), - # NPM - (r"npm_[a-zA-Z0-9]{36}", "NPM Access Token"), - # PyPI - (r"pypi-[a-zA-Z0-9]{60,}", "PyPI API Token"), - # Supabase/JWT - (r"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}", "Supabase/JWT Token"), - # Linear - (r"lin_api_[a-zA-Z0-9]{40,}", "Linear API Key"), - # Vercel - (r"[a-zA-Z0-9]{24}_[a-zA-Z0-9]{28,}", "Potential Vercel Token"), - # Heroku - ( - r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", - "Heroku API Key / UUID", - ), - # Doppler - (r"dp\.pt\.[a-zA-Z0-9]{40,}", "Doppler Service Token"), -] - -# Private key patterns -PRIVATE_KEY_PATTERNS = [ - (r"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----", "RSA Private Key"), - (r"-----BEGIN\s+OPENSSH\s+PRIVATE\s+KEY-----", "OpenSSH Private Key"), - (r"-----BEGIN\s+DSA\s+PRIVATE\s+KEY-----", "DSA Private Key"), - (r"-----BEGIN\s+EC\s+PRIVATE\s+KEY-----", "EC Private Key"), - (r"-----BEGIN\s+PGP\s+PRIVATE\s+KEY\s+BLOCK-----", "PGP Private Key"), - (r"-----BEGIN\s+CERTIFICATE-----", "Certificate (may contain private key)"), -] - -# Database connection strings with embedded credentials -DATABASE_PATTERNS = [ - ( - r'mongodb(?:\+srv)?://[^"\s:]+:[^@"\s]+@[^\s"]+', - "MongoDB Connection String with credentials", - ), - ( - r'postgres(?:ql)?://[^"\s:]+:[^@"\s]+@[^\s"]+', - "PostgreSQL Connection String with credentials", - ), - (r'mysql://[^"\s:]+:[^@"\s]+@[^\s"]+', "MySQL Connection String with credentials"), - (r'redis://[^"\s:]+:[^@"\s]+@[^\s"]+', "Redis Connection String with credentials"), - ( - r'amqp://[^"\s:]+:[^@"\s]+@[^\s"]+', - "RabbitMQ Connection String with credentials", - ), -] - -# Combine all patterns -ALL_PATTERNS = ( - GENERIC_PATTERNS + SERVICE_PATTERNS + PRIVATE_KEY_PATTERNS + DATABASE_PATTERNS -) - - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class SecretMatch: - """A potential secret found in a file.""" - - file_path: str - line_number: int - pattern_name: str - matched_text: str - line_content: str - - -# ============================================================================= -# IGNORE LIST -# ============================================================================= - -# Files/directories to always skip -DEFAULT_IGNORE_PATTERNS = [ - r"\.git/", - r"node_modules/", - r"\.venv/", - r"venv/", - r"__pycache__/", - r"\.pyc$", - r"dist/", - r"build/", - r"\.egg-info/", - r"\.example$", - r"\.sample$", - r"\.template$", - r"\.md$", # Documentation files - r"\.rst$", - r"\.txt$", - r"package-lock\.json$", - r"yarn\.lock$", - r"pnpm-lock\.yaml$", - r"Cargo\.lock$", - r"poetry\.lock$", -] - -# Binary file extensions to skip -BINARY_EXTENSIONS = { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".ico", - ".webp", - ".svg", - ".woff", - ".woff2", - ".ttf", - ".eot", - ".otf", - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".zip", - ".tar", - ".gz", - ".bz2", - ".7z", - ".rar", - ".exe", - ".dll", - ".so", - ".dylib", - ".mp3", - ".mp4", - ".wav", - ".avi", - ".mov", - ".pyc", - ".pyo", - ".class", - ".o", -} - -# False positive patterns to filter out -FALSE_POSITIVE_PATTERNS = [ - r"process\.env\.", # Environment variable references - r"os\.environ", # Python env references - r"ENV\[", # Ruby/other env references - r"\$\{[A-Z_]+\}", # Shell variable substitution - r"your[-_]?api[-_]?key", # Placeholder values - r"xxx+", # Placeholder - r"placeholder", # Placeholder - r"example", # Example value - r"sample", # Sample value - r"test[-_]?key", # Test placeholder - r"<[A-Z_]+>", # Placeholder like - r"TODO", # Comment markers - r"FIXME", - r"CHANGEME", - r"INSERT[-_]?YOUR", - r"REPLACE[-_]?WITH", -] - - -# ============================================================================= -# CORE FUNCTIONS -# ============================================================================= - - -def load_secretsignore(project_dir: Path) -> list[str]: - """Load custom ignore patterns from .secretsignore file.""" - ignore_file = project_dir / ".secretsignore" - if not ignore_file.exists(): - return [] - - patterns = [] - try: - content = ignore_file.read_text() - for line in content.splitlines(): - line = line.strip() - # Skip comments and empty lines - if line and not line.startswith("#"): - patterns.append(line) - except OSError: - pass - - return patterns - - -def should_skip_file(file_path: str, custom_ignores: list[str]) -> bool: - """Check if a file should be skipped based on ignore patterns.""" - path = Path(file_path) - - # Check binary extensions - if path.suffix.lower() in BINARY_EXTENSIONS: - return True - - # Check default ignore patterns - for pattern in DEFAULT_IGNORE_PATTERNS: - if re.search(pattern, file_path): - return True - - # Check custom ignore patterns - for pattern in custom_ignores: - if re.search(pattern, file_path): - return True - - return False - - -def is_false_positive(line: str, matched_text: str) -> bool: - """Check if a match is likely a false positive.""" - line_lower = line.lower() - - for pattern in FALSE_POSITIVE_PATTERNS: - if re.search(pattern, line_lower): - return True - - # Check if it's just a variable name or type hint - if re.match(r"^[a-z_]+:\s*str\s*$", line.strip(), re.IGNORECASE): - return True - - # Check if it's in a comment - stripped = line.strip() - if ( - stripped.startswith("#") - or stripped.startswith("//") - or stripped.startswith("*") - ): - # But still flag if there's an actual long key-like string - if not re.search(r"[a-zA-Z0-9_-]{40,}", matched_text): - return True - - return False - - -def mask_secret(text: str, visible_chars: int = 8) -> str: - """Mask a secret, showing only first few characters.""" - if len(text) <= visible_chars: - return text - return text[:visible_chars] + "***" - - -def scan_content(content: str, file_path: str) -> list[SecretMatch]: - """Scan file content for potential secrets.""" - matches = [] - lines = content.splitlines() - - for line_num, line in enumerate(lines, 1): - for pattern, pattern_name in ALL_PATTERNS: - try: - for match in re.finditer(pattern, line, re.IGNORECASE): - matched_text = match.group(0) - - # Skip false positives - if is_false_positive(line, matched_text): - continue - - matches.append( - SecretMatch( - file_path=file_path, - line_number=line_num, - pattern_name=pattern_name, - matched_text=matched_text, - line_content=line.strip()[:100], # Truncate long lines - ) - ) - except re.error: - # Invalid regex, skip - continue - - return matches - - -def get_staged_files() -> list[str]: - """Get list of staged files from git (excluding deleted files).""" - try: - result = subprocess.run( - ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], - capture_output=True, - text=True, - check=True, - ) - files = [f.strip() for f in result.stdout.splitlines() if f.strip()] - return files - except subprocess.CalledProcessError: - return [] - - -def get_all_tracked_files() -> list[str]: - """Get all tracked files in the repository.""" - try: - result = subprocess.run( - ["git", "ls-files"], - capture_output=True, - text=True, - check=True, - ) - files = [f.strip() for f in result.stdout.splitlines() if f.strip()] - return files - except subprocess.CalledProcessError: - return [] - - -def scan_files( - files: list[str], - project_dir: Path | None = None, -) -> list[SecretMatch]: - """Scan a list of files for secrets.""" - if project_dir is None: - project_dir = Path.cwd() - - custom_ignores = load_secretsignore(project_dir) - all_matches = [] - - for file_path in files: - # Skip files based on ignore patterns - if should_skip_file(file_path, custom_ignores): - continue - - full_path = project_dir / file_path - - # Skip if file doesn't exist or is a directory - if not full_path.exists() or full_path.is_dir(): - continue - - try: - content = full_path.read_text(encoding="utf-8", errors="ignore") - matches = scan_content(content, file_path) - all_matches.extend(matches) - except (OSError, UnicodeDecodeError): - # Skip files that can't be read - continue - - return all_matches - - -# ============================================================================= -# OUTPUT FORMATTING -# ============================================================================= - -# ANSI color codes -RED = "\033[0;31m" -GREEN = "\033[0;32m" -YELLOW = "\033[1;33m" -CYAN = "\033[0;36m" -NC = "\033[0m" # No Color - - -def print_results(matches: list[SecretMatch]) -> None: - """Print scan results in a formatted way.""" - if not matches: - print(f"{GREEN}No secrets detected. Commit allowed.{NC}") - return - - print(f"{RED}POTENTIAL SECRETS DETECTED!{NC}") - print(f"{RED}{'=' * 60}{NC}") - - # Group by file - files_with_matches: dict[str, list[SecretMatch]] = {} - for match in matches: - if match.file_path not in files_with_matches: - files_with_matches[match.file_path] = [] - files_with_matches[match.file_path].append(match) - - for file_path, file_matches in files_with_matches.items(): - print(f"\n{YELLOW}File: {file_path}{NC}") - for match in file_matches: - masked = mask_secret(match.matched_text) - print(f" Line {match.line_number}: [{match.pattern_name}]") - print(f" {CYAN}{masked}{NC}") - - print(f"\n{RED}{'=' * 60}{NC}") - print(f"\n{YELLOW}If these are false positives, you can:{NC}") - print(" 1. Add patterns to .secretsignore (create if needed)") - print(" 2. Use environment variables instead of hardcoded values") - print() - print(f"{RED}Commit blocked to protect against leaking secrets.{NC}") - - -def print_json_results(matches: list[SecretMatch]) -> None: - """Print scan results as JSON (for programmatic use).""" - import json - - results = { - "secrets_found": len(matches) > 0, - "count": len(matches), - "matches": [ - { - "file": m.file_path, - "line": m.line_number, - "type": m.pattern_name, - "preview": mask_secret(m.matched_text), - } - for m in matches - ], - } - print(json.dumps(results, indent=2)) - - -# ============================================================================= -# MAIN -# ============================================================================= - - -def main() -> int: - """Main entry point.""" - parser = argparse.ArgumentParser( - description="Scan files for potential secrets before commit" - ) - parser.add_argument( - "--staged-only", - "-s", - action="store_true", - default=True, - help="Only scan staged files (default)", - ) - parser.add_argument( - "--all-files", "-a", action="store_true", help="Scan all tracked files" - ) - parser.add_argument( - "--path", "-p", type=str, help="Scan a specific file or directory" - ) - parser.add_argument("--json", action="store_true", help="Output results as JSON") - parser.add_argument( - "--quiet", "-q", action="store_true", help="Only output if secrets are found" - ) - - args = parser.parse_args() - - project_dir = Path.cwd() - - # Determine which files to scan - if args.path: - path = Path(args.path) - if path.is_file(): - files = [str(path)] - elif path.is_dir(): - files = [ - str(f.relative_to(project_dir)) for f in path.rglob("*") if f.is_file() - ] - else: - print(f"{RED}Error: Path not found: {args.path}{NC}", file=sys.stderr) - return 2 - elif args.all_files: - files = get_all_tracked_files() - else: - files = get_staged_files() - - if not files: - if not args.quiet: - print(f"{GREEN}No files to scan.{NC}") - return 0 - - if not args.quiet and not args.json: - print(f"Scanning {len(files)} file(s) for secrets...") - - # Scan files - matches = scan_files(files, project_dir) - - # Output results - if args.json: - print_json_results(matches) - elif matches or not args.quiet: - print_results(matches) - - # Return exit code - return 1 if matches else 0 - - -if __name__ == "__main__": - sys.exit(main()) +"""Backward compatibility shim - import from security.scan_secrets instead.""" +from security.scan_secrets import * diff --git a/auto-claude/security.py b/auto-claude/security.py index 13364900..49caf200 100644 --- a/auto-claude/security.py +++ b/auto-claude/security.py @@ -1,94 +1,2 @@ -""" -Security Hooks for Auto-Build Framework -======================================= - -BACKWARD COMPATIBILITY FACADE - -This module maintains the original API for backward compatibility. -All functionality has been refactored into the security/ submodule: - -- security/validator.py - Command validation logic -- security/parser.py - Command parsing utilities -- security/profile.py - Security profile management -- security/hooks.py - Security hook implementations -- security/__init__.py - Public API exports - -See security/ directory for the actual implementation. - -The security system has three layers: -1. Base commands - Always allowed (core shell utilities) -2. Stack commands - Detected from project structure (frameworks, languages) -3. Custom commands - User-defined allowlist - -See project_analyzer.py for the detection logic. -""" - -# Import everything from the security module to maintain backward compatibility -from security import * # noqa: F401, F403 - -# Explicitly import commonly used items for clarity -from security import ( - BASE_COMMANDS, - VALIDATORS, - SecurityProfile, - bash_security_hook, - extract_commands, - get_command_for_validation, - get_security_profile, - is_command_allowed, - needs_validation, - reset_profile_cache, - split_command_segments, - validate_command, -) - -# Re-export for backward compatibility -__all__ = [ - "bash_security_hook", - "validate_command", - "get_security_profile", - "reset_profile_cache", - "extract_commands", - "split_command_segments", - "get_command_for_validation", - "VALIDATORS", - "SecurityProfile", - "is_command_allowed", - "needs_validation", - "BASE_COMMANDS", -] - - -# ============================================================================= -# CLI for testing (maintained for backward compatibility) -# ============================================================================= - -if __name__ == "__main__": - import sys - from pathlib import Path - - if len(sys.argv) < 2: - print("Usage: python security.py ") - print(" python security.py --list [project_dir]") - sys.exit(1) - - if sys.argv[1] == "--list": - # List all allowed commands for a project - project_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path.cwd() - profile = get_security_profile(project_dir) - - print("\nAllowed commands:") - for cmd in sorted(profile.get_all_allowed_commands()): - print(f" {cmd}") - - print(f"\nTotal: {len(profile.get_all_allowed_commands())} commands") - else: - # Validate a command - command = " ".join(sys.argv[1:]) - is_allowed, reason = validate_command(command) - - if is_allowed: - print(f"✓ ALLOWED: {command}") - else: - print(f"✗ BLOCKED: {command}") - print(f" Reason: {reason}") +"""Backward compatibility shim - import from security module instead.""" +from security import * diff --git a/auto-claude/security/main.py b/auto-claude/security/main.py new file mode 100644 index 00000000..13364900 --- /dev/null +++ b/auto-claude/security/main.py @@ -0,0 +1,94 @@ +""" +Security Hooks for Auto-Build Framework +======================================= + +BACKWARD COMPATIBILITY FACADE + +This module maintains the original API for backward compatibility. +All functionality has been refactored into the security/ submodule: + +- security/validator.py - Command validation logic +- security/parser.py - Command parsing utilities +- security/profile.py - Security profile management +- security/hooks.py - Security hook implementations +- security/__init__.py - Public API exports + +See security/ directory for the actual implementation. + +The security system has three layers: +1. Base commands - Always allowed (core shell utilities) +2. Stack commands - Detected from project structure (frameworks, languages) +3. Custom commands - User-defined allowlist + +See project_analyzer.py for the detection logic. +""" + +# Import everything from the security module to maintain backward compatibility +from security import * # noqa: F401, F403 + +# Explicitly import commonly used items for clarity +from security import ( + BASE_COMMANDS, + VALIDATORS, + SecurityProfile, + bash_security_hook, + extract_commands, + get_command_for_validation, + get_security_profile, + is_command_allowed, + needs_validation, + reset_profile_cache, + split_command_segments, + validate_command, +) + +# Re-export for backward compatibility +__all__ = [ + "bash_security_hook", + "validate_command", + "get_security_profile", + "reset_profile_cache", + "extract_commands", + "split_command_segments", + "get_command_for_validation", + "VALIDATORS", + "SecurityProfile", + "is_command_allowed", + "needs_validation", + "BASE_COMMANDS", +] + + +# ============================================================================= +# CLI for testing (maintained for backward compatibility) +# ============================================================================= + +if __name__ == "__main__": + import sys + from pathlib import Path + + if len(sys.argv) < 2: + print("Usage: python security.py ") + print(" python security.py --list [project_dir]") + sys.exit(1) + + if sys.argv[1] == "--list": + # List all allowed commands for a project + project_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path.cwd() + profile = get_security_profile(project_dir) + + print("\nAllowed commands:") + for cmd in sorted(profile.get_all_allowed_commands()): + print(f" {cmd}") + + print(f"\nTotal: {len(profile.get_all_allowed_commands())} commands") + else: + # Validate a command + command = " ".join(sys.argv[1:]) + is_allowed, reason = validate_command(command) + + if is_allowed: + print(f"✓ ALLOWED: {command}") + else: + print(f"✗ BLOCKED: {command}") + print(f" Reason: {reason}") diff --git a/auto-claude/security/scan_secrets.py b/auto-claude/security/scan_secrets.py new file mode 100644 index 00000000..ab4c3138 --- /dev/null +++ b/auto-claude/security/scan_secrets.py @@ -0,0 +1,561 @@ +#!/usr/bin/env python3 +""" +Secret Scanning Script for Auto-Build Framework +================================================ + +Scans staged git files for potential secrets before commit. +Designed to prevent accidental exposure of API keys, tokens, and credentials. + +Usage: + python scan_secrets.py [--staged-only] [--all-files] [--path PATH] + +Exit codes: + 0 - No secrets detected + 1 - Potential secrets found (commit should be blocked) + 2 - Error occurred during scanning +""" + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +# ============================================================================= +# SECRET PATTERNS +# ============================================================================= + +# Generic high-entropy patterns that match common API key formats +GENERIC_PATTERNS = [ + # Generic API key patterns (32+ char alphanumeric strings assigned to variables) + ( + r'(?:api[_-]?key|apikey|api_secret|secret[_-]?key)\s*[:=]\s*["\']([a-zA-Z0-9_-]{32,})["\']', + "Generic API key assignment", + ), + # Generic token patterns + ( + r'(?:access[_-]?token|auth[_-]?token|bearer[_-]?token|token)\s*[:=]\s*["\']([a-zA-Z0-9_-]{32,})["\']', + "Generic access token", + ), + # Password patterns + ( + r'(?:password|passwd|pwd|pass)\s*[:=]\s*["\']([^"\']{8,})["\']', + "Password assignment", + ), + # Generic secret patterns + ( + r'(?:secret|client_secret|app_secret)\s*[:=]\s*["\']([a-zA-Z0-9_/+=]{16,})["\']', + "Secret assignment", + ), + # Bearer tokens in headers + (r'["\']?[Bb]earer\s+([a-zA-Z0-9_-]{20,})["\']?', "Bearer token"), + # Base64-encoded secrets (longer than typical, may be credentials) + (r'["\'][A-Za-z0-9+/]{64,}={0,2}["\']', "Potential base64-encoded secret"), +] + +# Service-specific patterns (known formats) +SERVICE_PATTERNS = [ + # OpenAI / Anthropic style keys + (r"sk-[a-zA-Z0-9]{20,}", "OpenAI/Anthropic-style API key"), + (r"sk-ant-[a-zA-Z0-9-]{20,}", "Anthropic API key"), + (r"sk-proj-[a-zA-Z0-9-]{20,}", "OpenAI project API key"), + # AWS + (r"AKIA[0-9A-Z]{16}", "AWS Access Key ID"), + ( + r'(?:aws_secret_access_key|aws_secret)\s*[:=]\s*["\']?([a-zA-Z0-9/+=]{40})["\']?', + "AWS Secret Access Key", + ), + # Google Cloud + (r"AIza[0-9A-Za-z_-]{35}", "Google API Key"), + (r'"type"\s*:\s*"service_account"', "Google Service Account JSON"), + # GitHub + (r"ghp_[a-zA-Z0-9]{36}", "GitHub Personal Access Token"), + (r"github_pat_[a-zA-Z0-9_]{22,}", "GitHub Fine-grained PAT"), + (r"gho_[a-zA-Z0-9]{36}", "GitHub OAuth Token"), + (r"ghs_[a-zA-Z0-9]{36}", "GitHub App Installation Token"), + (r"ghr_[a-zA-Z0-9]{36}", "GitHub Refresh Token"), + # Stripe + (r"sk_live_[0-9a-zA-Z]{24,}", "Stripe Live Secret Key"), + (r"sk_test_[0-9a-zA-Z]{24,}", "Stripe Test Secret Key"), + (r"pk_live_[0-9a-zA-Z]{24,}", "Stripe Live Publishable Key"), + (r"rk_live_[0-9a-zA-Z]{24,}", "Stripe Restricted Key"), + # Slack + (r"xox[baprs]-[0-9a-zA-Z-]{10,}", "Slack Token"), + (r"https://hooks\.slack\.com/services/[A-Z0-9/]+", "Slack Webhook URL"), + # Discord + (r"[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}", "Discord Bot Token"), + (r"https://discord(?:app)?\.com/api/webhooks/\d+/[\w-]+", "Discord Webhook URL"), + # Twilio + (r"SK[a-f0-9]{32}", "Twilio API Key"), + (r"AC[a-f0-9]{32}", "Twilio Account SID"), + # SendGrid + (r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "SendGrid API Key"), + # Mailchimp + (r"[a-f0-9]{32}-us\d+", "Mailchimp API Key"), + # NPM + (r"npm_[a-zA-Z0-9]{36}", "NPM Access Token"), + # PyPI + (r"pypi-[a-zA-Z0-9]{60,}", "PyPI API Token"), + # Supabase/JWT + (r"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}", "Supabase/JWT Token"), + # Linear + (r"lin_api_[a-zA-Z0-9]{40,}", "Linear API Key"), + # Vercel + (r"[a-zA-Z0-9]{24}_[a-zA-Z0-9]{28,}", "Potential Vercel Token"), + # Heroku + ( + r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}", + "Heroku API Key / UUID", + ), + # Doppler + (r"dp\.pt\.[a-zA-Z0-9]{40,}", "Doppler Service Token"), +] + +# Private key patterns +PRIVATE_KEY_PATTERNS = [ + (r"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----", "RSA Private Key"), + (r"-----BEGIN\s+OPENSSH\s+PRIVATE\s+KEY-----", "OpenSSH Private Key"), + (r"-----BEGIN\s+DSA\s+PRIVATE\s+KEY-----", "DSA Private Key"), + (r"-----BEGIN\s+EC\s+PRIVATE\s+KEY-----", "EC Private Key"), + (r"-----BEGIN\s+PGP\s+PRIVATE\s+KEY\s+BLOCK-----", "PGP Private Key"), + (r"-----BEGIN\s+CERTIFICATE-----", "Certificate (may contain private key)"), +] + +# Database connection strings with embedded credentials +DATABASE_PATTERNS = [ + ( + r'mongodb(?:\+srv)?://[^"\s:]+:[^@"\s]+@[^\s"]+', + "MongoDB Connection String with credentials", + ), + ( + r'postgres(?:ql)?://[^"\s:]+:[^@"\s]+@[^\s"]+', + "PostgreSQL Connection String with credentials", + ), + (r'mysql://[^"\s:]+:[^@"\s]+@[^\s"]+', "MySQL Connection String with credentials"), + (r'redis://[^"\s:]+:[^@"\s]+@[^\s"]+', "Redis Connection String with credentials"), + ( + r'amqp://[^"\s:]+:[^@"\s]+@[^\s"]+', + "RabbitMQ Connection String with credentials", + ), +] + +# Combine all patterns +ALL_PATTERNS = ( + GENERIC_PATTERNS + SERVICE_PATTERNS + PRIVATE_KEY_PATTERNS + DATABASE_PATTERNS +) + + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class SecretMatch: + """A potential secret found in a file.""" + + file_path: str + line_number: int + pattern_name: str + matched_text: str + line_content: str + + +# ============================================================================= +# IGNORE LIST +# ============================================================================= + +# Files/directories to always skip +DEFAULT_IGNORE_PATTERNS = [ + r"\.git/", + r"node_modules/", + r"\.venv/", + r"venv/", + r"__pycache__/", + r"\.pyc$", + r"dist/", + r"build/", + r"\.egg-info/", + r"\.example$", + r"\.sample$", + r"\.template$", + r"\.md$", # Documentation files + r"\.rst$", + r"\.txt$", + r"package-lock\.json$", + r"yarn\.lock$", + r"pnpm-lock\.yaml$", + r"Cargo\.lock$", + r"poetry\.lock$", +] + +# Binary file extensions to skip +BINARY_EXTENSIONS = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".webp", + ".svg", + ".woff", + ".woff2", + ".ttf", + ".eot", + ".otf", + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".zip", + ".tar", + ".gz", + ".bz2", + ".7z", + ".rar", + ".exe", + ".dll", + ".so", + ".dylib", + ".mp3", + ".mp4", + ".wav", + ".avi", + ".mov", + ".pyc", + ".pyo", + ".class", + ".o", +} + +# False positive patterns to filter out +FALSE_POSITIVE_PATTERNS = [ + r"process\.env\.", # Environment variable references + r"os\.environ", # Python env references + r"ENV\[", # Ruby/other env references + r"\$\{[A-Z_]+\}", # Shell variable substitution + r"your[-_]?api[-_]?key", # Placeholder values + r"xxx+", # Placeholder + r"placeholder", # Placeholder + r"example", # Example value + r"sample", # Sample value + r"test[-_]?key", # Test placeholder + r"<[A-Z_]+>", # Placeholder like + r"TODO", # Comment markers + r"FIXME", + r"CHANGEME", + r"INSERT[-_]?YOUR", + r"REPLACE[-_]?WITH", +] + + +# ============================================================================= +# CORE FUNCTIONS +# ============================================================================= + + +def load_secretsignore(project_dir: Path) -> list[str]: + """Load custom ignore patterns from .secretsignore file.""" + ignore_file = project_dir / ".secretsignore" + if not ignore_file.exists(): + return [] + + patterns = [] + try: + content = ignore_file.read_text() + for line in content.splitlines(): + line = line.strip() + # Skip comments and empty lines + if line and not line.startswith("#"): + patterns.append(line) + except OSError: + pass + + return patterns + + +def should_skip_file(file_path: str, custom_ignores: list[str]) -> bool: + """Check if a file should be skipped based on ignore patterns.""" + path = Path(file_path) + + # Check binary extensions + if path.suffix.lower() in BINARY_EXTENSIONS: + return True + + # Check default ignore patterns + for pattern in DEFAULT_IGNORE_PATTERNS: + if re.search(pattern, file_path): + return True + + # Check custom ignore patterns + for pattern in custom_ignores: + if re.search(pattern, file_path): + return True + + return False + + +def is_false_positive(line: str, matched_text: str) -> bool: + """Check if a match is likely a false positive.""" + line_lower = line.lower() + + for pattern in FALSE_POSITIVE_PATTERNS: + if re.search(pattern, line_lower): + return True + + # Check if it's just a variable name or type hint + if re.match(r"^[a-z_]+:\s*str\s*$", line.strip(), re.IGNORECASE): + return True + + # Check if it's in a comment + stripped = line.strip() + if ( + stripped.startswith("#") + or stripped.startswith("//") + or stripped.startswith("*") + ): + # But still flag if there's an actual long key-like string + if not re.search(r"[a-zA-Z0-9_-]{40,}", matched_text): + return True + + return False + + +def mask_secret(text: str, visible_chars: int = 8) -> str: + """Mask a secret, showing only first few characters.""" + if len(text) <= visible_chars: + return text + return text[:visible_chars] + "***" + + +def scan_content(content: str, file_path: str) -> list[SecretMatch]: + """Scan file content for potential secrets.""" + matches = [] + lines = content.splitlines() + + for line_num, line in enumerate(lines, 1): + for pattern, pattern_name in ALL_PATTERNS: + try: + for match in re.finditer(pattern, line, re.IGNORECASE): + matched_text = match.group(0) + + # Skip false positives + if is_false_positive(line, matched_text): + continue + + matches.append( + SecretMatch( + file_path=file_path, + line_number=line_num, + pattern_name=pattern_name, + matched_text=matched_text, + line_content=line.strip()[:100], # Truncate long lines + ) + ) + except re.error: + # Invalid regex, skip + continue + + return matches + + +def get_staged_files() -> list[str]: + """Get list of staged files from git (excluding deleted files).""" + try: + result = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, + text=True, + check=True, + ) + files = [f.strip() for f in result.stdout.splitlines() if f.strip()] + return files + except subprocess.CalledProcessError: + return [] + + +def get_all_tracked_files() -> list[str]: + """Get all tracked files in the repository.""" + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + check=True, + ) + files = [f.strip() for f in result.stdout.splitlines() if f.strip()] + return files + except subprocess.CalledProcessError: + return [] + + +def scan_files( + files: list[str], + project_dir: Path | None = None, +) -> list[SecretMatch]: + """Scan a list of files for secrets.""" + if project_dir is None: + project_dir = Path.cwd() + + custom_ignores = load_secretsignore(project_dir) + all_matches = [] + + for file_path in files: + # Skip files based on ignore patterns + if should_skip_file(file_path, custom_ignores): + continue + + full_path = project_dir / file_path + + # Skip if file doesn't exist or is a directory + if not full_path.exists() or full_path.is_dir(): + continue + + try: + content = full_path.read_text(encoding="utf-8", errors="ignore") + matches = scan_content(content, file_path) + all_matches.extend(matches) + except (OSError, UnicodeDecodeError): + # Skip files that can't be read + continue + + return all_matches + + +# ============================================================================= +# OUTPUT FORMATTING +# ============================================================================= + +# ANSI color codes +RED = "\033[0;31m" +GREEN = "\033[0;32m" +YELLOW = "\033[1;33m" +CYAN = "\033[0;36m" +NC = "\033[0m" # No Color + + +def print_results(matches: list[SecretMatch]) -> None: + """Print scan results in a formatted way.""" + if not matches: + print(f"{GREEN}No secrets detected. Commit allowed.{NC}") + return + + print(f"{RED}POTENTIAL SECRETS DETECTED!{NC}") + print(f"{RED}{'=' * 60}{NC}") + + # Group by file + files_with_matches: dict[str, list[SecretMatch]] = {} + for match in matches: + if match.file_path not in files_with_matches: + files_with_matches[match.file_path] = [] + files_with_matches[match.file_path].append(match) + + for file_path, file_matches in files_with_matches.items(): + print(f"\n{YELLOW}File: {file_path}{NC}") + for match in file_matches: + masked = mask_secret(match.matched_text) + print(f" Line {match.line_number}: [{match.pattern_name}]") + print(f" {CYAN}{masked}{NC}") + + print(f"\n{RED}{'=' * 60}{NC}") + print(f"\n{YELLOW}If these are false positives, you can:{NC}") + print(" 1. Add patterns to .secretsignore (create if needed)") + print(" 2. Use environment variables instead of hardcoded values") + print() + print(f"{RED}Commit blocked to protect against leaking secrets.{NC}") + + +def print_json_results(matches: list[SecretMatch]) -> None: + """Print scan results as JSON (for programmatic use).""" + import json + + results = { + "secrets_found": len(matches) > 0, + "count": len(matches), + "matches": [ + { + "file": m.file_path, + "line": m.line_number, + "type": m.pattern_name, + "preview": mask_secret(m.matched_text), + } + for m in matches + ], + } + print(json.dumps(results, indent=2)) + + +# ============================================================================= +# MAIN +# ============================================================================= + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Scan files for potential secrets before commit" + ) + parser.add_argument( + "--staged-only", + "-s", + action="store_true", + default=True, + help="Only scan staged files (default)", + ) + parser.add_argument( + "--all-files", "-a", action="store_true", help="Scan all tracked files" + ) + parser.add_argument( + "--path", "-p", type=str, help="Scan a specific file or directory" + ) + parser.add_argument("--json", action="store_true", help="Output results as JSON") + parser.add_argument( + "--quiet", "-q", action="store_true", help="Only output if secrets are found" + ) + + args = parser.parse_args() + + project_dir = Path.cwd() + + # Determine which files to scan + if args.path: + path = Path(args.path) + if path.is_file(): + files = [str(path)] + elif path.is_dir(): + files = [ + str(f.relative_to(project_dir)) for f in path.rglob("*") if f.is_file() + ] + else: + print(f"{RED}Error: Path not found: {args.path}{NC}", file=sys.stderr) + return 2 + elif args.all_files: + files = get_all_tracked_files() + else: + files = get_staged_files() + + if not files: + if not args.quiet: + print(f"{GREEN}No files to scan.{NC}") + return 0 + + if not args.quiet and not args.json: + print(f"Scanning {len(files)} file(s) for secrets...") + + # Scan files + matches = scan_files(files, project_dir) + + # Output results + if args.json: + print_json_results(matches) + elif matches or not args.quiet: + print_results(matches) + + # Return exit code + return 1 if matches else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/auto-claude/security_scanner.py b/auto-claude/security_scanner.py index 8cabb51d..503bd1b2 100644 --- a/auto-claude/security_scanner.py +++ b/auto-claude/security_scanner.py @@ -1,597 +1,2 @@ -#!/usr/bin/env python3 -""" -Security Scanner Module -======================= - -Consolidates security scanning including secrets detection and SAST tools. -This module integrates the existing scan_secrets.py and provides a unified -interface for all security scanning. - -The security scanner is used by: -- QA Agent: To verify no secrets are committed -- Validation Strategy: To run security scans for high-risk changes - -Usage: - from security_scanner import SecurityScanner - - scanner = SecurityScanner() - results = scanner.scan(project_dir, spec_dir) - - if results.has_critical_issues: - print("Security issues found - blocking QA approval") -""" - -import json -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# Import the existing secrets scanner -try: - from scan_secrets import SecretMatch, get_all_tracked_files, scan_files - - HAS_SECRETS_SCANNER = True -except ImportError: - HAS_SECRETS_SCANNER = False - SecretMatch = None - - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class SecurityVulnerability: - """ - Represents a security vulnerability found during scanning. - - Attributes: - severity: Severity level (critical, high, medium, low, info) - source: Which scanner found this (secrets, bandit, npm_audit, etc.) - title: Short title of the vulnerability - description: Detailed description - file: File where vulnerability was found (if applicable) - line: Line number (if applicable) - cwe: CWE identifier if available - """ - - severity: str # critical, high, medium, low, info - source: str # secrets, bandit, npm_audit, semgrep, etc. - title: str - description: str - file: str | None = None - line: int | None = None - cwe: str | None = None - - -@dataclass -class SecurityScanResult: - """ - Result of a security scan. - - Attributes: - secrets: List of detected secrets - vulnerabilities: List of security vulnerabilities - scan_errors: List of errors during scanning - has_critical_issues: Whether any critical issues were found - should_block_qa: Whether these results should block QA approval - """ - - secrets: list[dict[str, Any]] = field(default_factory=list) - vulnerabilities: list[SecurityVulnerability] = field(default_factory=list) - scan_errors: list[str] = field(default_factory=list) - has_critical_issues: bool = False - should_block_qa: bool = False - - -# ============================================================================= -# SECURITY SCANNER -# ============================================================================= - - -class SecurityScanner: - """ - Consolidates all security scanning operations. - - Integrates: - - scan_secrets.py for secrets detection - - Bandit for Python SAST (if available) - - npm audit for JavaScript vulnerabilities (if applicable) - """ - - def __init__(self) -> None: - """Initialize the security scanner.""" - self._bandit_available: bool | None = None - self._npm_available: bool | None = None - - def scan( - self, - project_dir: Path, - spec_dir: Path | None = None, - changed_files: list[str] | None = None, - run_secrets: bool = True, - run_sast: bool = True, - run_dependency_audit: bool = True, - ) -> SecurityScanResult: - """ - Run all applicable security scans. - - Args: - project_dir: Path to the project root - spec_dir: Path to the spec directory (for storing results) - changed_files: Optional list of files to scan (if None, scans all) - run_secrets: Whether to run secrets scanning - run_sast: Whether to run SAST tools - run_dependency_audit: Whether to run dependency audits - - Returns: - SecurityScanResult with all findings - """ - project_dir = Path(project_dir) - result = SecurityScanResult() - - # Run secrets scan - if run_secrets: - self._run_secrets_scan(project_dir, changed_files, result) - - # Run SAST based on project type - if run_sast: - self._run_sast_scans(project_dir, result) - - # Run dependency audits - if run_dependency_audit: - self._run_dependency_audits(project_dir, result) - - # Determine if should block QA - result.has_critical_issues = ( - any(v.severity in ["critical", "high"] for v in result.vulnerabilities) - or len(result.secrets) > 0 - ) - - # Any secrets always block, critical vulnerabilities block - result.should_block_qa = len(result.secrets) > 0 or any( - v.severity == "critical" for v in result.vulnerabilities - ) - - # Save results if spec_dir provided - if spec_dir: - self._save_results(spec_dir, result) - - return result - - def _run_secrets_scan( - self, - project_dir: Path, - changed_files: list[str] | None, - result: SecurityScanResult, - ) -> None: - """Run secrets scanning using scan_secrets.py.""" - if not HAS_SECRETS_SCANNER: - result.scan_errors.append("scan_secrets module not available") - return - - try: - # Get files to scan - if changed_files: - files_to_scan = changed_files - else: - files_to_scan = get_all_tracked_files() - - # Run scan - matches = scan_files(files_to_scan, project_dir) - - # Convert matches to result format - for match in matches: - result.secrets.append( - { - "file": match.file_path, - "line": match.line_number, - "pattern": match.pattern_name, - "matched_text": self._redact_secret(match.matched_text), - } - ) - - # Also add as vulnerability - result.vulnerabilities.append( - SecurityVulnerability( - severity="critical", - source="secrets", - title=f"Potential secret: {match.pattern_name}", - description=f"Found potential {match.pattern_name} in file", - file=match.file_path, - line=match.line_number, - ) - ) - - except Exception as e: - result.scan_errors.append(f"Secrets scan error: {str(e)}") - - def _run_sast_scans(self, project_dir: Path, result: SecurityScanResult) -> None: - """Run SAST tools based on project type.""" - # Python SAST with Bandit - if self._is_python_project(project_dir): - self._run_bandit(project_dir, result) - - # JavaScript/Node.js - npm audit - # (handled in dependency audits for Node projects) - - def _run_bandit(self, project_dir: Path, result: SecurityScanResult) -> None: - """Run Bandit security scanner for Python projects.""" - if not self._check_bandit_available(): - return - - try: - # Find Python source directories - src_dirs = [] - for candidate in ["src", "app", project_dir.name, "."]: - candidate_path = project_dir / candidate - if ( - candidate_path.exists() - and (candidate_path / "__init__.py").exists() - ): - src_dirs.append(str(candidate_path)) - - if not src_dirs: - # Try to find any Python files - py_files = list(project_dir.glob("**/*.py")) - if not py_files: - return - src_dirs = ["."] - - # Run bandit - cmd = [ - "bandit", - "-r", - *src_dirs, - "-f", - "json", - "--exit-zero", # Don't fail on findings - ] - - proc = subprocess.run( - cmd, - cwd=project_dir, - capture_output=True, - text=True, - timeout=120, - ) - - if proc.stdout: - try: - bandit_output = json.loads(proc.stdout) - for finding in bandit_output.get("results", []): - severity = finding.get("issue_severity", "MEDIUM").lower() - if severity == "high": - severity = "high" - elif severity == "medium": - severity = "medium" - else: - severity = "low" - - result.vulnerabilities.append( - SecurityVulnerability( - severity=severity, - source="bandit", - title=finding.get("issue_text", "Unknown issue"), - description=finding.get("issue_text", ""), - file=finding.get("filename"), - line=finding.get("line_number"), - cwe=finding.get("issue_cwe", {}).get("id"), - ) - ) - except json.JSONDecodeError: - result.scan_errors.append("Failed to parse Bandit output") - - except subprocess.TimeoutExpired: - result.scan_errors.append("Bandit scan timed out") - except FileNotFoundError: - result.scan_errors.append("Bandit not found") - except Exception as e: - result.scan_errors.append(f"Bandit error: {str(e)}") - - def _run_dependency_audits( - self, project_dir: Path, result: SecurityScanResult - ) -> None: - """Run dependency vulnerability audits.""" - # npm audit for JavaScript projects - if (project_dir / "package.json").exists(): - self._run_npm_audit(project_dir, result) - - # pip-audit for Python projects (if available) - if self._is_python_project(project_dir): - self._run_pip_audit(project_dir, result) - - def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None: - """Run npm audit for JavaScript projects.""" - try: - cmd = ["npm", "audit", "--json"] - - proc = subprocess.run( - cmd, - cwd=project_dir, - capture_output=True, - text=True, - timeout=120, - ) - - if proc.stdout: - try: - audit_output = json.loads(proc.stdout) - - # npm audit v2+ format - vulnerabilities = audit_output.get("vulnerabilities", {}) - for pkg_name, vuln_info in vulnerabilities.items(): - severity = vuln_info.get("severity", "moderate") - if severity == "critical": - severity = "critical" - elif severity == "high": - severity = "high" - elif severity == "moderate": - severity = "medium" - else: - severity = "low" - - result.vulnerabilities.append( - SecurityVulnerability( - severity=severity, - source="npm_audit", - title=f"Vulnerable dependency: {pkg_name}", - description=vuln_info.get("via", [{}])[0].get( - "title", "" - ) - if isinstance(vuln_info.get("via"), list) - and vuln_info.get("via") - else str(vuln_info.get("via", "")), - file="package.json", - ) - ) - except json.JSONDecodeError: - pass # npm audit may return invalid JSON on no findings - - except subprocess.TimeoutExpired: - result.scan_errors.append("npm audit timed out") - except FileNotFoundError: - pass # npm not available - except Exception as e: - result.scan_errors.append(f"npm audit error: {str(e)}") - - def _run_pip_audit(self, project_dir: Path, result: SecurityScanResult) -> None: - """Run pip-audit for Python projects (if available).""" - try: - cmd = ["pip-audit", "--format", "json"] - - proc = subprocess.run( - cmd, - cwd=project_dir, - capture_output=True, - text=True, - timeout=120, - ) - - if proc.stdout: - try: - audit_output = json.loads(proc.stdout) - for vuln in audit_output: - severity = "high" if vuln.get("fix_versions") else "medium" - - result.vulnerabilities.append( - SecurityVulnerability( - severity=severity, - source="pip_audit", - title=f"Vulnerable package: {vuln.get('name')}", - description=vuln.get("description", ""), - cwe=vuln.get("aliases", [""])[0] - if vuln.get("aliases") - else None, - ) - ) - except json.JSONDecodeError: - pass - - except FileNotFoundError: - pass # pip-audit not available - except subprocess.TimeoutExpired: - pass - except Exception: - pass - - def _is_python_project(self, project_dir: Path) -> bool: - """Check if this is a Python project.""" - indicators = [ - project_dir / "pyproject.toml", - project_dir / "requirements.txt", - project_dir / "setup.py", - project_dir / "setup.cfg", - ] - return any(p.exists() for p in indicators) - - def _check_bandit_available(self) -> bool: - """Check if Bandit is available.""" - if self._bandit_available is None: - try: - subprocess.run( - ["bandit", "--version"], - capture_output=True, - timeout=5, - ) - self._bandit_available = True - except (FileNotFoundError, subprocess.TimeoutExpired): - self._bandit_available = False - return self._bandit_available - - def _redact_secret(self, text: str) -> str: - """Redact a secret for safe logging.""" - if len(text) <= 8: - return "*" * len(text) - return text[:4] + "*" * (len(text) - 8) + text[-4:] - - def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None: - """Save scan results to spec directory.""" - spec_dir = Path(spec_dir) - spec_dir.mkdir(parents=True, exist_ok=True) - - output_file = spec_dir / "security_scan_results.json" - output_data = self.to_dict(result) - - with open(output_file, "w", encoding="utf-8") as f: - json.dump(output_data, f, indent=2) - - def to_dict(self, result: SecurityScanResult) -> dict[str, Any]: - """Convert result to dictionary for JSON serialization.""" - return { - "secrets": result.secrets, - "vulnerabilities": [ - { - "severity": v.severity, - "source": v.source, - "title": v.title, - "description": v.description, - "file": v.file, - "line": v.line, - "cwe": v.cwe, - } - for v in result.vulnerabilities - ], - "scan_errors": result.scan_errors, - "has_critical_issues": result.has_critical_issues, - "should_block_qa": result.should_block_qa, - "summary": { - "total_secrets": len(result.secrets), - "total_vulnerabilities": len(result.vulnerabilities), - "critical_count": sum( - 1 for v in result.vulnerabilities if v.severity == "critical" - ), - "high_count": sum( - 1 for v in result.vulnerabilities if v.severity == "high" - ), - "medium_count": sum( - 1 for v in result.vulnerabilities if v.severity == "medium" - ), - "low_count": sum( - 1 for v in result.vulnerabilities if v.severity == "low" - ), - }, - } - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def scan_for_security_issues( - project_dir: Path, - spec_dir: Path | None = None, - changed_files: list[str] | None = None, -) -> SecurityScanResult: - """ - Convenience function to run security scan. - - Args: - project_dir: Path to project root - spec_dir: Optional spec directory to save results - changed_files: Optional list of files to scan - - Returns: - SecurityScanResult with all findings - """ - scanner = SecurityScanner() - return scanner.scan(project_dir, spec_dir, changed_files) - - -def has_security_issues(project_dir: Path) -> bool: - """ - Quick check if project has security issues. - - Args: - project_dir: Path to project root - - Returns: - True if any critical/high issues found - """ - scanner = SecurityScanner() - result = scanner.scan(project_dir, run_sast=False, run_dependency_audit=False) - return result.has_critical_issues - - -def scan_secrets_only( - project_dir: Path, - changed_files: list[str] | None = None, -) -> list[dict[str, Any]]: - """ - Scan only for secrets (quick scan). - - Args: - project_dir: Path to project root - changed_files: Optional list of files to scan - - Returns: - List of detected secrets - """ - scanner = SecurityScanner() - result = scanner.scan( - project_dir, - changed_files=changed_files, - run_sast=False, - run_dependency_audit=False, - ) - return result.secrets - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Run security scans") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--spec-dir", type=Path, help="Path to spec directory") - parser.add_argument( - "--secrets-only", action="store_true", help="Only scan for secrets" - ) - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - scanner = SecurityScanner() - result = scanner.scan( - args.project_dir, - spec_dir=args.spec_dir, - run_sast=not args.secrets_only, - run_dependency_audit=not args.secrets_only, - ) - - if args.json: - print(json.dumps(scanner.to_dict(result), indent=2)) - else: - print(f"Secrets Found: {len(result.secrets)}") - print(f"Vulnerabilities: {len(result.vulnerabilities)}") - print(f"Has Critical Issues: {result.has_critical_issues}") - print(f"Should Block QA: {result.should_block_qa}") - - if result.secrets: - print("\nSecrets Detected:") - for secret in result.secrets: - print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}") - - if result.vulnerabilities: - print(f"\nVulnerabilities ({len(result.vulnerabilities)}):") - for v in result.vulnerabilities: - print(f" [{v.severity.upper()}] {v.title}") - if v.file: - print(f" File: {v.file}:{v.line or ''}") - - if result.scan_errors: - print(f"\nScan Errors ({len(result.scan_errors)}):") - for error in result.scan_errors: - print(f" - {error}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from analysis.security_scanner instead.""" +from analysis.security_scanner import * diff --git a/auto-claude/service_orchestrator.py b/auto-claude/service_orchestrator.py index 671d050e..3eb0262e 100644 --- a/auto-claude/service_orchestrator.py +++ b/auto-claude/service_orchestrator.py @@ -1,610 +1,18 @@ -#!/usr/bin/env python3 -""" -Service Orchestrator Module -=========================== - -Orchestrates multi-service environments for testing. -Handles docker-compose, monorepo service discovery, and health checks. - -The service orchestrator is used by: -- QA Agent: To start services before integration/e2e tests -- Validation Strategy: To determine if multi-service orchestration is needed - -Usage: - from service_orchestrator import ServiceOrchestrator - - orchestrator = ServiceOrchestrator(project_dir) - if orchestrator.is_multi_service(): - orchestrator.start_services() - # run tests - orchestrator.stop_services() -""" - -import json -import subprocess -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class ServiceConfig: - """ - Configuration for a single service. - - Attributes: - name: Name of the service - path: Path to the service (relative to project root) - port: Port the service runs on - type: Type of service (docker, local, mock) - health_check_url: URL for health check - startup_command: Command to start the service - startup_timeout: Timeout in seconds for startup - """ - - name: str - path: str | None = None - port: int | None = None - type: str = "docker" # docker, local, mock - health_check_url: str | None = None - startup_command: str | None = None - startup_timeout: int = 120 - - -@dataclass -class OrchestrationResult: - """ - Result of service orchestration. - - Attributes: - success: Whether all services started successfully - services_started: List of services that were started - services_failed: List of services that failed to start - errors: List of error messages - """ - - success: bool = False - services_started: list[str] = field(default_factory=list) - services_failed: list[str] = field(default_factory=list) - errors: list[str] = field(default_factory=list) - - -# ============================================================================= -# SERVICE ORCHESTRATOR -# ============================================================================= - - -class ServiceOrchestrator: - """ - Orchestrates multi-service environments. - - Supports: - - Docker Compose for containerized services - - Monorepo service discovery - - Health check waiting - """ - - def __init__(self, project_dir: Path) -> None: - """ - Initialize the service orchestrator. - - Args: - project_dir: Path to the project root - """ - self.project_dir = Path(project_dir) - self._compose_file: Path | None = None - self._services: list[ServiceConfig] = [] - self._processes: dict[str, subprocess.Popen] = {} - self._discover_services() - - def _discover_services(self) -> None: - """Discover services in the project.""" - # Check for docker-compose - self._compose_file = self._find_compose_file() - - if self._compose_file: - self._parse_compose_services() - else: - # Check for monorepo structure - self._discover_monorepo_services() - - def _find_compose_file(self) -> Path | None: - """Find docker-compose configuration file.""" - candidates = [ - "docker-compose.yml", - "docker-compose.yaml", - "compose.yml", - "compose.yaml", - "docker-compose.dev.yml", - "docker-compose.dev.yaml", - ] - - for candidate in candidates: - path = self.project_dir / candidate - if path.exists(): - return path - - return None - - def _parse_compose_services(self) -> None: - """Parse services from docker-compose file.""" - if not self._compose_file: - return - - try: - # Try to import yaml - import yaml - - HAS_YAML = True - except ImportError: - HAS_YAML = False - - if not HAS_YAML: - # Basic parsing without yaml module - content = self._compose_file.read_text() - if "services:" in content: - # Very basic service name extraction - lines = content.split("\n") - in_services = False - for line in lines: - if line.strip() == "services:": - in_services = True - continue - if ( - in_services - and line.startswith(" ") - and not line.startswith(" ") - ): - service_name = line.strip().rstrip(":") - if service_name: - self._services.append(ServiceConfig(name=service_name)) - return - - try: - with open(self._compose_file, encoding="utf-8") as f: - compose_data = yaml.safe_load(f) - - services = compose_data.get("services", {}) - for name, config in services.items(): - if not isinstance(config, dict): - continue - - # Extract port mapping - ports = config.get("ports", []) - port = None - if ports: - port_mapping = str(ports[0]) - if ":" in port_mapping: - port = int(port_mapping.split(":")[0]) - - # Determine health check URL - health_url = None - if port: - health_url = f"http://localhost:{port}/health" - - self._services.append( - ServiceConfig( - name=name, - port=port, - type="docker", - health_check_url=health_url, - ) - ) - except Exception: - pass - - def _discover_monorepo_services(self) -> None: - """Discover services in a monorepo structure.""" - # Common monorepo patterns - service_dirs = [ - "services", - "packages", - "apps", - "microservices", - ] - - for service_dir in service_dirs: - dir_path = self.project_dir / service_dir - if dir_path.exists() and dir_path.is_dir(): - for item in dir_path.iterdir(): - if item.is_dir() and self._is_service_directory(item): - self._services.append( - ServiceConfig( - name=item.name, - path=str(item.relative_to(self.project_dir)), - type="local", - ) - ) - - def _is_service_directory(self, path: Path) -> bool: - """Check if a directory contains a service.""" - # Look for indicators of a service - indicators = [ - "package.json", - "pyproject.toml", - "requirements.txt", - "Dockerfile", - "main.py", - "app.py", - "index.ts", - "index.js", - "main.go", - "Cargo.toml", - ] - - return any((path / indicator).exists() for indicator in indicators) - - def is_multi_service(self) -> bool: - """ - Check if this is a multi-service project. - - Returns: - True if multiple services are detected - """ - return len(self._services) > 1 or self._compose_file is not None - - def has_docker_compose(self) -> bool: - """ - Check if project has docker-compose configuration. - - Returns: - True if docker-compose file exists - """ - return self._compose_file is not None - - def get_services(self) -> list[ServiceConfig]: - """ - Get list of discovered services. - - Returns: - List of ServiceConfig objects - """ - return self._services.copy() - - def start_services(self, timeout: int = 120) -> OrchestrationResult: - """ - Start all services. - - Args: - timeout: Timeout in seconds for all services to start - - Returns: - OrchestrationResult with status - """ - result = OrchestrationResult() - - if self._compose_file: - return self._start_docker_compose(timeout) - else: - return self._start_local_services(timeout) - - def _start_docker_compose(self, timeout: int) -> OrchestrationResult: - """Start services using docker-compose.""" - result = OrchestrationResult() - - try: - # Check if docker-compose is available - docker_cmd = self._get_docker_compose_cmd() - if not docker_cmd: - result.errors.append("docker-compose not found") - return result - - # Start services - cmd = docker_cmd + ["up", "-d"] - - proc = subprocess.run( - cmd, - cwd=self.project_dir, - capture_output=True, - text=True, - timeout=timeout, - ) - - if proc.returncode != 0: - result.errors.append(f"docker-compose up failed: {proc.stderr}") - return result - - # Wait for health checks - if self._wait_for_health(timeout): - result.success = True - result.services_started = [s.name for s in self._services] - else: - result.errors.append("Services did not become healthy in time") - result.services_failed = [s.name for s in self._services] - - except subprocess.TimeoutExpired: - result.errors.append("docker-compose startup timed out") - except Exception as e: - result.errors.append(f"Error starting services: {str(e)}") - - return result - - def _start_local_services(self, timeout: int) -> OrchestrationResult: - """Start local services (non-docker).""" - result = OrchestrationResult() - - for service in self._services: - if service.startup_command: - try: - proc = subprocess.Popen( - service.startup_command, - shell=True, - cwd=self.project_dir / service.path - if service.path - else self.project_dir, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - self._processes[service.name] = proc - result.services_started.append(service.name) - except Exception as e: - result.errors.append(f"Failed to start {service.name}: {str(e)}") - result.services_failed.append(service.name) - - # Wait for services to be ready - if result.services_started: - if self._wait_for_health(timeout): - result.success = True - else: - result.errors.append("Services did not become healthy in time") - - return result - - def stop_services(self) -> None: - """Stop all running services.""" - if self._compose_file: - self._stop_docker_compose() - else: - self._stop_local_services() - - def _stop_docker_compose(self) -> None: - """Stop services using docker-compose.""" - try: - docker_cmd = self._get_docker_compose_cmd() - if docker_cmd: - subprocess.run( - docker_cmd + ["down"], - cwd=self.project_dir, - capture_output=True, - timeout=60, - ) - except Exception: - pass - - def _stop_local_services(self) -> None: - """Stop local services.""" - for name, proc in self._processes.items(): - try: - proc.terminate() - proc.wait(timeout=10) - except Exception: - try: - proc.kill() - except Exception: - pass - self._processes.clear() - - def _get_docker_compose_cmd(self) -> list[str] | None: - """Get the docker-compose command (v1 or v2).""" - # Try docker compose v2 first - try: - proc = subprocess.run( - ["docker", "compose", "version"], - capture_output=True, - timeout=5, - ) - if proc.returncode == 0: - return ["docker", "compose", "-f", str(self._compose_file)] - except Exception: - pass - - # Try docker-compose v1 - try: - proc = subprocess.run( - ["docker-compose", "version"], - capture_output=True, - timeout=5, - ) - if proc.returncode == 0: - return ["docker-compose", "-f", str(self._compose_file)] - except Exception: - pass - - return None - - def _wait_for_health(self, timeout: int) -> bool: - """ - Wait for all services to become healthy. - - Args: - timeout: Maximum time to wait in seconds - - Returns: - True if all services became healthy - """ - start_time = time.time() - - while time.time() - start_time < timeout: - all_healthy = True - - for service in self._services: - if service.port: - if not self._check_port(service.port): - all_healthy = False - break - - if all_healthy: - return True - - time.sleep(2) - - return False - - def _check_port(self, port: int) -> bool: - """Check if a port is responding.""" - import socket - - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(1) - result = s.connect_ex(("localhost", port)) - return result == 0 - except Exception: - return False - - def to_dict(self) -> dict[str, Any]: - """Convert orchestration config to dictionary.""" - return { - "is_multi_service": self.is_multi_service(), - "has_docker_compose": self.has_docker_compose(), - "compose_file": str(self._compose_file) if self._compose_file else None, - "services": [ - { - "name": s.name, - "path": s.path, - "port": s.port, - "type": s.type, - "health_check_url": s.health_check_url, - } - for s in self._services - ], - } - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def is_multi_service_project(project_dir: Path) -> bool: - """ - Check if project is multi-service. - - Args: - project_dir: Path to project root - - Returns: - True if multi-service project - """ - orchestrator = ServiceOrchestrator(project_dir) - return orchestrator.is_multi_service() - - -def get_service_config(project_dir: Path) -> dict[str, Any]: - """ - Get service configuration for project. - - Args: - project_dir: Path to project root - - Returns: - Dictionary with service configuration - """ - orchestrator = ServiceOrchestrator(project_dir) - return orchestrator.to_dict() - - -# ============================================================================= -# CONTEXT MANAGER -# ============================================================================= - - -class ServiceContext: - """ - Context manager for service orchestration. - - Usage: - with ServiceContext(project_dir) as services: - # Services are running - run_tests() - # Services are stopped - """ - - def __init__(self, project_dir: Path, timeout: int = 120) -> None: - """Initialize service context.""" - self.orchestrator = ServiceOrchestrator(project_dir) - self.timeout = timeout - self.result: OrchestrationResult | None = None - - def __enter__(self) -> "ServiceContext": - """Start services on context entry.""" - if self.orchestrator.is_multi_service(): - self.result = self.orchestrator.start_services(self.timeout) - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - """Stop services on context exit.""" - self.orchestrator.stop_services() - - @property - def success(self) -> bool: - """Check if services started successfully.""" - if self.result: - return self.result.success - return True # No services to start - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Service orchestration") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--start", action="store_true", help="Start services") - parser.add_argument("--stop", action="store_true", help="Stop services") - parser.add_argument("--status", action="store_true", help="Show service status") - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - orchestrator = ServiceOrchestrator(args.project_dir) - - if args.start: - result = orchestrator.start_services() - if args.json: - print( - json.dumps( - { - "success": result.success, - "services_started": result.services_started, - "errors": result.errors, - }, - indent=2, - ) - ) - else: - print(f"Started: {result.services_started}") - if result.errors: - print(f"Errors: {result.errors}") - elif args.stop: - orchestrator.stop_services() - print("Services stopped") - else: - # Default: show status - config = orchestrator.to_dict() - - if args.json: - print(json.dumps(config, indent=2)) - else: - print(f"Multi-service: {config['is_multi_service']}") - print(f"Docker Compose: {config['has_docker_compose']}") - if config["compose_file"]: - print(f"Compose File: {config['compose_file']}") - print(f"\nServices ({len(config['services'])}):") - for service in config["services"]: - port_info = f":{service['port']}" if service["port"] else "" - print(f" - {service['name']} ({service['type']}){port_info}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from services.orchestrator instead.""" +from services.orchestrator import ( + ServiceConfig, + OrchestrationResult, + ServiceOrchestrator, + ServiceContext, + is_multi_service_project, + get_service_config, +) + +__all__ = [ + "ServiceConfig", + "OrchestrationResult", + "ServiceOrchestrator", + "ServiceContext", + "is_multi_service_project", + "get_service_config", +] diff --git a/auto-claude/services/__init__.py b/auto-claude/services/__init__.py new file mode 100644 index 00000000..7b6fa8d2 --- /dev/null +++ b/auto-claude/services/__init__.py @@ -0,0 +1,16 @@ +""" +Services Module +=============== + +Background services and orchestration for Auto Claude. +""" + +from .context import ServiceContext +from .orchestrator import ServiceOrchestrator +from .recovery import RecoveryManager + +__all__ = [ + "ServiceContext", + "ServiceOrchestrator", + "RecoveryManager", +] diff --git a/auto-claude/service_context.py b/auto-claude/services/context.py similarity index 100% rename from auto-claude/service_context.py rename to auto-claude/services/context.py diff --git a/auto-claude/services/orchestrator.py b/auto-claude/services/orchestrator.py new file mode 100644 index 00000000..671d050e --- /dev/null +++ b/auto-claude/services/orchestrator.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +""" +Service Orchestrator Module +=========================== + +Orchestrates multi-service environments for testing. +Handles docker-compose, monorepo service discovery, and health checks. + +The service orchestrator is used by: +- QA Agent: To start services before integration/e2e tests +- Validation Strategy: To determine if multi-service orchestration is needed + +Usage: + from service_orchestrator import ServiceOrchestrator + + orchestrator = ServiceOrchestrator(project_dir) + if orchestrator.is_multi_service(): + orchestrator.start_services() + # run tests + orchestrator.stop_services() +""" + +import json +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class ServiceConfig: + """ + Configuration for a single service. + + Attributes: + name: Name of the service + path: Path to the service (relative to project root) + port: Port the service runs on + type: Type of service (docker, local, mock) + health_check_url: URL for health check + startup_command: Command to start the service + startup_timeout: Timeout in seconds for startup + """ + + name: str + path: str | None = None + port: int | None = None + type: str = "docker" # docker, local, mock + health_check_url: str | None = None + startup_command: str | None = None + startup_timeout: int = 120 + + +@dataclass +class OrchestrationResult: + """ + Result of service orchestration. + + Attributes: + success: Whether all services started successfully + services_started: List of services that were started + services_failed: List of services that failed to start + errors: List of error messages + """ + + success: bool = False + services_started: list[str] = field(default_factory=list) + services_failed: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +# ============================================================================= +# SERVICE ORCHESTRATOR +# ============================================================================= + + +class ServiceOrchestrator: + """ + Orchestrates multi-service environments. + + Supports: + - Docker Compose for containerized services + - Monorepo service discovery + - Health check waiting + """ + + def __init__(self, project_dir: Path) -> None: + """ + Initialize the service orchestrator. + + Args: + project_dir: Path to the project root + """ + self.project_dir = Path(project_dir) + self._compose_file: Path | None = None + self._services: list[ServiceConfig] = [] + self._processes: dict[str, subprocess.Popen] = {} + self._discover_services() + + def _discover_services(self) -> None: + """Discover services in the project.""" + # Check for docker-compose + self._compose_file = self._find_compose_file() + + if self._compose_file: + self._parse_compose_services() + else: + # Check for monorepo structure + self._discover_monorepo_services() + + def _find_compose_file(self) -> Path | None: + """Find docker-compose configuration file.""" + candidates = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + "docker-compose.dev.yml", + "docker-compose.dev.yaml", + ] + + for candidate in candidates: + path = self.project_dir / candidate + if path.exists(): + return path + + return None + + def _parse_compose_services(self) -> None: + """Parse services from docker-compose file.""" + if not self._compose_file: + return + + try: + # Try to import yaml + import yaml + + HAS_YAML = True + except ImportError: + HAS_YAML = False + + if not HAS_YAML: + # Basic parsing without yaml module + content = self._compose_file.read_text() + if "services:" in content: + # Very basic service name extraction + lines = content.split("\n") + in_services = False + for line in lines: + if line.strip() == "services:": + in_services = True + continue + if ( + in_services + and line.startswith(" ") + and not line.startswith(" ") + ): + service_name = line.strip().rstrip(":") + if service_name: + self._services.append(ServiceConfig(name=service_name)) + return + + try: + with open(self._compose_file, encoding="utf-8") as f: + compose_data = yaml.safe_load(f) + + services = compose_data.get("services", {}) + for name, config in services.items(): + if not isinstance(config, dict): + continue + + # Extract port mapping + ports = config.get("ports", []) + port = None + if ports: + port_mapping = str(ports[0]) + if ":" in port_mapping: + port = int(port_mapping.split(":")[0]) + + # Determine health check URL + health_url = None + if port: + health_url = f"http://localhost:{port}/health" + + self._services.append( + ServiceConfig( + name=name, + port=port, + type="docker", + health_check_url=health_url, + ) + ) + except Exception: + pass + + def _discover_monorepo_services(self) -> None: + """Discover services in a monorepo structure.""" + # Common monorepo patterns + service_dirs = [ + "services", + "packages", + "apps", + "microservices", + ] + + for service_dir in service_dirs: + dir_path = self.project_dir / service_dir + if dir_path.exists() and dir_path.is_dir(): + for item in dir_path.iterdir(): + if item.is_dir() and self._is_service_directory(item): + self._services.append( + ServiceConfig( + name=item.name, + path=str(item.relative_to(self.project_dir)), + type="local", + ) + ) + + def _is_service_directory(self, path: Path) -> bool: + """Check if a directory contains a service.""" + # Look for indicators of a service + indicators = [ + "package.json", + "pyproject.toml", + "requirements.txt", + "Dockerfile", + "main.py", + "app.py", + "index.ts", + "index.js", + "main.go", + "Cargo.toml", + ] + + return any((path / indicator).exists() for indicator in indicators) + + def is_multi_service(self) -> bool: + """ + Check if this is a multi-service project. + + Returns: + True if multiple services are detected + """ + return len(self._services) > 1 or self._compose_file is not None + + def has_docker_compose(self) -> bool: + """ + Check if project has docker-compose configuration. + + Returns: + True if docker-compose file exists + """ + return self._compose_file is not None + + def get_services(self) -> list[ServiceConfig]: + """ + Get list of discovered services. + + Returns: + List of ServiceConfig objects + """ + return self._services.copy() + + def start_services(self, timeout: int = 120) -> OrchestrationResult: + """ + Start all services. + + Args: + timeout: Timeout in seconds for all services to start + + Returns: + OrchestrationResult with status + """ + result = OrchestrationResult() + + if self._compose_file: + return self._start_docker_compose(timeout) + else: + return self._start_local_services(timeout) + + def _start_docker_compose(self, timeout: int) -> OrchestrationResult: + """Start services using docker-compose.""" + result = OrchestrationResult() + + try: + # Check if docker-compose is available + docker_cmd = self._get_docker_compose_cmd() + if not docker_cmd: + result.errors.append("docker-compose not found") + return result + + # Start services + cmd = docker_cmd + ["up", "-d"] + + proc = subprocess.run( + cmd, + cwd=self.project_dir, + capture_output=True, + text=True, + timeout=timeout, + ) + + if proc.returncode != 0: + result.errors.append(f"docker-compose up failed: {proc.stderr}") + return result + + # Wait for health checks + if self._wait_for_health(timeout): + result.success = True + result.services_started = [s.name for s in self._services] + else: + result.errors.append("Services did not become healthy in time") + result.services_failed = [s.name for s in self._services] + + except subprocess.TimeoutExpired: + result.errors.append("docker-compose startup timed out") + except Exception as e: + result.errors.append(f"Error starting services: {str(e)}") + + return result + + def _start_local_services(self, timeout: int) -> OrchestrationResult: + """Start local services (non-docker).""" + result = OrchestrationResult() + + for service in self._services: + if service.startup_command: + try: + proc = subprocess.Popen( + service.startup_command, + shell=True, + cwd=self.project_dir / service.path + if service.path + else self.project_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self._processes[service.name] = proc + result.services_started.append(service.name) + except Exception as e: + result.errors.append(f"Failed to start {service.name}: {str(e)}") + result.services_failed.append(service.name) + + # Wait for services to be ready + if result.services_started: + if self._wait_for_health(timeout): + result.success = True + else: + result.errors.append("Services did not become healthy in time") + + return result + + def stop_services(self) -> None: + """Stop all running services.""" + if self._compose_file: + self._stop_docker_compose() + else: + self._stop_local_services() + + def _stop_docker_compose(self) -> None: + """Stop services using docker-compose.""" + try: + docker_cmd = self._get_docker_compose_cmd() + if docker_cmd: + subprocess.run( + docker_cmd + ["down"], + cwd=self.project_dir, + capture_output=True, + timeout=60, + ) + except Exception: + pass + + def _stop_local_services(self) -> None: + """Stop local services.""" + for name, proc in self._processes.items(): + try: + proc.terminate() + proc.wait(timeout=10) + except Exception: + try: + proc.kill() + except Exception: + pass + self._processes.clear() + + def _get_docker_compose_cmd(self) -> list[str] | None: + """Get the docker-compose command (v1 or v2).""" + # Try docker compose v2 first + try: + proc = subprocess.run( + ["docker", "compose", "version"], + capture_output=True, + timeout=5, + ) + if proc.returncode == 0: + return ["docker", "compose", "-f", str(self._compose_file)] + except Exception: + pass + + # Try docker-compose v1 + try: + proc = subprocess.run( + ["docker-compose", "version"], + capture_output=True, + timeout=5, + ) + if proc.returncode == 0: + return ["docker-compose", "-f", str(self._compose_file)] + except Exception: + pass + + return None + + def _wait_for_health(self, timeout: int) -> bool: + """ + Wait for all services to become healthy. + + Args: + timeout: Maximum time to wait in seconds + + Returns: + True if all services became healthy + """ + start_time = time.time() + + while time.time() - start_time < timeout: + all_healthy = True + + for service in self._services: + if service.port: + if not self._check_port(service.port): + all_healthy = False + break + + if all_healthy: + return True + + time.sleep(2) + + return False + + def _check_port(self, port: int) -> bool: + """Check if a port is responding.""" + import socket + + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + result = s.connect_ex(("localhost", port)) + return result == 0 + except Exception: + return False + + def to_dict(self) -> dict[str, Any]: + """Convert orchestration config to dictionary.""" + return { + "is_multi_service": self.is_multi_service(), + "has_docker_compose": self.has_docker_compose(), + "compose_file": str(self._compose_file) if self._compose_file else None, + "services": [ + { + "name": s.name, + "path": s.path, + "port": s.port, + "type": s.type, + "health_check_url": s.health_check_url, + } + for s in self._services + ], + } + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def is_multi_service_project(project_dir: Path) -> bool: + """ + Check if project is multi-service. + + Args: + project_dir: Path to project root + + Returns: + True if multi-service project + """ + orchestrator = ServiceOrchestrator(project_dir) + return orchestrator.is_multi_service() + + +def get_service_config(project_dir: Path) -> dict[str, Any]: + """ + Get service configuration for project. + + Args: + project_dir: Path to project root + + Returns: + Dictionary with service configuration + """ + orchestrator = ServiceOrchestrator(project_dir) + return orchestrator.to_dict() + + +# ============================================================================= +# CONTEXT MANAGER +# ============================================================================= + + +class ServiceContext: + """ + Context manager for service orchestration. + + Usage: + with ServiceContext(project_dir) as services: + # Services are running + run_tests() + # Services are stopped + """ + + def __init__(self, project_dir: Path, timeout: int = 120) -> None: + """Initialize service context.""" + self.orchestrator = ServiceOrchestrator(project_dir) + self.timeout = timeout + self.result: OrchestrationResult | None = None + + def __enter__(self) -> "ServiceContext": + """Start services on context entry.""" + if self.orchestrator.is_multi_service(): + self.result = self.orchestrator.start_services(self.timeout) + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Stop services on context exit.""" + self.orchestrator.stop_services() + + @property + def success(self) -> bool: + """Check if services started successfully.""" + if self.result: + return self.result.success + return True # No services to start + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Service orchestration") + parser.add_argument("project_dir", type=Path, help="Path to project root") + parser.add_argument("--start", action="store_true", help="Start services") + parser.add_argument("--stop", action="store_true", help="Stop services") + parser.add_argument("--status", action="store_true", help="Show service status") + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + orchestrator = ServiceOrchestrator(args.project_dir) + + if args.start: + result = orchestrator.start_services() + if args.json: + print( + json.dumps( + { + "success": result.success, + "services_started": result.services_started, + "errors": result.errors, + }, + indent=2, + ) + ) + else: + print(f"Started: {result.services_started}") + if result.errors: + print(f"Errors: {result.errors}") + elif args.stop: + orchestrator.stop_services() + print("Services stopped") + else: + # Default: show status + config = orchestrator.to_dict() + + if args.json: + print(json.dumps(config, indent=2)) + else: + print(f"Multi-service: {config['is_multi_service']}") + print(f"Docker Compose: {config['has_docker_compose']}") + if config["compose_file"]: + print(f"Compose File: {config['compose_file']}") + print(f"\nServices ({len(config['services'])}):") + for service in config["services"]: + port_info = f":{service['port']}" if service["port"] else "" + print(f" - {service['name']} ({service['type']}){port_info}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/services/recovery.py b/auto-claude/services/recovery.py new file mode 100644 index 00000000..af6fb66c --- /dev/null +++ b/auto-claude/services/recovery.py @@ -0,0 +1,606 @@ +""" +Smart Rollback and Recovery System +=================================== + +Automatic recovery from build failures, stuck loops, and broken builds. +Enables true "walk away" automation by detecting and recovering from common failure modes. + +Key Features: +- Automatic rollback to last working state +- Circular fix detection (prevents infinite loops) +- Attempt history tracking across sessions +- Smart retry with different approaches +- Escalation to human when stuck +""" + +import json +import subprocess +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from pathlib import Path + + +class FailureType(Enum): + """Types of failures that can occur during autonomous builds.""" + + BROKEN_BUILD = "broken_build" # Code doesn't compile/run + VERIFICATION_FAILED = "verification_failed" # Subtask verification failed + CIRCULAR_FIX = "circular_fix" # Same fix attempted multiple times + CONTEXT_EXHAUSTED = "context_exhausted" # Ran out of context mid-subtask + UNKNOWN = "unknown" + + +@dataclass +class RecoveryAction: + """Action to take in response to a failure.""" + + action: str # "rollback", "retry", "skip", "escalate" + target: str # commit hash, subtask id, or message + reason: str + + +class RecoveryManager: + """ + Manages recovery from build failures. + + Responsibilities: + - Track attempt history across sessions + - Classify failures and determine recovery actions + - Rollback to working states + - Detect circular fixes (same approach repeatedly) + - Escalate stuck subtasks for human intervention + """ + + def __init__(self, spec_dir: Path, project_dir: Path): + """ + Initialize recovery manager. + + Args: + spec_dir: Spec directory containing memory/ + project_dir: Root project directory for git operations + """ + self.spec_dir = spec_dir + self.project_dir = project_dir + self.memory_dir = spec_dir / "memory" + self.attempt_history_file = self.memory_dir / "attempt_history.json" + self.build_commits_file = self.memory_dir / "build_commits.json" + + # Ensure memory directory exists + self.memory_dir.mkdir(parents=True, exist_ok=True) + + # Initialize files if they don't exist + if not self.attempt_history_file.exists(): + self._init_attempt_history() + + if not self.build_commits_file.exists(): + self._init_build_commits() + + def _init_attempt_history(self) -> None: + """Initialize the attempt history file.""" + initial_data = { + "subtasks": {}, + "stuck_subtasks": [], + "metadata": { + "created_at": datetime.now().isoformat(), + "last_updated": datetime.now().isoformat(), + }, + } + with open(self.attempt_history_file, "w") as f: + json.dump(initial_data, f, indent=2) + + def _init_build_commits(self) -> None: + """Initialize the build commits tracking file.""" + initial_data = { + "commits": [], + "last_good_commit": None, + "metadata": { + "created_at": datetime.now().isoformat(), + "last_updated": datetime.now().isoformat(), + }, + } + with open(self.build_commits_file, "w") as f: + json.dump(initial_data, f, indent=2) + + def _load_attempt_history(self) -> dict: + """Load attempt history from JSON file.""" + try: + with open(self.attempt_history_file) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + self._init_attempt_history() + with open(self.attempt_history_file) as f: + return json.load(f) + + def _save_attempt_history(self, data: dict) -> None: + """Save attempt history to JSON file.""" + data["metadata"]["last_updated"] = datetime.now().isoformat() + with open(self.attempt_history_file, "w") as f: + json.dump(data, f, indent=2) + + def _load_build_commits(self) -> dict: + """Load build commits from JSON file.""" + try: + with open(self.build_commits_file) as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + self._init_build_commits() + with open(self.build_commits_file) as f: + return json.load(f) + + def _save_build_commits(self, data: dict) -> None: + """Save build commits to JSON file.""" + data["metadata"]["last_updated"] = datetime.now().isoformat() + with open(self.build_commits_file, "w") as f: + json.dump(data, f, indent=2) + + def classify_failure(self, error: str, subtask_id: str) -> FailureType: + """ + Classify what type of failure occurred. + + Args: + error: Error message or description + subtask_id: ID of the subtask that failed + + Returns: + FailureType enum value + """ + error_lower = error.lower() + + # Check for broken build indicators + build_errors = [ + "syntax error", + "compilation error", + "module not found", + "import error", + "cannot find module", + "unexpected token", + "indentation error", + "parse error", + ] + if any(be in error_lower for be in build_errors): + return FailureType.BROKEN_BUILD + + # Check for verification failures + verification_errors = [ + "verification failed", + "expected", + "assertion", + "test failed", + "status code", + ] + if any(ve in error_lower for ve in verification_errors): + return FailureType.VERIFICATION_FAILED + + # Check for context exhaustion + context_errors = ["context", "token limit", "maximum length"] + if any(ce in error_lower for ce in context_errors): + return FailureType.CONTEXT_EXHAUSTED + + # Check for circular fixes (will be determined by attempt history) + if self.is_circular_fix(subtask_id, error): + return FailureType.CIRCULAR_FIX + + return FailureType.UNKNOWN + + def get_attempt_count(self, subtask_id: str) -> int: + """ + Get how many times this subtask has been attempted. + + Args: + subtask_id: ID of the subtask + + Returns: + Number of attempts + """ + history = self._load_attempt_history() + subtask_data = history["subtasks"].get(subtask_id, {}) + return len(subtask_data.get("attempts", [])) + + def record_attempt( + self, + subtask_id: str, + session: int, + success: bool, + approach: str, + error: str | None = None, + ) -> None: + """ + Record an attempt at a subtask. + + Args: + subtask_id: ID of the subtask + session: Session number + success: Whether the attempt succeeded + approach: Description of the approach taken + error: Error message if failed + """ + history = self._load_attempt_history() + + # Initialize subtask entry if it doesn't exist + if subtask_id not in history["subtasks"]: + history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"} + + # Add the attempt + attempt = { + "session": session, + "timestamp": datetime.now().isoformat(), + "approach": approach, + "success": success, + "error": error, + } + history["subtasks"][subtask_id]["attempts"].append(attempt) + + # Update status + if success: + history["subtasks"][subtask_id]["status"] = "completed" + else: + history["subtasks"][subtask_id]["status"] = "failed" + + self._save_attempt_history(history) + + def is_circular_fix(self, subtask_id: str, current_approach: str) -> bool: + """ + Detect if we're trying the same approach repeatedly. + + Args: + subtask_id: ID of the subtask + current_approach: Description of current approach + + Returns: + True if this appears to be a circular fix attempt + """ + history = self._load_attempt_history() + subtask_data = history["subtasks"].get(subtask_id, {}) + attempts = subtask_data.get("attempts", []) + + if len(attempts) < 2: + return False + + # Check if last 3 attempts used similar approaches + # Simple similarity check: look for repeated keywords + recent_attempts = attempts[-3:] if len(attempts) >= 3 else attempts + + # Extract key terms from current approach (ignore common words) + stop_words = { + "with", + "using", + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "trying", + } + current_keywords = set( + word for word in current_approach.lower().split() if word not in stop_words + ) + + similar_count = 0 + for attempt in recent_attempts: + attempt_keywords = set( + word + for word in attempt["approach"].lower().split() + if word not in stop_words + ) + + # Calculate Jaccard similarity (intersection over union) + overlap = len(current_keywords & attempt_keywords) + total = len(current_keywords | attempt_keywords) + + if total > 0: + similarity = overlap / total + # If >30% of meaningful words overlap, consider it similar + # This catches key technical terms appearing repeatedly + # (e.g., "async await" across multiple attempts) + if similarity > 0.3: + similar_count += 1 + + # If 2+ recent attempts were similar to current approach, it's circular + return similar_count >= 2 + + def determine_recovery_action( + self, failure_type: FailureType, subtask_id: str + ) -> RecoveryAction: + """ + Decide what to do based on failure type and history. + + Args: + failure_type: Type of failure that occurred + subtask_id: ID of the subtask that failed + + Returns: + RecoveryAction describing what to do + """ + attempt_count = self.get_attempt_count(subtask_id) + + if failure_type == FailureType.BROKEN_BUILD: + # Broken build: rollback to last good state + last_good = self.get_last_good_commit() + if last_good: + return RecoveryAction( + action="rollback", + target=last_good, + reason=f"Build broken in subtask {subtask_id}, rolling back to working state", + ) + else: + return RecoveryAction( + action="escalate", + target=subtask_id, + reason="Build broken and no good commit found to rollback to", + ) + + elif failure_type == FailureType.VERIFICATION_FAILED: + # Verification failed: retry with different approach if < 3 attempts + if attempt_count < 3: + return RecoveryAction( + action="retry", + target=subtask_id, + reason=f"Verification failed, retry with different approach (attempt {attempt_count + 1}/3)", + ) + else: + return RecoveryAction( + action="skip", + target=subtask_id, + reason=f"Verification failed after {attempt_count} attempts, marking as stuck", + ) + + elif failure_type == FailureType.CIRCULAR_FIX: + # Circular fix detected: skip and escalate + return RecoveryAction( + action="skip", + target=subtask_id, + reason="Circular fix detected - same approach tried multiple times", + ) + + elif failure_type == FailureType.CONTEXT_EXHAUSTED: + # Context exhausted: commit current progress and continue + return RecoveryAction( + action="continue", + target=subtask_id, + reason="Context exhausted, will commit progress and continue in next session", + ) + + else: # UNKNOWN + # Unknown error: retry once, then escalate + if attempt_count < 2: + return RecoveryAction( + action="retry", + target=subtask_id, + reason=f"Unknown error, retrying (attempt {attempt_count + 1}/2)", + ) + else: + return RecoveryAction( + action="escalate", + target=subtask_id, + reason=f"Unknown error persists after {attempt_count} attempts", + ) + + def get_last_good_commit(self) -> str | None: + """ + Find the most recent commit where build was working. + + Returns: + Commit hash or None + """ + commits = self._load_build_commits() + return commits.get("last_good_commit") + + def record_good_commit(self, commit_hash: str, subtask_id: str) -> None: + """ + Record a commit where the build was working. + + Args: + commit_hash: Git commit hash + subtask_id: Subtask that was successfully completed + """ + commits = self._load_build_commits() + + commit_record = { + "hash": commit_hash, + "subtask_id": subtask_id, + "timestamp": datetime.now().isoformat(), + } + + commits["commits"].append(commit_record) + commits["last_good_commit"] = commit_hash + + self._save_build_commits(commits) + + def rollback_to_commit(self, commit_hash: str) -> bool: + """ + Rollback to a specific commit. + + Args: + commit_hash: Git commit hash to rollback to + + Returns: + True if successful, False otherwise + """ + try: + # Use git reset --hard to rollback + result = subprocess.run( + ["git", "reset", "--hard", commit_hash], + cwd=self.project_dir, + capture_output=True, + text=True, + check=True, + ) + return True + except subprocess.CalledProcessError as e: + print(f"Error rolling back to {commit_hash}: {e.stderr}") + return False + + def mark_subtask_stuck(self, subtask_id: str, reason: str) -> None: + """ + Mark a subtask as needing human intervention. + + Args: + subtask_id: ID of the subtask + reason: Why it's stuck + """ + history = self._load_attempt_history() + + stuck_entry = { + "subtask_id": subtask_id, + "reason": reason, + "escalated_at": datetime.now().isoformat(), + "attempt_count": self.get_attempt_count(subtask_id), + } + + # Check if already in stuck list + existing = [ + s for s in history["stuck_subtasks"] if s["subtask_id"] == subtask_id + ] + if not existing: + history["stuck_subtasks"].append(stuck_entry) + + # Update subtask status + if subtask_id in history["subtasks"]: + history["subtasks"][subtask_id]["status"] = "stuck" + + self._save_attempt_history(history) + + def get_stuck_subtasks(self) -> list[dict]: + """ + Get all subtasks marked as stuck. + + Returns: + List of stuck subtask entries + """ + history = self._load_attempt_history() + return history.get("stuck_subtasks", []) + + def get_subtask_history(self, subtask_id: str) -> dict: + """ + Get the attempt history for a specific subtask. + + Args: + subtask_id: ID of the subtask + + Returns: + Subtask history dict with attempts + """ + history = self._load_attempt_history() + return history["subtasks"].get( + subtask_id, {"attempts": [], "status": "pending"} + ) + + def get_recovery_hints(self, subtask_id: str) -> list[str]: + """ + Get hints for recovery based on previous attempts. + + Args: + subtask_id: ID of the subtask + + Returns: + List of hint strings + """ + subtask_history = self.get_subtask_history(subtask_id) + attempts = subtask_history.get("attempts", []) + + if not attempts: + return ["This is the first attempt at this subtask"] + + hints = [f"Previous attempts: {len(attempts)}"] + + # Add info about what was tried + for i, attempt in enumerate(attempts[-3:], 1): + hints.append( + f"Attempt {i}: {attempt['approach']} - " + f"{'SUCCESS' if attempt['success'] else 'FAILED'}" + ) + if attempt.get("error"): + hints.append(f" Error: {attempt['error'][:100]}") + + # Add guidance + if len(attempts) >= 2: + hints.append( + "\n⚠️ IMPORTANT: Try a DIFFERENT approach than previous attempts" + ) + hints.append( + "Consider: different library, different pattern, or simpler implementation" + ) + + return hints + + def clear_stuck_subtasks(self) -> None: + """Clear all stuck subtasks (for manual resolution).""" + history = self._load_attempt_history() + history["stuck_subtasks"] = [] + self._save_attempt_history(history) + + def reset_subtask(self, subtask_id: str) -> None: + """ + Reset a subtask's attempt history. + + Args: + subtask_id: ID of the subtask to reset + """ + history = self._load_attempt_history() + + # Clear attempt history + if subtask_id in history["subtasks"]: + history["subtasks"][subtask_id] = {"attempts": [], "status": "pending"} + + # Remove from stuck subtasks + history["stuck_subtasks"] = [ + s for s in history["stuck_subtasks"] if s["subtask_id"] != subtask_id + ] + + self._save_attempt_history(history) + + +# Utility functions for integration with agent.py + + +def check_and_recover( + spec_dir: Path, project_dir: Path, subtask_id: str, error: str | None = None +) -> RecoveryAction | None: + """ + Check if recovery is needed and return appropriate action. + + Args: + spec_dir: Spec directory + project_dir: Project directory + subtask_id: Current subtask ID + error: Error message if any + + Returns: + RecoveryAction if recovery needed, None otherwise + """ + if not error: + return None + + manager = RecoveryManager(spec_dir, project_dir) + failure_type = manager.classify_failure(error, subtask_id) + + return manager.determine_recovery_action(failure_type, subtask_id) + + +def get_recovery_context(spec_dir: Path, project_dir: Path, subtask_id: str) -> dict: + """ + Get recovery context for a subtask (for prompt generation). + + Args: + spec_dir: Spec directory + project_dir: Project directory + subtask_id: Subtask ID + + Returns: + Dict with recovery hints and history + """ + manager = RecoveryManager(spec_dir, project_dir) + + return { + "attempt_count": manager.get_attempt_count(subtask_id), + "hints": manager.get_recovery_hints(subtask_id), + "subtask_history": manager.get_subtask_history(subtask_id), + "stuck_subtasks": manager.get_stuck_subtasks(), + } diff --git a/auto-claude/spec/critique.py b/auto-claude/spec/critique.py new file mode 100644 index 00000000..3308db84 --- /dev/null +++ b/auto-claude/spec/critique.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Self-Critique System +==================== + +Implements a self-critique loop that agents must run before marking subtasks complete. +This helps catch quality issues early, before verification stage. + +The critique system ensures: +- Code follows patterns from reference files +- All required files were modified/created +- Error handling is present +- No debugging artifacts left behind +- Implementation matches subtask requirements +""" + +import re +from dataclasses import dataclass, field + + +@dataclass +class CritiqueResult: + """Result of a self-critique evaluation.""" + + passes: bool + issues: list[str] = field(default_factory=list) + improvements_made: list[str] = field(default_factory=list) + recommendations: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + """Convert to dictionary for storage.""" + return { + "passes": self.passes, + "issues": self.issues, + "improvements_made": self.improvements_made, + "recommendations": self.recommendations, + } + + @classmethod + def from_dict(cls, data: dict) -> "CritiqueResult": + """Load from dictionary.""" + return cls( + passes=data.get("passes", False), + issues=data.get("issues", []), + improvements_made=data.get("improvements_made", []), + recommendations=data.get("recommendations", []), + ) + + +def generate_critique_prompt( + subtask: dict, files_modified: list[str], patterns_from: list[str] +) -> str: + """ + Generate a critique prompt for the agent to self-evaluate. + + Args: + subtask: The subtask being implemented + files_modified: List of files actually modified + patterns_from: List of pattern files to compare against + + Returns: + Formatted prompt for self-critique + """ + subtask_id = subtask.get("id", "unknown") + subtask_desc = subtask.get("description", "No description") + service = subtask.get("service", "all services") + files_to_modify = subtask.get("files_to_modify", []) + files_to_create = subtask.get("files_to_create", []) + + prompt = f"""## MANDATORY Self-Critique: {subtask_id} + +**Subtask Description:** {subtask_desc} +**Service:** {service} + +Before marking this subtask as complete, you MUST perform a thorough self-critique. +This is NOT optional - it's a required quality gate. + +### STEP 1: Code Quality Checklist + +Review your implementation against these criteria: + +**Pattern Adherence:** +- [ ] Follows patterns from reference files exactly: {", ".join(patterns_from) if patterns_from else "N/A"} +- [ ] Variable naming matches codebase conventions +- [ ] Imports organized correctly (grouped, sorted) +- [ ] Code style consistent with existing files + +**Error Handling:** +- [ ] Try-catch blocks where operations can fail +- [ ] Meaningful error messages +- [ ] Proper error propagation +- [ ] Edge cases considered + +**Code Cleanliness:** +- [ ] No console.log/print statements for debugging +- [ ] No commented-out code blocks +- [ ] No TODO comments without context +- [ ] No hardcoded values that should be configurable + +**Best Practices:** +- [ ] Functions are focused and single-purpose +- [ ] No code duplication +- [ ] Appropriate use of constants +- [ ] Documentation/comments where needed + +### STEP 2: Implementation Completeness + +**Files Modified:** +Expected: {", ".join(files_to_modify) if files_to_modify else "None"} +Actual: {", ".join(files_modified) if files_modified else "None"} +- [ ] All files_to_modify were actually modified +- [ ] No unexpected files were modified + +**Files Created:** +Expected: {", ".join(files_to_create) if files_to_create else "None"} +- [ ] All files_to_create were actually created +- [ ] Files follow naming conventions + +**Requirements:** +- [ ] Subtask description requirements fully met +- [ ] All acceptance criteria from spec considered +- [ ] No scope creep - stayed within subtask boundaries + +### STEP 3: Potential Issues Analysis + +List any concerns, limitations, or potential problems with your implementation: + +1. [Issue 1, or "None identified"] +2. [Issue 2, if any] +3. [Issue 3, if any] + +Be honest. Finding issues now is better than discovering them during verification. + +### STEP 4: Improvements Made + +If you identified issues in your critique, list what you fixed: + +1. [Improvement 1, or "No fixes needed"] +2. [Improvement 2, if applicable] +3. [Improvement 3, if applicable] + +### STEP 5: Final Verdict + +**PROCEED:** [YES/NO - Only YES if all critical items pass] + +**REASON:** [Brief explanation of your decision] + +**CONFIDENCE:** [High/Medium/Low - How confident are you in this implementation?] + +--- + +## Instructions for Agent + +1. Work through each section methodically +2. Check each box honestly - don't skip items +3. If you find issues, FIX THEM before continuing +4. Re-run this critique after fixes +5. Only mark the subtask complete when verdict is YES with High confidence +6. Document your critique results in your response + +Remember: The next session has no context. Quality issues you miss now will be harder to fix later. +""" + + return prompt + + +def parse_critique_response(response: str) -> CritiqueResult: + """ + Parse the agent's critique response into structured data. + + Args: + response: The agent's response to the critique prompt + + Returns: + CritiqueResult with parsed information + """ + issues = [] + improvements = [] + recommendations = [] + passes = False + + # Extract PROCEED verdict + proceed_match = re.search( + r"\*\*PROCEED:\*\*\s*\[?\s*(YES|NO)", response, re.IGNORECASE + ) + if proceed_match: + passes = proceed_match.group(1).upper() == "YES" + + # Extract issues from Step 3 + issues_section = re.search( + r"### STEP 3:.*?Potential Issues.*?\n\n(.*?)(?=###|\Z)", + response, + re.DOTALL | re.IGNORECASE, + ) + if issues_section: + issue_lines = issues_section.group(1).strip().split("\n") + for line in issue_lines: + line = line.strip() + if not line or line.startswith("---"): + continue + # Remove list markers + issue = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip() + # Skip if it's a placeholder or indicates no issues + if ( + issue + and issue.lower() + not in ["none", "none identified", "no issues", "no concerns"] + and issue + not in [ + '[Issue 1, or "None identified"]', + "[Issue 2, if any]", + "[Issue 3, if any]", + ] + ): + issues.append(issue) + + # Extract improvements from Step 4 + improvements_section = re.search( + r"### STEP 4:.*?Improvements Made.*?\n\n(.*?)(?=###|\Z)", + response, + re.DOTALL | re.IGNORECASE, + ) + if improvements_section: + improvement_lines = improvements_section.group(1).strip().split("\n") + for line in improvement_lines: + line = line.strip() + if not line or line.startswith("---"): + continue + # Remove list markers + improvement = re.sub(r"^\d+\.\s*|\*\s*|-\s*", "", line).strip() + # Skip if it's a placeholder or indicates no improvements + if ( + improvement + and improvement.lower() + not in ["none", "no fixes needed", "no improvements", "n/a"] + and improvement + not in [ + '[Improvement 1, or "No fixes needed"]', + "[Improvement 2, if applicable]", + "[Improvement 3, if applicable]", + ] + ): + improvements.append(improvement) + + # Extract confidence level as recommendation + confidence_match = re.search( + r"\*\*CONFIDENCE:\*\*\s*\[?\s*(High|Medium|Low)", response, re.IGNORECASE + ) + if confidence_match: + confidence = confidence_match.group(1) + if confidence.lower() != "high": + recommendations.append( + f"Confidence level: {confidence} - consider additional review" + ) + + return CritiqueResult( + passes=passes, + issues=issues, + improvements_made=improvements, + recommendations=recommendations, + ) + + +def should_proceed(result: CritiqueResult) -> bool: + """ + Determine if the subtask should be marked complete based on critique. + + Args: + result: The critique result + + Returns: + True if subtask can be marked complete, False otherwise + """ + # Must pass the critique + if not result.passes: + return False + + # If there are unresolved issues, don't proceed + if result.issues: + return False + + return True + + +def format_critique_summary(result: CritiqueResult) -> str: + """ + Format a critique result as a human-readable summary. + + Args: + result: The critique result + + Returns: + Formatted summary string + """ + lines = ["## Critique Summary"] + lines.append("") + lines.append(f"**Status:** {'PASSED ✓' if result.passes else 'FAILED ✗'}") + lines.append("") + + if result.issues: + lines.append("**Issues Identified:**") + for i, issue in enumerate(result.issues, 1): + lines.append(f"{i}. {issue}") + lines.append("") + + if result.improvements_made: + lines.append("**Improvements Made:**") + for i, improvement in enumerate(result.improvements_made, 1): + lines.append(f"{i}. {improvement}") + lines.append("") + + if result.recommendations: + lines.append("**Recommendations:**") + for i, rec in enumerate(result.recommendations, 1): + lines.append(f"{i}. {rec}") + lines.append("") + + if should_proceed(result): + lines.append("**Decision:** Subtask is ready to be marked complete.") + else: + lines.append("**Decision:** Subtask needs more work before completion.") + + return "\n".join(lines) + + +# Example usage for testing +if __name__ == "__main__": + # Demo subtask + subtask = { + "id": "auth-middleware", + "description": "Add JWT authentication middleware", + "service": "backend", + "files_to_modify": ["app/middleware/auth.py"], + "patterns_from": ["app/middleware/cors.py"], + } + + files_modified = ["app/middleware/auth.py"] + + # Generate prompt + prompt = generate_critique_prompt(subtask, files_modified, subtask["patterns_from"]) + print(prompt) + print("\n" + "=" * 80 + "\n") + + # Simulate a critique response + sample_response = """ +### STEP 3: Potential Issues Analysis + +1. Token expiration edge case not fully tested +2. None + +### STEP 4: Improvements Made + +1. Added comprehensive error handling for invalid tokens +2. Improved logging for debugging +3. Added input validation for JWT format + +### STEP 5: Final Verdict + +**PROCEED:** YES + +**REASON:** All critical items verified, patterns followed, error handling complete + +**CONFIDENCE:** High +""" + + # Parse response + result = parse_critique_response(sample_response) + print(format_critique_summary(result)) + print(f"\nShould proceed: {should_proceed(result)}") diff --git a/auto-claude/spec/phases/planning_phases.py b/auto-claude/spec/phases/planning_phases.py index b19125b4..314387d6 100644 --- a/auto-claude/spec/phases/planning_phases.py +++ b/auto-claude/spec/phases/planning_phases.py @@ -22,7 +22,7 @@ class PlanningPhaseMixin: async def phase_planning(self) -> PhaseResult: """Create the implementation plan.""" - from validate_spec.auto_fix import auto_fix_plan + from ..validate_pkg.auto_fix import auto_fix_plan plan_file = self.spec_dir / "implementation_plan.json" diff --git a/auto-claude/spec/pipeline/__init__.py b/auto-claude/spec/pipeline/__init__.py index e4476fd0..515c5f1c 100644 --- a/auto-claude/spec/pipeline/__init__.py +++ b/auto-claude/spec/pipeline/__init__.py @@ -10,10 +10,12 @@ Components: - orchestrator: Main SpecOrchestrator class """ +from init import init_auto_claude_dir from .models import get_specs_dir from .orchestrator import SpecOrchestrator __all__ = [ "SpecOrchestrator", "get_specs_dir", + "init_auto_claude_dir", ] diff --git a/auto-claude/spec/pipeline/orchestrator.py b/auto-claude/spec/pipeline/orchestrator.py index df8df91a..0c81977f 100644 --- a/auto-claude/spec/pipeline/orchestrator.py +++ b/auto-claude/spec/pipeline/orchestrator.py @@ -25,7 +25,7 @@ from ui import ( print_section, print_status, ) -from validate_spec.spec_validator import SpecValidator +from ..validate_pkg.spec_validator import SpecValidator from .. import complexity, phases, requirements from .agent_runner import AgentRunner @@ -547,3 +547,41 @@ class SpecOrchestrator: return False return True + + # Backward compatibility methods for tests + def _generate_spec_name(self, task_description: str) -> str: + """Generate a spec name from task description (backward compatibility). + + This method is kept for backward compatibility with existing tests. + The functionality has been moved to models.generate_spec_name. + + Args: + task_description: The task description + + Returns: + Generated spec name + """ + from .models import generate_spec_name + + return generate_spec_name(task_description) + + def _rename_spec_dir_from_requirements(self) -> bool: + """Rename spec directory from requirements (backward compatibility). + + This method is kept for backward compatibility with existing tests. + The functionality has been moved to models.rename_spec_dir_from_requirements. + + Returns: + True if successful or not needed, False on error + """ + result = rename_spec_dir_from_requirements(self.spec_dir) + # Update self.spec_dir if it was renamed + if result and self.spec_dir.name.endswith("-pending"): + # Find the renamed directory + parent = self.spec_dir.parent + prefix = self.spec_dir.name[:4] # e.g., "001-" + for candidate in parent.iterdir(): + if candidate.name.startswith(prefix) and "pending" not in candidate.name: + self.spec_dir = candidate + break + return result diff --git a/auto-claude/validate_spec/MIGRATION.md b/auto-claude/spec/validate_pkg/MIGRATION.md similarity index 100% rename from auto-claude/validate_spec/MIGRATION.md rename to auto-claude/spec/validate_pkg/MIGRATION.md diff --git a/auto-claude/validate_spec/README.md b/auto-claude/spec/validate_pkg/README.md similarity index 100% rename from auto-claude/validate_spec/README.md rename to auto-claude/spec/validate_pkg/README.md diff --git a/auto-claude/spec/validate_pkg/__init__.py b/auto-claude/spec/validate_pkg/__init__.py new file mode 100644 index 00000000..9f4061e9 --- /dev/null +++ b/auto-claude/spec/validate_pkg/__init__.py @@ -0,0 +1,19 @@ +""" +Spec Validation System +====================== + +Validates spec outputs at each checkpoint to ensure reliability. +This is the enforcement layer that catches errors before they propagate. + +The spec creation process has mandatory checkpoints: +1. Prerequisites (project_index.json exists) +2. Context (context.json created with required fields) +3. Spec document (spec.md with required sections) +4. Implementation plan (implementation_plan.json with valid schema) +""" + +from .auto_fix import auto_fix_plan +from .models import ValidationResult +from .spec_validator import SpecValidator + +__all__ = ["SpecValidator", "ValidationResult", "auto_fix_plan"] diff --git a/auto-claude/spec/validate_pkg/auto_fix.py b/auto-claude/spec/validate_pkg/auto_fix.py new file mode 100644 index 00000000..e89e8ca6 --- /dev/null +++ b/auto-claude/spec/validate_pkg/auto_fix.py @@ -0,0 +1,80 @@ +""" +Auto-Fix Utilities +================== + +Automated fixes for common implementation plan issues. +""" + +import json +from pathlib import Path + + +def auto_fix_plan(spec_dir: Path) -> bool: + """Attempt to auto-fix common implementation_plan.json issues. + + Args: + spec_dir: Path to the spec directory + + Returns: + True if fixes were applied, False otherwise + """ + plan_file = spec_dir / "implementation_plan.json" + + if not plan_file.exists(): + return False + + try: + with open(plan_file) as f: + plan = json.load(f) + except json.JSONDecodeError: + return False + + fixed = False + + # Fix missing top-level fields + if "feature" not in plan: + plan["feature"] = "Unnamed Feature" + fixed = True + + if "workflow_type" not in plan: + plan["workflow_type"] = "feature" + fixed = True + + if "phases" not in plan: + plan["phases"] = [] + fixed = True + + # Fix phases + for i, phase in enumerate(plan.get("phases", [])): + if "phase" not in phase: + phase["phase"] = i + 1 + fixed = True + + if "name" not in phase: + phase["name"] = f"Phase {i + 1}" + fixed = True + + if "subtasks" not in phase: + phase["subtasks"] = [] + fixed = True + + # Fix subtasks + for j, subtask in enumerate(phase.get("subtasks", [])): + if "id" not in subtask: + subtask["id"] = f"subtask-{i + 1}-{j + 1}" + fixed = True + + if "description" not in subtask: + subtask["description"] = "No description" + fixed = True + + if "status" not in subtask: + subtask["status"] = "pending" + fixed = True + + if fixed: + with open(plan_file, "w") as f: + json.dump(plan, f, indent=2) + print(f"Auto-fixed: {plan_file}") + + return fixed diff --git a/auto-claude/validate_spec/models.py b/auto-claude/spec/validate_pkg/models.py similarity index 100% rename from auto-claude/validate_spec/models.py rename to auto-claude/spec/validate_pkg/models.py diff --git a/auto-claude/validate_spec/schemas.py b/auto-claude/spec/validate_pkg/schemas.py similarity index 100% rename from auto-claude/validate_spec/schemas.py rename to auto-claude/spec/validate_pkg/schemas.py diff --git a/auto-claude/spec/validate_pkg/spec_validator.py b/auto-claude/spec/validate_pkg/spec_validator.py new file mode 100644 index 00000000..1b8064de --- /dev/null +++ b/auto-claude/spec/validate_pkg/spec_validator.py @@ -0,0 +1,80 @@ +""" +Spec Validator +============== + +Main validator class that orchestrates all validation checkpoints. +""" + +from pathlib import Path + +from .models import ValidationResult +from .validators import ( + ContextValidator, + ImplementationPlanValidator, + PrereqsValidator, + SpecDocumentValidator, +) + + +class SpecValidator: + """Validates spec outputs at each checkpoint.""" + + def __init__(self, spec_dir: Path): + """Initialize the spec validator. + + Args: + spec_dir: Path to the spec directory + """ + self.spec_dir = Path(spec_dir) + + # Initialize individual validators + self._prereqs_validator = PrereqsValidator(self.spec_dir) + self._context_validator = ContextValidator(self.spec_dir) + self._spec_document_validator = SpecDocumentValidator(self.spec_dir) + self._implementation_plan_validator = ImplementationPlanValidator(self.spec_dir) + + def validate_all(self) -> list[ValidationResult]: + """Run all validations. + + Returns: + List of validation results for all checkpoints + """ + results = [ + self.validate_prereqs(), + self.validate_context(), + self.validate_spec_document(), + self.validate_implementation_plan(), + ] + return results + + def validate_prereqs(self) -> ValidationResult: + """Validate prerequisites exist. + + Returns: + ValidationResult for prerequisites checkpoint + """ + return self._prereqs_validator.validate() + + def validate_context(self) -> ValidationResult: + """Validate context.json exists and has required structure. + + Returns: + ValidationResult for context checkpoint + """ + return self._context_validator.validate() + + def validate_spec_document(self) -> ValidationResult: + """Validate spec.md exists and has required sections. + + Returns: + ValidationResult for spec document checkpoint + """ + return self._spec_document_validator.validate() + + def validate_implementation_plan(self) -> ValidationResult: + """Validate implementation_plan.json exists and has valid schema. + + Returns: + ValidationResult for implementation plan checkpoint + """ + return self._implementation_plan_validator.validate() diff --git a/auto-claude/validate_spec/validators/__init__.py b/auto-claude/spec/validate_pkg/validators/__init__.py similarity index 100% rename from auto-claude/validate_spec/validators/__init__.py rename to auto-claude/spec/validate_pkg/validators/__init__.py diff --git a/auto-claude/validate_spec/validators/context_validator.py b/auto-claude/spec/validate_pkg/validators/context_validator.py similarity index 100% rename from auto-claude/validate_spec/validators/context_validator.py rename to auto-claude/spec/validate_pkg/validators/context_validator.py diff --git a/auto-claude/validate_spec/validators/implementation_plan_validator.py b/auto-claude/spec/validate_pkg/validators/implementation_plan_validator.py similarity index 100% rename from auto-claude/validate_spec/validators/implementation_plan_validator.py rename to auto-claude/spec/validate_pkg/validators/implementation_plan_validator.py diff --git a/auto-claude/validate_spec/validators/prereqs_validator.py b/auto-claude/spec/validate_pkg/validators/prereqs_validator.py similarity index 100% rename from auto-claude/validate_spec/validators/prereqs_validator.py rename to auto-claude/spec/validate_pkg/validators/prereqs_validator.py diff --git a/auto-claude/validate_spec/validators/spec_document_validator.py b/auto-claude/spec/validate_pkg/validators/spec_document_validator.py similarity index 100% rename from auto-claude/validate_spec/validators/spec_document_validator.py rename to auto-claude/spec/validate_pkg/validators/spec_document_validator.py diff --git a/auto-claude/validate_spec.py b/auto-claude/spec/validate_spec.py similarity index 98% rename from auto-claude/validate_spec.py rename to auto-claude/spec/validate_spec.py index 22475367..5b5cdaba 100644 --- a/auto-claude/validate_spec.py +++ b/auto-claude/spec/validate_spec.py @@ -19,7 +19,7 @@ import json import sys from pathlib import Path -from validate_spec import SpecValidator, auto_fix_plan +from validate_pkg import SpecValidator, auto_fix_plan def main() -> None: diff --git a/auto-claude/spec/validation_strategy.py b/auto-claude/spec/validation_strategy.py new file mode 100644 index 00000000..cad467e2 --- /dev/null +++ b/auto-claude/spec/validation_strategy.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python3 +""" +Validation Strategy Module +========================== + +Builds validation strategies based on project type and risk level. +This module determines how the QA agent should validate implementations. + +The validation strategy is used by: +- Planner Agent: To define verification requirements in the implementation plan +- QA Agent: To determine what tests to create and run + +Usage: + from validation_strategy import ValidationStrategyBuilder + + builder = ValidationStrategyBuilder() + strategy = builder.build_strategy(project_dir, spec_dir, "medium") + + for step in strategy: + print(f"Run: {step.command}") +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from risk_classifier import RiskClassifier + +# ============================================================================= +# DATA CLASSES +# ============================================================================= + + +@dataclass +class ValidationStep: + """ + A single validation step to execute. + + Attributes: + name: Human-readable name of the step + command: Command to execute (or "manual" for manual steps) + expected_outcome: Description of what success looks like + step_type: Type of validation (test, visual, api, security, manual) + required: Whether this step is mandatory + blocking: Whether failure blocks approval + """ + + name: str + command: str + expected_outcome: str + step_type: str # test, visual, api, security, manual + required: bool = True + blocking: bool = True + + +@dataclass +class ValidationStrategy: + """ + Complete validation strategy for a task. + + Attributes: + risk_level: Risk level (trivial, low, medium, high, critical) + project_type: Detected project type + steps: List of validation steps to execute + test_types_required: List of test types to create + security_scan_required: Whether security scanning is needed + staging_deployment_required: Whether staging deployment is needed + skip_validation: Whether validation can be skipped entirely + reasoning: Explanation of the strategy + """ + + risk_level: str + project_type: str + steps: list[ValidationStep] = field(default_factory=list) + test_types_required: list[str] = field(default_factory=list) + security_scan_required: bool = False + staging_deployment_required: bool = False + skip_validation: bool = False + reasoning: str = "" + + +# ============================================================================= +# PROJECT TYPE DETECTION +# ============================================================================= + + +# Project type indicators +PROJECT_TYPE_INDICATORS = { + "html_css": { + "files": ["index.html", "style.css", "styles.css"], + "extensions": [".html", ".css"], + "no_package_manager": True, + }, + "react_spa": { + "dependencies": ["react", "react-dom"], + "files": ["package.json"], + }, + "vue_spa": { + "dependencies": ["vue"], + "files": ["package.json"], + }, + "nextjs": { + "dependencies": ["next"], + "files": ["next.config.js", "next.config.mjs", "next.config.ts"], + }, + "nodejs": { + "files": ["package.json"], + "not_dependencies": ["react", "vue", "next", "angular"], + }, + "python_api": { + "dependencies_python": ["fastapi", "flask", "django"], + "files": ["pyproject.toml", "setup.py", "requirements.txt"], + }, + "python_cli": { + "files": ["pyproject.toml", "setup.py"], + "entry_points": True, + }, + "rust": { + "files": ["Cargo.toml"], + }, + "go": { + "files": ["go.mod"], + }, + "ruby": { + "files": ["Gemfile"], + }, +} + + +def detect_project_type(project_dir: Path) -> str: + """ + Detect the project type based on files and dependencies. + + Args: + project_dir: Path to the project directory + + Returns: + Project type string (e.g., "react_spa", "python_api", "nodejs") + """ + project_dir = Path(project_dir) + + # Check for specific frameworks first + package_json = project_dir / "package.json" + if package_json.exists(): + try: + with open(package_json, encoding="utf-8") as f: + pkg = json.load(f) + deps = pkg.get("dependencies", {}) + dev_deps = pkg.get("devDependencies", {}) + all_deps = {**deps, **dev_deps} + + if "electron" in all_deps: + return "electron" + if "next" in all_deps: + return "nextjs" + if "react" in all_deps: + return "react_spa" + if "vue" in all_deps: + return "vue_spa" + if "@angular/core" in all_deps: + return "angular_spa" + return "nodejs" + except (OSError, json.JSONDecodeError): + return "nodejs" + + # Check for Python projects + pyproject = project_dir / "pyproject.toml" + requirements = project_dir / "requirements.txt" + if pyproject.exists() or requirements.exists(): + # Try to detect API framework + deps_text = "" + if requirements.exists(): + deps_text = requirements.read_text().lower() + if pyproject.exists(): + deps_text += pyproject.read_text().lower() + + if "fastapi" in deps_text or "flask" in deps_text or "django" in deps_text: + return "python_api" + if "click" in deps_text or "typer" in deps_text or "argparse" in deps_text: + return "python_cli" + return "python" + + # Check for other languages + if (project_dir / "Cargo.toml").exists(): + return "rust" + if (project_dir / "go.mod").exists(): + return "go" + if (project_dir / "Gemfile").exists(): + return "ruby" + + # Check for simple HTML/CSS + html_files = list(project_dir.glob("*.html")) + if html_files: + return "html_css" + + return "unknown" + + +# ============================================================================= +# VALIDATION STRATEGY BUILDER +# ============================================================================= + + +class ValidationStrategyBuilder: + """ + Builds validation strategies based on project type and risk level. + + The builder uses the risk assessment from complexity_assessment.json + and adapts the validation strategy to the detected project type. + """ + + def __init__(self) -> None: + """Initialize the strategy builder.""" + self._risk_classifier = RiskClassifier() + + def build_strategy( + self, + project_dir: Path, + spec_dir: Path, + risk_level: str | None = None, + ) -> ValidationStrategy: + """ + Build a validation strategy for the given project and spec. + + Args: + project_dir: Path to the project root + spec_dir: Path to the spec directory + risk_level: Override risk level (if not provided, reads from assessment) + + Returns: + ValidationStrategy with appropriate steps + """ + project_dir = Path(project_dir) + spec_dir = Path(spec_dir) + + # Get risk level from assessment if not provided + if risk_level is None: + assessment = self._risk_classifier.load_assessment(spec_dir) + if assessment: + risk_level = assessment.validation.risk_level + else: + risk_level = "medium" # Default to medium + + # Detect project type + project_type = detect_project_type(project_dir) + + # Build strategy based on project type + strategy_builders = { + "html_css": self._strategy_for_html_css, + "react_spa": self._strategy_for_spa, + "vue_spa": self._strategy_for_spa, + "angular_spa": self._strategy_for_spa, + "nextjs": self._strategy_for_fullstack, + "nodejs": self._strategy_for_nodejs, + "electron": self._strategy_for_electron, + "python_api": self._strategy_for_python_api, + "python_cli": self._strategy_for_cli, + "python": self._strategy_for_python, + "rust": self._strategy_for_rust, + "go": self._strategy_for_go, + "ruby": self._strategy_for_ruby, + } + + builder_func = strategy_builders.get(project_type, self._strategy_default) + strategy = builder_func(project_dir, risk_level) + + # Add security scanning for high+ risk + if risk_level in ["high", "critical"]: + strategy = self._add_security_steps(strategy, project_type) + + # Set common properties + strategy.risk_level = risk_level + strategy.project_type = project_type + strategy.skip_validation = risk_level == "trivial" + + return strategy + + def _strategy_for_html_css( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for simple HTML/CSS projects. + + Focus on visual verification and accessibility. + """ + steps = [ + ValidationStep( + name="Start HTTP Server", + command="python -m http.server 8000 &", + expected_outcome="Server running on port 8000", + step_type="setup", + required=True, + blocking=True, + ), + ValidationStep( + name="Visual Verification", + command="npx playwright screenshot http://localhost:8000 screenshot.png", + expected_outcome="Screenshot captured without errors", + step_type="visual", + required=True, + blocking=False, + ), + ValidationStep( + name="Console Error Check", + command="npx playwright test --grep 'console-errors'", + expected_outcome="No JavaScript console errors", + step_type="test", + required=True, + blocking=True, + ), + ] + + # Add Lighthouse for medium+ risk + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="Lighthouse Audit", + command="npx lighthouse http://localhost:8000 --output=json --output-path=lighthouse.json", + expected_outcome="Performance > 90, Accessibility > 90", + step_type="visual", + required=True, + blocking=risk_level in ["high", "critical"], + ) + ) + + return ValidationStrategy( + risk_level=risk_level, + project_type="html_css", + steps=steps, + test_types_required=["visual"] if risk_level != "trivial" else [], + reasoning="HTML/CSS project requires visual verification and accessibility checks.", + ) + + def _strategy_for_spa( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Single Page Applications (React, Vue, Angular). + + Focus on component tests and E2E testing. + """ + steps = [] + + # Unit/component tests for all non-trivial + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit/Component Tests", + command="npm test", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # E2E tests for medium+ risk + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="E2E Tests", + command="npx playwright test", + expected_outcome="All E2E tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # Browser console check + steps.append( + ValidationStep( + name="Console Error Check", + command="npm run dev & sleep 5 && npx playwright test --grep 'no-console-errors'", + expected_outcome="No console errors in browser", + step_type="test", + required=True, + blocking=risk_level in ["high", "critical"], + ) + ) + + # Determine test types + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + if risk_level in ["high", "critical"]: + test_types.append("e2e") + + return ValidationStrategy( + risk_level=risk_level, + project_type="spa", + steps=steps, + test_types_required=test_types, + reasoning="SPA requires component tests for logic and E2E for user flows.", + ) + + def _strategy_for_fullstack( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for fullstack frameworks (Next.js, Rails, Django). + + Focus on API tests, frontend tests, and integration. + """ + steps = [] + + # Unit tests + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="npm test", + expected_outcome="All unit tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # API tests for medium+ risk + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="API Integration Tests", + command="npm run test:api", + expected_outcome="All API tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # E2E tests for high+ risk + if risk_level in ["high", "critical"]: + steps.append( + ValidationStep( + name="E2E Tests", + command="npm run test:e2e", + expected_outcome="All E2E tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # Database migration check + steps.append( + ValidationStep( + name="Database Migration Check", + command="npm run db:migrate:status", + expected_outcome="All migrations applied successfully", + step_type="api", + required=risk_level in ["medium", "high", "critical"], + blocking=True, + ) + ) + + # Determine test types + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + if risk_level in ["high", "critical"]: + test_types.append("e2e") + + return ValidationStrategy( + risk_level=risk_level, + project_type="fullstack", + steps=steps, + test_types_required=test_types, + reasoning="Fullstack requires API tests, frontend tests, and DB migration checks.", + ) + + def _strategy_for_nodejs( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Node.js backend projects. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="npm test", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="Integration Tests", + command="npm run test:integration", + expected_outcome="All integration tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + + return ValidationStrategy( + risk_level=risk_level, + project_type="nodejs", + steps=steps, + test_types_required=test_types, + reasoning="Node.js backend requires unit and integration tests.", + ) + + def _strategy_for_python_api( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Python API projects (FastAPI, Flask, Django). + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="pytest tests/ -v", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="API Tests", + command="pytest tests/api/ -v", + expected_outcome="All API tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + steps.append( + ValidationStep( + name="Coverage Check", + command="pytest --cov=src --cov-report=term-missing", + expected_outcome="Coverage >= 80%", + step_type="test", + required=True, + blocking=risk_level == "critical", + ) + ) + + if risk_level in ["high", "critical"]: + steps.append( + ValidationStep( + name="Database Migration Check", + command="alembic current && alembic check", + expected_outcome="Migrations are current and valid", + step_type="api", + required=True, + blocking=True, + ) + ) + + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + if risk_level in ["high", "critical"]: + test_types.append("e2e") + + return ValidationStrategy( + risk_level=risk_level, + project_type="python_api", + steps=steps, + test_types_required=test_types, + reasoning="Python API requires pytest tests and migration checks.", + ) + + def _strategy_for_cli( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for CLI tools. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="pytest tests/ -v", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + steps.append( + ValidationStep( + name="CLI Help Check", + command="python -m module_name --help", + expected_outcome="Help text displays without errors", + step_type="test", + required=True, + blocking=True, + ) + ) + + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="CLI Output Verification", + command="python -m module_name --version", + expected_outcome="Version displays correctly", + step_type="test", + required=True, + blocking=False, + ) + ) + + return ValidationStrategy( + risk_level=risk_level, + project_type="python_cli", + steps=steps, + test_types_required=["unit"], + reasoning="CLI tools require output verification and unit tests.", + ) + + def _strategy_for_python( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for generic Python projects. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="pytest tests/ -v", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + + return ValidationStrategy( + risk_level=risk_level, + project_type="python", + steps=steps, + test_types_required=test_types, + reasoning="Python project requires pytest unit tests.", + ) + + def _strategy_for_rust( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Rust projects. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Cargo Test", + command="cargo test", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + steps.append( + ValidationStep( + name="Cargo Clippy", + command="cargo clippy -- -D warnings", + expected_outcome="No clippy warnings", + step_type="test", + required=True, + blocking=risk_level in ["high", "critical"], + ) + ) + + return ValidationStrategy( + risk_level=risk_level, + project_type="rust", + steps=steps, + test_types_required=["unit"], + reasoning="Rust project requires cargo test and clippy checks.", + ) + + def _strategy_for_go( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Go projects. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Go Test", + command="go test ./...", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + steps.append( + ValidationStep( + name="Go Vet", + command="go vet ./...", + expected_outcome="No issues found", + step_type="test", + required=True, + blocking=risk_level in ["high", "critical"], + ) + ) + + return ValidationStrategy( + risk_level=risk_level, + project_type="go", + steps=steps, + test_types_required=["unit"], + reasoning="Go project requires go test and vet checks.", + ) + + def _strategy_for_ruby( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Ruby projects. + """ + steps = [] + + if risk_level != "trivial": + steps.append( + ValidationStep( + name="RSpec Tests", + command="bundle exec rspec", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + return ValidationStrategy( + risk_level=risk_level, + project_type="ruby", + steps=steps, + test_types_required=["unit"], + reasoning="Ruby project requires RSpec tests.", + ) + + def _strategy_for_electron( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Validation strategy for Electron desktop applications. + + Focus on main/renderer process tests, E2E testing, and app packaging. + """ + steps = [] + + # Unit tests for all non-trivial + if risk_level != "trivial": + steps.append( + ValidationStep( + name="Unit Tests", + command="npm test", + expected_outcome="All tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # E2E tests for medium+ risk (Electron apps need GUI testing) + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="E2E Tests", + command="npm run test:e2e", + expected_outcome="All E2E tests pass", + step_type="test", + required=True, + blocking=True, + ) + ) + + # App build/package verification for medium+ risk + if risk_level in ["medium", "high", "critical"]: + steps.append( + ValidationStep( + name="Build Verification", + command="npm run build", + expected_outcome="App builds without errors", + step_type="test", + required=True, + blocking=True, + ) + ) + + # Console error check for high+ risk + if risk_level in ["high", "critical"]: + steps.append( + ValidationStep( + name="Console Error Check", + command="npm run test:console", + expected_outcome="No console errors in main or renderer process", + step_type="test", + required=True, + blocking=True, + ) + ) + + # Determine test types + test_types = ["unit"] + if risk_level in ["medium", "high", "critical"]: + test_types.append("integration") + test_types.append("e2e") + + return ValidationStrategy( + risk_level=risk_level, + project_type="electron", + steps=steps, + test_types_required=test_types, + reasoning="Electron app requires unit tests, E2E tests for GUI, and build verification.", + ) + + def _strategy_default( + self, project_dir: Path, risk_level: str + ) -> ValidationStrategy: + """ + Default validation strategy for unknown project types. + """ + steps = [ + ValidationStep( + name="Manual Verification", + command="manual", + expected_outcome="Code changes reviewed and tested manually", + step_type="manual", + required=True, + blocking=True, + ), + ] + + return ValidationStrategy( + risk_level=risk_level, + project_type="unknown", + steps=steps, + test_types_required=[], + reasoning="Unknown project type - manual verification required.", + ) + + def _add_security_steps( + self, strategy: ValidationStrategy, project_type: str + ) -> ValidationStrategy: + """ + Add security scanning steps to a strategy. + """ + security_steps = [] + + # Secrets scanning (always for high+ risk) + security_steps.append( + ValidationStep( + name="Secrets Scan", + command="python auto-claude/scan_secrets.py --all-files --json", + expected_outcome="No secrets detected", + step_type="security", + required=True, + blocking=True, + ) + ) + + # Language-specific SAST + if project_type in ["python", "python_api", "python_cli"]: + security_steps.append( + ValidationStep( + name="Bandit Security Scan", + command="bandit -r src/ -f json", + expected_outcome="No high severity issues", + step_type="security", + required=True, + blocking=True, + ) + ) + + if project_type in ["nodejs", "react_spa", "vue_spa", "nextjs"]: + security_steps.append( + ValidationStep( + name="npm audit", + command="npm audit --json", + expected_outcome="No critical vulnerabilities", + step_type="security", + required=True, + blocking=True, + ) + ) + + strategy.steps.extend(security_steps) + strategy.security_scan_required = True + + return strategy + + def to_dict(self, strategy: ValidationStrategy) -> dict[str, Any]: + """ + Convert a ValidationStrategy to a dictionary for JSON serialization. + """ + return { + "risk_level": strategy.risk_level, + "project_type": strategy.project_type, + "skip_validation": strategy.skip_validation, + "test_types_required": strategy.test_types_required, + "security_scan_required": strategy.security_scan_required, + "staging_deployment_required": strategy.staging_deployment_required, + "reasoning": strategy.reasoning, + "steps": [ + { + "name": step.name, + "command": step.command, + "expected_outcome": step.expected_outcome, + "type": step.step_type, + "required": step.required, + "blocking": step.blocking, + } + for step in strategy.steps + ], + } + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def build_validation_strategy( + project_dir: Path, + spec_dir: Path, + risk_level: str | None = None, +) -> ValidationStrategy: + """ + Convenience function to build a validation strategy. + + Args: + project_dir: Path to project root + spec_dir: Path to spec directory + risk_level: Optional override for risk level + + Returns: + ValidationStrategy object + """ + builder = ValidationStrategyBuilder() + return builder.build_strategy(project_dir, spec_dir, risk_level) + + +def get_strategy_as_dict( + project_dir: Path, + spec_dir: Path, + risk_level: str | None = None, +) -> dict[str, Any]: + """ + Get validation strategy as a dictionary. + + Args: + project_dir: Path to project root + spec_dir: Path to spec directory + risk_level: Optional override for risk level + + Returns: + Dictionary representation of strategy + """ + builder = ValidationStrategyBuilder() + strategy = builder.build_strategy(project_dir, spec_dir, risk_level) + return builder.to_dict(strategy) + + +# ============================================================================= +# CLI +# ============================================================================= + + +def main() -> None: + """CLI entry point for testing.""" + import argparse + + parser = argparse.ArgumentParser(description="Build validation strategy") + parser.add_argument("project_dir", type=Path, help="Path to project root") + parser.add_argument("--spec-dir", type=Path, help="Path to spec directory") + parser.add_argument("--risk-level", type=str, help="Override risk level") + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + spec_dir = args.spec_dir or args.project_dir + builder = ValidationStrategyBuilder() + strategy = builder.build_strategy(args.project_dir, spec_dir, args.risk_level) + + if args.json: + print(json.dumps(builder.to_dict(strategy), indent=2)) + else: + print(f"Project Type: {strategy.project_type}") + print(f"Risk Level: {strategy.risk_level}") + print(f"Skip Validation: {strategy.skip_validation}") + print(f"Test Types: {', '.join(strategy.test_types_required)}") + print(f"Security Scan: {strategy.security_scan_required}") + print(f"Reasoning: {strategy.reasoning}") + print(f"\nValidation Steps ({len(strategy.steps)}):") + for i, step in enumerate(strategy.steps, 1): + print(f" {i}. {step.name}") + print(f" Command: {step.command}") + print(f" Expected: {step.expected_outcome}") + + +if __name__ == "__main__": + main() diff --git a/auto-claude/task_logger.py.backup b/auto-claude/task_logger.py.backup deleted file mode 100644 index 1a827c35..00000000 --- a/auto-claude/task_logger.py.backup +++ /dev/null @@ -1,818 +0,0 @@ -""" -Task Logger -============ - -Persistent logging system for Auto Claude tasks. -Logs are organized by phase (planning, coding, validation) and stored in the spec directory. - -Key features: -- Phase-based log organization (collapsible in UI) -- Streaming markers for real-time UI updates (similar to insights_runner.py) -- Persistent storage in JSON format for easy frontend consumption -- Tool usage tracking with start/end markers -""" - -import json -import sys -from dataclasses import asdict, dataclass -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path - - -class LogPhase(str, Enum): - """Log phases matching the execution flow.""" - - PLANNING = "planning" - CODING = "coding" - VALIDATION = "validation" - - -class LogEntryType(str, Enum): - """Types of log entries.""" - - TEXT = "text" - TOOL_START = "tool_start" - TOOL_END = "tool_end" - PHASE_START = "phase_start" - PHASE_END = "phase_end" - ERROR = "error" - SUCCESS = "success" - INFO = "info" - - -@dataclass -class LogEntry: - """A single log entry.""" - - timestamp: str - type: str - content: str - phase: str - tool_name: str | None = None - tool_input: str | None = None - subtask_id: str | None = None - session: int | None = None - # New fields for expandable detail view - detail: str | None = ( - None # Full content that can be expanded (e.g., file contents, command output) - ) - subphase: str | None = ( - None # Subphase grouping (e.g., "PROJECT DISCOVERY", "CONTEXT GATHERING") - ) - collapsed: bool | None = None # Whether to show collapsed by default in UI - - def to_dict(self) -> dict: - """Convert to dictionary, excluding None values.""" - return {k: v for k, v in asdict(self).items() if v is not None} - - -@dataclass -class PhaseLog: - """Logs for a single phase.""" - - phase: str - status: str # "pending", "active", "completed", "failed" - started_at: str | None = None - completed_at: str | None = None - entries: list = None - - def __post_init__(self): - if self.entries is None: - self.entries = [] - - def to_dict(self) -> dict: - return { - "phase": self.phase, - "status": self.status, - "started_at": self.started_at, - "completed_at": self.completed_at, - "entries": self.entries, - } - - -class TaskLogger: - """ - Logger for a specific task/spec. - - Handles persistent storage of logs and emits streaming markers - for real-time UI updates. - - Usage: - logger = TaskLogger(spec_dir) - logger.start_phase(LogPhase.CODING) - logger.log("Starting implementation...") - logger.tool_start("Read", "/path/to/file.py") - logger.tool_end("Read") - logger.log("File read complete") - logger.end_phase(LogPhase.CODING, success=True) - """ - - LOG_FILE = "task_logs.json" - - def __init__(self, spec_dir: Path, emit_markers: bool = True): - """ - Initialize the task logger. - - Args: - spec_dir: Path to the spec directory - emit_markers: Whether to emit streaming markers to stdout - """ - self.spec_dir = Path(spec_dir) - self.log_file = self.spec_dir / self.LOG_FILE - self.emit_markers = emit_markers - self.current_phase: LogPhase | None = None - self.current_session: int | None = None - self.current_subtask: str | None = None - self._data: dict = self._load_or_create() - - def _load_or_create(self) -> dict: - """Load existing logs or create new structure.""" - if self.log_file.exists(): - try: - with open(self.log_file) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - pass - - return { - "spec_id": self.spec_dir.name, - "created_at": self._timestamp(), - "updated_at": self._timestamp(), - "phases": { - LogPhase.PLANNING.value: { - "phase": LogPhase.PLANNING.value, - "status": "pending", - "started_at": None, - "completed_at": None, - "entries": [], - }, - LogPhase.CODING.value: { - "phase": LogPhase.CODING.value, - "status": "pending", - "started_at": None, - "completed_at": None, - "entries": [], - }, - LogPhase.VALIDATION.value: { - "phase": LogPhase.VALIDATION.value, - "status": "pending", - "started_at": None, - "completed_at": None, - "entries": [], - }, - }, - } - - def _save(self): - """Save logs to file.""" - self._data["updated_at"] = self._timestamp() - try: - self.spec_dir.mkdir(parents=True, exist_ok=True) - with open(self.log_file, "w") as f: - json.dump(self._data, f, indent=2) - except OSError as e: - print(f"Warning: Failed to save task logs: {e}", file=sys.stderr) - - def _timestamp(self) -> str: - """Get current timestamp in ISO format.""" - return datetime.now(timezone.utc).isoformat() - - def _emit(self, marker_type: str, data: dict): - """Emit a streaming marker to stdout for UI consumption.""" - if not self.emit_markers: - return - try: - marker = f"__TASK_LOG_{marker_type.upper()}__:{json.dumps(data)}" - print(marker, flush=True) - except Exception: - pass # Don't let marker emission break logging - - def _add_entry(self, entry: LogEntry): - """Add an entry to the current phase.""" - phase_key = entry.phase - if phase_key not in self._data["phases"]: - # Create phase if it doesn't exist - self._data["phases"][phase_key] = { - "phase": phase_key, - "status": "active", - "started_at": self._timestamp(), - "completed_at": None, - "entries": [], - } - - self._data["phases"][phase_key]["entries"].append(entry.to_dict()) - self._save() - - def set_session(self, session: int): - """Set the current session number.""" - self.current_session = session - - def set_subtask(self, subtask_id: str | None): - """Set the current subtask being processed.""" - self.current_subtask = subtask_id - - def start_phase(self, phase: LogPhase, message: str | None = None): - """ - Start a new phase, auto-closing any stale active phases. - - This handles restart/recovery scenarios where a previous run was interrupted - before properly closing a phase. When starting a new phase, any other phases - that are still marked as "active" will be auto-closed. - - Args: - phase: The phase to start - message: Optional message to log at phase start - """ - self.current_phase = phase - phase_key = phase.value - - # Auto-close any other active phases (handles restart/recovery scenarios) - for other_phase_key, phase_data in self._data["phases"].items(): - if other_phase_key != phase_key and phase_data.get("status") == "active": - # Auto-close stale phase from previous interrupted run - phase_data["status"] = "completed" - phase_data["completed_at"] = self._timestamp() - # Add a log entry noting the auto-close - auto_close_entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.PHASE_END.value, - content=f"{other_phase_key} phase auto-closed on resume", - phase=other_phase_key, - session=self.current_session, - ) - self._data["phases"][other_phase_key]["entries"].append( - auto_close_entry.to_dict() - ) - - # Update phase status - if phase_key in self._data["phases"]: - self._data["phases"][phase_key]["status"] = "active" - self._data["phases"][phase_key]["started_at"] = self._timestamp() - - # Emit marker for UI - self._emit("PHASE_START", {"phase": phase_key, "timestamp": self._timestamp()}) - - # Add phase start entry - entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.PHASE_START.value, - content=message or f"Starting {phase_key} phase", - phase=phase_key, - session=self.current_session, - ) - self._add_entry(entry) - - # Also print the message - if message: - print(message, flush=True) - - def end_phase( - self, phase: LogPhase, success: bool = True, message: str | None = None - ): - """ - End a phase. - - Args: - phase: The phase to end - success: Whether the phase completed successfully - message: Optional message to log at phase end - """ - phase_key = phase.value - - # Update phase status - if phase_key in self._data["phases"]: - self._data["phases"][phase_key]["status"] = ( - "completed" if success else "failed" - ) - self._data["phases"][phase_key]["completed_at"] = self._timestamp() - - # Emit marker for UI - self._emit( - "PHASE_END", - {"phase": phase_key, "success": success, "timestamp": self._timestamp()}, - ) - - # Add phase end entry - entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.PHASE_END.value, - content=message - or f"{'Completed' if success else 'Failed'} {phase_key} phase", - phase=phase_key, - session=self.current_session, - ) - self._add_entry(entry) - - if message: - print(message, flush=True) - - if phase == self.current_phase: - self.current_phase = None - - self._save() - - def log( - self, - content: str, - entry_type: LogEntryType = LogEntryType.TEXT, - phase: LogPhase | None = None, - print_to_console: bool = True, - ): - """ - Log a message. - - Args: - content: The message to log - entry_type: Type of entry (text, error, success, info) - phase: Optional phase override (uses current_phase if not specified) - print_to_console: Whether to also print to stdout (default True) - """ - phase_key = (phase or self.current_phase or LogPhase.CODING).value - - entry = LogEntry( - timestamp=self._timestamp(), - type=entry_type.value, - content=content, - phase=phase_key, - subtask_id=self.current_subtask, - session=self.current_session, - ) - self._add_entry(entry) - - # Emit streaming marker - self._emit( - "TEXT", - { - "content": content, - "phase": phase_key, - "type": entry_type.value, - "subtask_id": self.current_subtask, - "timestamp": self._timestamp(), - }, - ) - - # Also print to console (unless caller handles printing) - if print_to_console: - print(content, flush=True) - - def log_error(self, content: str, phase: LogPhase | None = None): - """Log an error message.""" - self.log(content, LogEntryType.ERROR, phase) - - def log_success(self, content: str, phase: LogPhase | None = None): - """Log a success message.""" - self.log(content, LogEntryType.SUCCESS, phase) - - def log_info(self, content: str, phase: LogPhase | None = None): - """Log an info message.""" - self.log(content, LogEntryType.INFO, phase) - - def log_with_detail( - self, - content: str, - detail: str, - entry_type: LogEntryType = LogEntryType.TEXT, - phase: LogPhase | None = None, - subphase: str | None = None, - collapsed: bool = True, - print_to_console: bool = True, - ): - """ - Log a message with expandable detail content. - - Args: - content: Brief summary shown by default - detail: Full content shown when expanded (e.g., file contents, command output) - entry_type: Type of entry (text, error, success, info) - phase: Optional phase override - subphase: Optional subphase grouping (e.g., "PROJECT DISCOVERY") - collapsed: Whether detail should be collapsed by default (default True) - print_to_console: Whether to print summary to stdout (default True) - """ - phase_key = (phase or self.current_phase or LogPhase.CODING).value - - entry = LogEntry( - timestamp=self._timestamp(), - type=entry_type.value, - content=content, - phase=phase_key, - subtask_id=self.current_subtask, - session=self.current_session, - detail=detail, - subphase=subphase, - collapsed=collapsed, - ) - self._add_entry(entry) - - # Emit streaming marker with detail indicator - self._emit( - "TEXT", - { - "content": content, - "phase": phase_key, - "type": entry_type.value, - "subtask_id": self.current_subtask, - "timestamp": self._timestamp(), - "has_detail": True, - "subphase": subphase, - }, - ) - - if print_to_console: - print(content, flush=True) - - def start_subphase( - self, - subphase: str, - phase: LogPhase | None = None, - print_to_console: bool = True, - ): - """ - Mark the start of a subphase within the current phase. - - Args: - subphase: Name of the subphase (e.g., "PROJECT DISCOVERY", "CONTEXT GATHERING") - phase: Optional phase override - print_to_console: Whether to print to stdout - """ - phase_key = (phase or self.current_phase or LogPhase.CODING).value - - entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.INFO.value, - content=f"Starting {subphase}", - phase=phase_key, - subtask_id=self.current_subtask, - session=self.current_session, - subphase=subphase, - ) - self._add_entry(entry) - - # Emit streaming marker - self._emit( - "SUBPHASE_START", - {"subphase": subphase, "phase": phase_key, "timestamp": self._timestamp()}, - ) - - if print_to_console: - print(f"\n--- {subphase} ---", flush=True) - - def tool_start( - self, - tool_name: str, - tool_input: str | None = None, - phase: LogPhase | None = None, - print_to_console: bool = True, - ): - """ - Log the start of a tool execution. - - Args: - tool_name: Name of the tool (e.g., "Read", "Write", "Bash") - tool_input: Brief description of tool input - phase: Optional phase override - print_to_console: Whether to also print to stdout (default True) - """ - phase_key = (phase or self.current_phase or LogPhase.CODING).value - - # Truncate long inputs for display - display_input = tool_input - if display_input and len(display_input) > 100: - display_input = display_input[:97] + "..." - - entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.TOOL_START.value, - content=f"[{tool_name}] {display_input or ''}".strip(), - phase=phase_key, - tool_name=tool_name, - tool_input=display_input, - subtask_id=self.current_subtask, - session=self.current_session, - ) - self._add_entry(entry) - - # Emit streaming marker (same format as insights_runner.py) - self._emit( - "TOOL_START", - {"name": tool_name, "input": display_input, "phase": phase_key}, - ) - - if print_to_console: - print(f"\n[Tool: {tool_name}]", flush=True) - - def tool_end( - self, - tool_name: str, - success: bool = True, - result: str | None = None, - detail: str | None = None, - phase: LogPhase | None = None, - print_to_console: bool = False, - ): - """ - Log the end of a tool execution. - - Args: - tool_name: Name of the tool - success: Whether the tool succeeded - result: Optional brief result description (shown in summary) - detail: Optional full result content (expandable in UI, e.g., file contents, command output) - phase: Optional phase override - print_to_console: Whether to also print to stdout (default False for tool_end) - """ - phase_key = (phase or self.current_phase or LogPhase.CODING).value - - # Truncate long results for display - display_result = result - if display_result and len(display_result) > 100: - display_result = display_result[:97] + "..." - - status = "Done" if success else "Error" - content = f"[{tool_name}] {status}" - if display_result: - content += f": {display_result}" - - # Truncate detail for storage (max 10KB to avoid bloating JSON) - stored_detail = detail - if stored_detail and len(stored_detail) > 10240: - stored_detail = ( - stored_detail[:10240] - + f"\n\n... [truncated - full output was {len(detail)} chars]" - ) - - entry = LogEntry( - timestamp=self._timestamp(), - type=LogEntryType.TOOL_END.value, - content=content, - phase=phase_key, - tool_name=tool_name, - subtask_id=self.current_subtask, - session=self.current_session, - detail=stored_detail, - collapsed=True, - ) - self._add_entry(entry) - - # Emit streaming marker - self._emit( - "TOOL_END", - { - "name": tool_name, - "success": success, - "phase": phase_key, - "has_detail": detail is not None, - }, - ) - - if print_to_console: - if result: - print(f" [{status}] {display_result}", flush=True) - else: - print(f" [{status}]", flush=True) - - def get_logs(self) -> dict: - """Get all logs.""" - return self._data - - def get_phase_logs(self, phase: LogPhase) -> dict: - """Get logs for a specific phase.""" - return self._data["phases"].get(phase.value, {}) - - def clear(self): - """Clear all logs (useful for testing).""" - self._data = self._load_or_create() - self._save() - - -def load_task_logs(spec_dir: Path) -> dict | None: - """ - Load task logs from a spec directory. - - Args: - spec_dir: Path to the spec directory - - Returns: - Logs dictionary or None if not found - """ - log_file = spec_dir / TaskLogger.LOG_FILE - if not log_file.exists(): - return None - - try: - with open(log_file) as f: - return json.load(f) - except (OSError, json.JSONDecodeError): - return None - - -def get_active_phase(spec_dir: Path) -> str | None: - """ - Get the currently active phase for a spec. - - Args: - spec_dir: Path to the spec directory - - Returns: - Phase name or None if no active phase - """ - logs = load_task_logs(spec_dir) - if not logs: - return None - - for phase_name, phase_data in logs.get("phases", {}).items(): - if phase_data.get("status") == "active": - return phase_name - - return None - - -# Global logger instance for easy access -_current_logger: TaskLogger | None = None - - -def get_task_logger( - spec_dir: Path | None = None, emit_markers: bool = True -) -> TaskLogger | None: - """ - Get or create a task logger for the given spec directory. - - Args: - spec_dir: Path to the spec directory (creates new logger if different from current) - emit_markers: Whether to emit streaming markers - - Returns: - TaskLogger instance or None if no spec_dir - """ - global _current_logger - - if spec_dir is None: - return _current_logger - - if _current_logger is None or _current_logger.spec_dir != spec_dir: - _current_logger = TaskLogger(spec_dir, emit_markers) - - return _current_logger - - -def clear_task_logger(): - """Clear the global task logger.""" - global _current_logger - _current_logger = None - - -def update_task_logger_path(new_spec_dir: Path): - """ - Update the global task logger's spec directory after a rename. - - This should be called after renaming a spec directory to ensure - the logger continues writing to the correct location. - - Args: - new_spec_dir: The new path to the spec directory - """ - global _current_logger - - if _current_logger is None: - return - - # Update the logger's internal paths - _current_logger.spec_dir = Path(new_spec_dir) - _current_logger.log_file = _current_logger.spec_dir / TaskLogger.LOG_FILE - - # Update spec_id in the data - _current_logger._data["spec_id"] = new_spec_dir.name - - # Save to the new location - _current_logger._save() - - -class StreamingLogCapture: - """ - Context manager to capture streaming output and log it. - - Usage: - with StreamingLogCapture(logger, phase) as capture: - # Run agent session - async for msg in client.receive_response(): - capture.process_message(msg) - """ - - def __init__(self, logger: TaskLogger, phase: LogPhase | None = None): - self.logger = logger - self.phase = phase - self.current_tool: str | None = None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - # End any active tool - if self.current_tool: - self.logger.tool_end( - self.current_tool, success=exc_type is None, phase=self.phase - ) - self.current_tool = None - return False - - def process_text(self, text: str): - """Process text output from the agent.""" - if text.strip(): - self.logger.log(text, phase=self.phase) - - def process_tool_start(self, tool_name: str, tool_input: str | None = None): - """Process tool start.""" - # End previous tool if any - if self.current_tool: - self.logger.tool_end(self.current_tool, success=True, phase=self.phase) - - self.current_tool = tool_name - self.logger.tool_start(tool_name, tool_input, phase=self.phase) - - def process_tool_end( - self, - tool_name: str, - success: bool = True, - result: str | None = None, - detail: str | None = None, - ): - """Process tool end.""" - self.logger.tool_end( - tool_name, success, result, detail=detail, phase=self.phase - ) - if self.current_tool == tool_name: - self.current_tool = None - - def process_message(self, msg, verbose: bool = False, capture_detail: bool = True): - """ - Process a message from the Claude SDK stream. - - Args: - msg: Message from client.receive_response() - verbose: Whether to show detailed tool results - capture_detail: Whether to capture full tool output for expandable detail view - """ - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - # Text is already logged by the agent session - pass - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - tool_input = None - if hasattr(block, "input") and block.input: - inp = block.input - if isinstance(inp, dict): - # Extract meaningful input description - if "pattern" in inp: - tool_input = f"pattern: {inp['pattern']}" - elif "file_path" in inp: - fp = inp["file_path"] - if len(fp) > 50: - fp = "..." + fp[-47:] - tool_input = fp - elif "command" in inp: - cmd = inp["command"] - if len(cmd) > 50: - cmd = cmd[:47] + "..." - tool_input = cmd - elif "path" in inp: - tool_input = inp["path"] - self.process_tool_start(block.name, tool_input) - - elif msg_type == "UserMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "ToolResultBlock": - is_error = getattr(block, "is_error", False) - result_content = getattr(block, "content", "") - - if self.current_tool: - result_str = None - if verbose and result_content: - result_str = str(result_content)[:100] - - # Capture full detail for expandable view - detail_content = None - if capture_detail and self.current_tool in ( - "Read", - "Grep", - "Bash", - "Edit", - "Write", - ): - full_result = str(result_content) - if len(full_result) < 50000: # 50KB max - detail_content = full_result - - self.process_tool_end( - self.current_tool, - success=not is_error, - result=result_str, - detail=detail_content, - ) diff --git a/auto-claude/task_logger.py b/auto-claude/task_logger/main.py similarity index 100% rename from auto-claude/task_logger.py rename to auto-claude/task_logger/main.py diff --git a/auto-claude/test_discovery.py b/auto-claude/test_discovery.py index ffed6307..e4a45a7a 100644 --- a/auto-claude/test_discovery.py +++ b/auto-claude/test_discovery.py @@ -1,682 +1,20 @@ -#!/usr/bin/env python3 -""" -Test Discovery Module -===================== - -Detects test frameworks, test commands, and test directories in a project. -This module analyzes project configuration files to discover how tests -should be run. - -The test discovery results are used by: -- QA Agent: To determine what test commands to run -- Test Creator: To know what framework to use when creating tests -- Planner: To include correct test commands in verification strategy - -Usage: - from test_discovery import TestDiscovery - - discovery = TestDiscovery() - result = discovery.discover(project_dir) - - print(f"Test frameworks: {result['frameworks']}") - print(f"Test command: {result['test_command']}") -""" - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class TestFramework: - """ - Represents a detected test framework. - - Attributes: - name: Name of the framework (e.g., "pytest", "jest", "vitest") - type: Type of testing (unit, integration, e2e, all) - command: Command to run tests - config_file: Configuration file if found - version: Version if detected - coverage_command: Command for coverage if available - """ - - name: str - type: str # unit, integration, e2e, all - command: str - config_file: str | None = None - version: str | None = None - coverage_command: str | None = None - - -@dataclass -class TestDiscoveryResult: - """ - Result of test framework discovery. - - Attributes: - frameworks: List of detected test frameworks - test_command: Primary test command to run - test_directories: Discovered test directories - package_manager: Detected package manager - has_tests: Whether any test files were found - coverage_command: Command for coverage if available - """ - - frameworks: list[TestFramework] = field(default_factory=list) - test_command: str = "" - test_directories: list[str] = field(default_factory=list) - package_manager: str = "" - has_tests: bool = False - coverage_command: str | None = None - - -# ============================================================================= -# FRAMEWORK DETECTORS -# ============================================================================= - - -# Pattern-based framework detection -FRAMEWORK_PATTERNS = { - # JavaScript/TypeScript - "jest": { - "config_files": [ - "jest.config.js", - "jest.config.ts", - "jest.config.mjs", - "jest.config.cjs", - ], - "package_key": "jest", - "type": "unit", - "command": "npx jest", - "coverage_command": "npx jest --coverage", - }, - "vitest": { - "config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"], - "package_key": "vitest", - "type": "unit", - "command": "npx vitest run", - "coverage_command": "npx vitest run --coverage", - }, - "mocha": { - "config_files": [ - ".mocharc.js", - ".mocharc.json", - ".mocharc.yaml", - ".mocharc.yml", - ], - "package_key": "mocha", - "type": "unit", - "command": "npx mocha", - "coverage_command": "npx nyc mocha", - }, - "playwright": { - "config_files": ["playwright.config.js", "playwright.config.ts"], - "package_key": "@playwright/test", - "type": "e2e", - "command": "npx playwright test", - "coverage_command": None, - }, - "cypress": { - "config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"], - "package_key": "cypress", - "type": "e2e", - "command": "npx cypress run", - "coverage_command": None, - }, - # Python - "pytest": { - "config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"], - "pyproject_key": "pytest", - "requirements_key": "pytest", - "type": "all", - "command": "pytest", - "coverage_command": "pytest --cov", - }, - "unittest": { - "config_files": [], - "type": "unit", - "command": "python -m unittest discover", - "coverage_command": "coverage run -m unittest discover", - }, - # Rust - "cargo_test": { - "config_files": ["Cargo.toml"], - "type": "all", - "command": "cargo test", - "coverage_command": "cargo tarpaulin", - }, - # Go - "go_test": { - "config_files": ["go.mod"], - "type": "all", - "command": "go test ./...", - "coverage_command": "go test -cover ./...", - }, - # Ruby - "rspec": { - "config_files": [".rspec", "spec/spec_helper.rb"], - "gemfile_key": "rspec", - "type": "all", - "command": "bundle exec rspec", - "coverage_command": "bundle exec rspec --format documentation", - }, - "minitest": { - "config_files": [], - "gemfile_key": "minitest", - "type": "unit", - "command": "bundle exec rake test", - "coverage_command": None, - }, -} - - -# ============================================================================= -# TEST DISCOVERY -# ============================================================================= - - -class TestDiscovery: - """ - Discovers test frameworks and configurations in a project. - - Analyzes: - - Package files (package.json, pyproject.toml, Cargo.toml, etc.) - - Configuration files (jest.config.js, pytest.ini, etc.) - - Directory structure (tests/, spec/, __tests__/) - """ - - def __init__(self) -> None: - """Initialize the test discovery.""" - self._cache: dict[str, TestDiscoveryResult] = {} - - def discover(self, project_dir: Path) -> TestDiscoveryResult: - """ - Discover test frameworks and configuration in the project. - - Args: - project_dir: Path to the project root - - Returns: - TestDiscoveryResult with detected frameworks and commands - """ - project_dir = Path(project_dir) - cache_key = str(project_dir.resolve()) - - if cache_key in self._cache: - return self._cache[cache_key] - - result = TestDiscoveryResult() - - # Detect package manager - result.package_manager = self._detect_package_manager(project_dir) - - # Discover frameworks based on project type - if (project_dir / "package.json").exists(): - self._discover_js_frameworks(project_dir, result) - - # Check for Python project indicators - python_indicators = [ - project_dir / "pyproject.toml", - project_dir / "requirements.txt", - project_dir / "setup.py", - project_dir / "pytest.ini", - project_dir / "conftest.py", - project_dir / "tests" / "conftest.py", - ] - if any(p.exists() for p in python_indicators): - self._discover_python_frameworks(project_dir, result) - - if (project_dir / "Cargo.toml").exists(): - self._discover_rust_frameworks(project_dir, result) - if (project_dir / "go.mod").exists(): - self._discover_go_frameworks(project_dir, result) - if (project_dir / "Gemfile").exists(): - self._discover_ruby_frameworks(project_dir, result) - - # Find test directories - result.test_directories = self._find_test_directories(project_dir) - - # Check if tests exist - result.has_tests = self._has_test_files(project_dir, result.test_directories) - - # Set primary test command - if result.frameworks: - result.test_command = result.frameworks[0].command - - # Set coverage command from first framework that has one - if not result.coverage_command: - for framework in result.frameworks: - if framework.coverage_command: - result.coverage_command = framework.coverage_command - break - - self._cache[cache_key] = result - return result - - def _detect_package_manager(self, project_dir: Path) -> str: - """Detect the package manager used by the project.""" - if (project_dir / "pnpm-lock.yaml").exists(): - return "pnpm" - if (project_dir / "yarn.lock").exists(): - return "yarn" - if (project_dir / "package-lock.json").exists(): - return "npm" - if (project_dir / "bun.lockb").exists(): - return "bun" - if (project_dir / "uv.lock").exists(): - return "uv" - if (project_dir / "poetry.lock").exists(): - return "poetry" - if (project_dir / "Pipfile.lock").exists(): - return "pipenv" - if (project_dir / "Cargo.lock").exists(): - return "cargo" - if (project_dir / "go.sum").exists(): - return "go" - if (project_dir / "Gemfile.lock").exists(): - return "bundler" - return "" - - def _discover_js_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover JavaScript/TypeScript test frameworks.""" - package_json = project_dir / "package.json" - if not package_json.exists(): - return - - try: - with open(package_json, encoding="utf-8") as f: - pkg = json.load(f) - except (OSError, json.JSONDecodeError): - return - - deps = pkg.get("dependencies", {}) - dev_deps = pkg.get("devDependencies", {}) - all_deps = {**deps, **dev_deps} - scripts = pkg.get("scripts", {}) - - # Check for test frameworks in dependencies - for name, pattern in FRAMEWORK_PATTERNS.items(): - if "package_key" not in pattern: - continue - - if pattern["package_key"] in all_deps: - # Check for config file - config_file = None - for cf in pattern.get("config_files", []): - if (project_dir / cf).exists(): - config_file = cf - break - - # Get version - version = all_deps.get(pattern["package_key"], "") - if version.startswith("^") or version.startswith("~"): - version = version[1:] - - # Determine command - prefer npm scripts if available - command = pattern["command"] - if "test" in scripts and pattern["package_key"] in scripts.get( - "test", "" - ): - command = f"{result.package_manager or 'npm'} test" - - result.frameworks.append( - TestFramework( - name=name, - type=pattern["type"], - command=command, - config_file=config_file, - version=version, - coverage_command=pattern.get("coverage_command"), - ) - ) - - # Check npm scripts for test commands - if not result.frameworks and "test" in scripts: - test_script = scripts["test"] - if ( - test_script - and test_script != 'echo "Error: no test specified" && exit 1' - ): - # Try to infer framework from script - framework_name = "npm_test" - framework_type = "unit" - - if "jest" in test_script: - framework_name = "jest" - elif "vitest" in test_script: - framework_name = "vitest" - elif "mocha" in test_script: - framework_name = "mocha" - elif "playwright" in test_script: - framework_name = "playwright" - framework_type = "e2e" - elif "cypress" in test_script: - framework_name = "cypress" - framework_type = "e2e" - - result.frameworks.append( - TestFramework( - name=framework_name, - type=framework_type, - command=f"{result.package_manager or 'npm'} test", - config_file=None, - ) - ) - - def _discover_python_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Python test frameworks.""" - # Check for pytest.ini first (explicit pytest config) - if (project_dir / "pytest.ini").exists(): - if not any(f.name == "pytest" for f in result.frameworks): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file="pytest.ini", - ) - ) - - # Check pyproject.toml - pyproject = project_dir / "pyproject.toml" - if pyproject.exists(): - content = pyproject.read_text() - - # Check for pytest - if "pytest" in content: - if not any(f.name == "pytest" for f in result.frameworks): - config_file = ( - "pyproject.toml" if "[tool.pytest" in content else None - ) - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file=config_file, - ) - ) - - # Check requirements.txt - requirements = project_dir / "requirements.txt" - if requirements.exists(): - content = requirements.read_text().lower() - if "pytest" in content and not any( - f.name == "pytest" for f in result.frameworks - ): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file=None, - ) - ) - - # Check for conftest.py (pytest marker) - conftest_root = project_dir / "conftest.py" - conftest_tests = project_dir / "tests" / "conftest.py" - if conftest_root.exists() or conftest_tests.exists(): - if not any(f.name == "pytest" for f in result.frameworks): - result.frameworks.append( - TestFramework( - name="pytest", - type="all", - command="pytest", - config_file="conftest.py", - ) - ) - - # Fall back to unittest if test files exist but no framework detected - if not result.frameworks: - test_dirs = self._find_test_directories(project_dir) - if test_dirs: - result.frameworks.append( - TestFramework( - name="unittest", - type="unit", - command="python -m unittest discover", - config_file=None, - ) - ) - - def _discover_rust_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Rust test frameworks.""" - cargo_toml = project_dir / "Cargo.toml" - if cargo_toml.exists(): - result.frameworks.append( - TestFramework( - name="cargo_test", - type="all", - command="cargo test", - config_file="Cargo.toml", - ) - ) - - def _discover_go_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Go test frameworks.""" - go_mod = project_dir / "go.mod" - if go_mod.exists(): - result.frameworks.append( - TestFramework( - name="go_test", - type="all", - command="go test ./...", - config_file="go.mod", - ) - ) - - def _discover_ruby_frameworks( - self, project_dir: Path, result: TestDiscoveryResult - ) -> None: - """Discover Ruby test frameworks.""" - gemfile = project_dir / "Gemfile" - if not gemfile.exists(): - return - - content = gemfile.read_text().lower() - - if "rspec" in content or (project_dir / ".rspec").exists(): - result.frameworks.append( - TestFramework( - name="rspec", - type="all", - command="bundle exec rspec", - config_file=".rspec" if (project_dir / ".rspec").exists() else None, - ) - ) - elif "minitest" in content: - result.frameworks.append( - TestFramework( - name="minitest", - type="unit", - command="bundle exec rake test", - config_file=None, - ) - ) - - def _find_test_directories(self, project_dir: Path) -> list[str]: - """Find test directories in the project.""" - test_dir_patterns = [ - "tests", - "test", - "spec", - "__tests__", - "specs", - "test_*", - ] - - found_dirs = [] - for pattern in test_dir_patterns: - if pattern.endswith("*"): - # Glob pattern - for d in project_dir.glob(pattern): - if d.is_dir(): - found_dirs.append(str(d.relative_to(project_dir))) - else: - # Exact name - test_dir = project_dir / pattern - if test_dir.is_dir(): - found_dirs.append(pattern) - - return found_dirs - - def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool: - """Check if any test files exist.""" - test_file_patterns = [ - "**/test_*.py", - "**/*_test.py", - "**/*.test.js", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.spec.js", - "**/*.spec.ts", - "**/*.spec.tsx", - "**/test_*.go", - "**/*_test.go", - "**/*_test.rs", - "**/spec/**/*_spec.rb", - ] - - # Check in test directories - for test_dir in test_directories: - test_path = project_dir / test_dir - if test_path.exists(): - for pattern in test_file_patterns: - if list(test_path.glob(pattern.replace("**/", ""))): - return True - - # Check project-wide - for pattern in test_file_patterns: - if list(project_dir.glob(pattern)): - return True - - return False - - def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]: - """Convert result to dictionary for JSON serialization.""" - return { - "frameworks": [ - { - "name": f.name, - "type": f.type, - "command": f.command, - "config_file": f.config_file, - "version": f.version, - "coverage_command": f.coverage_command, - } - for f in result.frameworks - ], - "test_command": result.test_command, - "test_directories": result.test_directories, - "package_manager": result.package_manager, - "has_tests": result.has_tests, - "coverage_command": result.coverage_command, - } - - def clear_cache(self) -> None: - """Clear the internal cache.""" - self._cache.clear() - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def discover_tests(project_dir: Path) -> TestDiscoveryResult: - """ - Convenience function to discover tests in a project. - - Args: - project_dir: Path to project root - - Returns: - TestDiscoveryResult with detected frameworks - """ - discovery = TestDiscovery() - return discovery.discover(project_dir) - - -def get_test_command(project_dir: Path) -> str: - """ - Get the primary test command for a project. - - Args: - project_dir: Path to project root - - Returns: - Test command string, or empty string if not found - """ - discovery = TestDiscovery() - result = discovery.discover(project_dir) - return result.test_command - - -def get_test_frameworks(project_dir: Path) -> list[str]: - """ - Get list of test framework names in a project. - - Args: - project_dir: Path to project root - - Returns: - List of framework names - """ - discovery = TestDiscovery() - result = discovery.discover(project_dir) - return [f.name for f in result.frameworks] - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Discover test frameworks") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - discovery = TestDiscovery() - result = discovery.discover(args.project_dir) - - if args.json: - print(json.dumps(discovery.to_dict(result), indent=2)) - else: - print(f"Package Manager: {result.package_manager or 'unknown'}") - print(f"Has Tests: {result.has_tests}") - print(f"Test Command: {result.test_command or 'none'}") - print(f"Test Directories: {', '.join(result.test_directories) or 'none'}") - print(f"Coverage Command: {result.coverage_command or 'none'}") - print(f"\nFrameworks ({len(result.frameworks)}):") - for f in result.frameworks: - print(f" - {f.name} ({f.type})") - print(f" Command: {f.command}") - if f.config_file: - print(f" Config: {f.config_file}") - if f.version: - print(f" Version: {f.version}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from analysis.test_discovery instead.""" +from analysis.test_discovery import ( + TestFramework, + TestDiscoveryResult, + TestDiscovery, + discover_tests, + get_test_command, + get_test_frameworks, + FRAMEWORK_PATTERNS, +) + +__all__ = [ + "TestFramework", + "TestDiscoveryResult", + "TestDiscovery", + "discover_tests", + "get_test_command", + "get_test_frameworks", + "FRAMEWORK_PATTERNS", +] diff --git a/auto-claude/ui.py b/auto-claude/ui/main.py similarity index 100% rename from auto-claude/ui.py rename to auto-claude/ui/main.py diff --git a/auto-claude/statusline.py b/auto-claude/ui/statusline.py similarity index 100% rename from auto-claude/statusline.py rename to auto-claude/ui/statusline.py diff --git a/auto-claude/validate_spec.py.backup b/auto-claude/validate_spec.py.backup deleted file mode 100644 index 3f56b167..00000000 --- a/auto-claude/validate_spec.py.backup +++ /dev/null @@ -1,633 +0,0 @@ -#!/usr/bin/env python3 -""" -Spec Validation System -====================== - -Validates spec outputs at each checkpoint to ensure reliability. -This is the enforcement layer that catches errors before they propagate. - -The spec creation process has mandatory checkpoints: -1. Prerequisites (project_index.json exists) -2. Context (context.json created with required fields) -3. Spec document (spec.md with required sections) -4. Implementation plan (implementation_plan.json with valid schema) - -Usage: - python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature/ --checkpoint prereqs - python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature/ --checkpoint context - python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature/ --checkpoint spec - python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature/ --checkpoint plan - python auto-claude/validate_spec.py --spec-dir auto-claude/specs/001-feature/ --checkpoint all -""" - -import json -import re -import sys -from dataclasses import dataclass -from pathlib import Path - -# JSON Schemas for validation -IMPLEMENTATION_PLAN_SCHEMA = { - "required_fields": ["feature", "workflow_type", "phases"], - "optional_fields": [ - "services_involved", - "final_acceptance", - "created_at", - "updated_at", - "spec_file", - "qa_acceptance", - "qa_signoff", - "summary", - "description", - "workflow_rationale", - "status", - ], - "workflow_types": ["feature", "refactor", "investigation", "migration", "simple"], - "phase_schema": { - # Support both old format ("phase" number) and new format ("id" string) - "required_fields_either": [["phase", "id"]], # At least one of these - "required_fields": ["name", "subtasks"], - "optional_fields": [ - "type", - "depends_on", - "parallel_safe", - "description", - "phase", - "id", - ], - "phase_types": [ - "setup", - "implementation", - "investigation", - "integration", - "cleanup", - ], - }, - "subtask_schema": { - "required_fields": ["id", "description", "status"], - "optional_fields": [ - "service", - "all_services", - "files_to_modify", - "files_to_create", - "patterns_from", - "verification", - "expected_output", - "actual_output", - "started_at", - "completed_at", - "session_id", - "critique_result", - ], - "status_values": ["pending", "in_progress", "completed", "blocked", "failed"], - }, - "verification_schema": { - "required_fields": ["type"], - "optional_fields": [ - "run", - "url", - "method", - "expect_status", - "expect_contains", - "scenario", - "steps", - ], - "verification_types": [ - "command", - "api", - "browser", - "component", - "manual", - "none", - "e2e", - ], - }, -} - -CONTEXT_SCHEMA = { - "required_fields": ["task_description"], - "optional_fields": [ - "scoped_services", - "files_to_modify", - "files_to_reference", - "patterns", - "service_contexts", - "created_at", - ], -} - -PROJECT_INDEX_SCHEMA = { - "required_fields": ["project_type"], - "optional_fields": [ - "services", - "infrastructure", - "conventions", - "root_path", - "created_at", - "git_info", - ], - "project_types": ["single", "monorepo"], -} - -SPEC_REQUIRED_SECTIONS = [ - "Overview", - "Workflow Type", - "Task Scope", - "Success Criteria", -] - -SPEC_RECOMMENDED_SECTIONS = [ - "Files to Modify", - "Files to Reference", - "Requirements", - "QA Acceptance Criteria", -] - - -@dataclass -class ValidationResult: - """Result of a validation check.""" - - valid: bool - checkpoint: str - errors: list[str] - warnings: list[str] - fixes: list[str] # Suggested fixes - - def __str__(self) -> str: - lines = [f"Checkpoint: {self.checkpoint}"] - lines.append(f"Status: {'PASS' if self.valid else 'FAIL'}") - - if self.errors: - lines.append("\nErrors:") - for err in self.errors: - lines.append(f" ✗ {err}") - - if self.warnings: - lines.append("\nWarnings:") - for warn in self.warnings: - lines.append(f" ⚠ {warn}") - - if self.fixes and not self.valid: - lines.append("\nSuggested Fixes:") - for fix in self.fixes: - lines.append(f" → {fix}") - - return "\n".join(lines) - - -class SpecValidator: - """Validates spec outputs at each checkpoint.""" - - def __init__(self, spec_dir: Path): - self.spec_dir = Path(spec_dir) - - def validate_all(self) -> list[ValidationResult]: - """Run all validations.""" - results = [ - self.validate_prereqs(), - self.validate_context(), - self.validate_spec_document(), - self.validate_implementation_plan(), - ] - return results - - def validate_prereqs(self) -> ValidationResult: - """Validate prerequisites exist.""" - errors = [] - warnings = [] - fixes = [] - - # Check spec directory exists - if not self.spec_dir.exists(): - errors.append(f"Spec directory does not exist: {self.spec_dir}") - fixes.append(f"Create directory: mkdir -p {self.spec_dir}") - return ValidationResult(False, "prereqs", errors, warnings, fixes) - - # Check project_index.json - project_index = self.spec_dir / "project_index.json" - if not project_index.exists(): - # Check if it exists at auto-claude level - auto_build_index = self.spec_dir.parent.parent / "project_index.json" - if auto_build_index.exists(): - warnings.append( - "project_index.json exists at auto-claude/ but not in spec folder" - ) - fixes.append(f"Copy: cp {auto_build_index} {project_index}") - else: - errors.append("project_index.json not found") - fixes.append( - "Run: python auto-claude/analyzer.py --output auto-claude/project_index.json" - ) - - return ValidationResult( - valid=len(errors) == 0, - checkpoint="prereqs", - errors=errors, - warnings=warnings, - fixes=fixes, - ) - - def validate_context(self) -> ValidationResult: - """Validate context.json exists and has required structure.""" - errors = [] - warnings = [] - fixes = [] - - context_file = self.spec_dir / "context.json" - - if not context_file.exists(): - errors.append("context.json not found") - fixes.append( - "Run: python auto-claude/context.py --task '[task]' --services '[services]' --output context.json" - ) - return ValidationResult(False, "context", errors, warnings, fixes) - - try: - with open(context_file) as f: - context = json.load(f) - except json.JSONDecodeError as e: - errors.append(f"context.json is invalid JSON: {e}") - fixes.append("Regenerate context.json or fix JSON syntax") - return ValidationResult(False, "context", errors, warnings, fixes) - - # Check required fields - for field in CONTEXT_SCHEMA["required_fields"]: - if field not in context: - errors.append(f"Missing required field: {field}") - fixes.append(f"Add '{field}' to context.json") - - # Check optional but recommended fields - recommended = ["files_to_modify", "files_to_reference", "scoped_services"] - for field in recommended: - if field not in context or not context[field]: - warnings.append(f"Missing recommended field: {field}") - - return ValidationResult( - valid=len(errors) == 0, - checkpoint="context", - errors=errors, - warnings=warnings, - fixes=fixes, - ) - - def validate_spec_document(self) -> ValidationResult: - """Validate spec.md exists and has required sections.""" - errors = [] - warnings = [] - fixes = [] - - spec_file = self.spec_dir / "spec.md" - - if not spec_file.exists(): - errors.append("spec.md not found") - fixes.append("Create spec.md with required sections") - return ValidationResult(False, "spec", errors, warnings, fixes) - - content = spec_file.read_text() - - # Check for required sections - for section in SPEC_REQUIRED_SECTIONS: - # Look for ## Section or # Section - pattern = rf"^##?\s+{re.escape(section)}" - if not re.search(pattern, content, re.MULTILINE | re.IGNORECASE): - errors.append(f"Missing required section: '{section}'") - fixes.append(f"Add '## {section}' section to spec.md") - - # Check for recommended sections - for section in SPEC_RECOMMENDED_SECTIONS: - pattern = rf"^##?\s+{re.escape(section)}" - if not re.search(pattern, content, re.MULTILINE | re.IGNORECASE): - warnings.append(f"Missing recommended section: '{section}'") - - # Check minimum content length - if len(content) < 500: - warnings.append("spec.md seems too short (< 500 chars)") - - return ValidationResult( - valid=len(errors) == 0, - checkpoint="spec", - errors=errors, - warnings=warnings, - fixes=fixes, - ) - - def validate_implementation_plan(self) -> ValidationResult: - """Validate implementation_plan.json exists and has valid schema.""" - errors = [] - warnings = [] - fixes = [] - - plan_file = self.spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - errors.append("implementation_plan.json not found") - fixes.append( - f"Run: python auto-claude/planner.py --spec-dir {self.spec_dir}" - ) - return ValidationResult(False, "plan", errors, warnings, fixes) - - try: - with open(plan_file) as f: - plan = json.load(f) - except json.JSONDecodeError as e: - errors.append(f"implementation_plan.json is invalid JSON: {e}") - fixes.append( - "Regenerate with: python auto-claude/planner.py --spec-dir " - + str(self.spec_dir) - ) - return ValidationResult(False, "plan", errors, warnings, fixes) - - # Validate top-level required fields - schema = IMPLEMENTATION_PLAN_SCHEMA - for field in schema["required_fields"]: - if field not in plan: - errors.append(f"Missing required field: {field}") - fixes.append(f"Add '{field}' to implementation_plan.json") - - # Validate workflow_type - if "workflow_type" in plan: - if plan["workflow_type"] not in schema["workflow_types"]: - errors.append(f"Invalid workflow_type: {plan['workflow_type']}") - fixes.append(f"Use one of: {schema['workflow_types']}") - - # Validate phases - phases = plan.get("phases", []) - if not phases: - errors.append("No phases defined") - fixes.append("Add at least one phase with subtasks") - else: - for i, phase in enumerate(phases): - phase_errors = self._validate_phase(phase, i) - errors.extend(phase_errors) - - # Check for at least one subtask - total_subtasks = sum(len(p.get("subtasks", [])) for p in phases) - if total_subtasks == 0: - errors.append("No subtasks defined in any phase") - fixes.append("Add subtasks to phases") - - # Validate dependencies don't create cycles - dep_errors = self._validate_dependencies(phases) - errors.extend(dep_errors) - - return ValidationResult( - valid=len(errors) == 0, - checkpoint="plan", - errors=errors, - warnings=warnings, - fixes=fixes, - ) - - def _validate_phase(self, phase: dict, index: int) -> list[str]: - """Validate a single phase. - - Supports both legacy format (using 'phase' number) and new format (using 'id' string). - """ - errors = [] - schema = IMPLEMENTATION_PLAN_SCHEMA["phase_schema"] - - # Check required fields - for field in schema["required_fields"]: - if field not in phase: - errors.append(f"Phase {index + 1}: missing required field '{field}'") - - # Check either-or required fields (must have at least one from each group) - for field_group in schema.get("required_fields_either", []): - if not any(f in phase for f in field_group): - errors.append( - f"Phase {index + 1}: missing required field (need one of: {', '.join(field_group)})" - ) - - if "type" in phase and phase["type"] not in schema["phase_types"]: - errors.append(f"Phase {index + 1}: invalid type '{phase['type']}'") - - # Validate subtasks - subtasks = phase.get("subtasks", []) - for j, subtask in enumerate(subtasks): - subtask_errors = self._validate_subtask(subtask, index, j) - errors.extend(subtask_errors) - - return errors - - def _validate_subtask( - self, subtask: dict, phase_idx: int, subtask_idx: int - ) -> list[str]: - """Validate a single subtask.""" - errors = [] - schema = IMPLEMENTATION_PLAN_SCHEMA["subtask_schema"] - - for field in schema["required_fields"]: - if field not in subtask: - errors.append( - f"Phase {phase_idx + 1}, Subtask {subtask_idx + 1}: missing required field '{field}'" - ) - - if "status" in subtask and subtask["status"] not in schema["status_values"]: - errors.append( - f"Phase {phase_idx + 1}, Subtask {subtask_idx + 1}: invalid status '{subtask['status']}'" - ) - - # Validate verification if present - if "verification" in subtask: - ver = subtask["verification"] - ver_schema = IMPLEMENTATION_PLAN_SCHEMA["verification_schema"] - - if "type" not in ver: - errors.append( - f"Phase {phase_idx + 1}, Subtask {subtask_idx + 1}: verification missing 'type'" - ) - elif ver["type"] not in ver_schema["verification_types"]: - errors.append( - f"Phase {phase_idx + 1}, Subtask {subtask_idx + 1}: invalid verification type '{ver['type']}'" - ) - - return errors - - def _validate_dependencies(self, phases: list[dict]) -> list[str]: - """Check for circular dependencies. - - Supports both legacy numeric phase IDs and new string-based phase IDs. - """ - errors = [] - - # Build a map of phase identifiers (supports both "id" and "phase" fields) - # and track their position/order for cycle detection - phase_ids = set() - phase_order = {} # Maps phase id -> position index - - for i, p in enumerate(phases): - # Support both "id" field (new format) and "phase" field (legacy format) - phase_id = p.get("id") or p.get("phase", i + 1) - phase_ids.add(phase_id) - phase_order[phase_id] = i - - for i, phase in enumerate(phases): - phase_id = phase.get("id") or phase.get("phase", i + 1) - depends_on = phase.get("depends_on", []) - - for dep in depends_on: - if dep not in phase_ids: - errors.append( - f"Phase {phase_id}: depends on non-existent phase {dep}" - ) - # Check for forward references (cycles) by comparing positions - elif phase_order.get(dep, -1) >= i: - errors.append( - f"Phase {phase_id}: cannot depend on phase {dep} (would create cycle)" - ) - - return errors - - -def auto_fix_plan(spec_dir: Path) -> bool: - """Attempt to auto-fix common implementation_plan.json issues.""" - plan_file = spec_dir / "implementation_plan.json" - - if not plan_file.exists(): - return False - - try: - with open(plan_file) as f: - plan = json.load(f) - except json.JSONDecodeError: - return False - - fixed = False - - # Fix missing top-level fields - if "feature" not in plan: - plan["feature"] = "Unnamed Feature" - fixed = True - - if "workflow_type" not in plan: - plan["workflow_type"] = "feature" - fixed = True - - if "phases" not in plan: - plan["phases"] = [] - fixed = True - - # Fix phases - for i, phase in enumerate(plan.get("phases", [])): - if "phase" not in phase: - phase["phase"] = i + 1 - fixed = True - - if "name" not in phase: - phase["name"] = f"Phase {i + 1}" - fixed = True - - if "subtasks" not in phase: - phase["subtasks"] = [] - fixed = True - - # Fix subtasks - for j, subtask in enumerate(phase.get("subtasks", [])): - if "id" not in subtask: - subtask["id"] = f"subtask-{i + 1}-{j + 1}" - fixed = True - - if "description" not in subtask: - subtask["description"] = "No description" - fixed = True - - if "status" not in subtask: - subtask["status"] = "pending" - fixed = True - - if fixed: - with open(plan_file, "w") as f: - json.dump(plan, f, indent=2) - print(f"Auto-fixed: {plan_file}") - - return fixed - - -def main(): - """CLI entry point.""" - import argparse - - parser = argparse.ArgumentParser(description="Validate spec outputs at checkpoints") - parser.add_argument( - "--spec-dir", - type=Path, - required=True, - help="Directory containing spec files", - ) - parser.add_argument( - "--checkpoint", - choices=["prereqs", "context", "spec", "plan", "all"], - default="all", - help="Which checkpoint to validate", - ) - parser.add_argument( - "--auto-fix", - action="store_true", - help="Attempt to auto-fix common issues", - ) - parser.add_argument( - "--json", - action="store_true", - help="Output results as JSON", - ) - - args = parser.parse_args() - - validator = SpecValidator(args.spec_dir) - - if args.auto_fix: - auto_fix_plan(args.spec_dir) - - # Run validations - if args.checkpoint == "all": - results = validator.validate_all() - elif args.checkpoint == "prereqs": - results = [validator.validate_prereqs()] - elif args.checkpoint == "context": - results = [validator.validate_context()] - elif args.checkpoint == "spec": - results = [validator.validate_spec_document()] - elif args.checkpoint == "plan": - results = [validator.validate_implementation_plan()] - - # Output - all_valid = all(r.valid for r in results) - - if args.json: - output = { - "valid": all_valid, - "results": [ - { - "checkpoint": r.checkpoint, - "valid": r.valid, - "errors": r.errors, - "warnings": r.warnings, - "fixes": r.fixes, - } - for r in results - ], - } - print(json.dumps(output, indent=2)) - else: - print("=" * 60) - print(" SPEC VALIDATION REPORT") - print("=" * 60) - print() - - for result in results: - print(result) - print() - - print("=" * 60) - if all_valid: - print(" ✓ ALL CHECKPOINTS PASSED") - else: - print(" ✗ VALIDATION FAILED - See errors above") - print("=" * 60) - - sys.exit(0 if all_valid else 1) - - -if __name__ == "__main__": - main() diff --git a/auto-claude/validate_spec/__init__.py b/auto-claude/validate_spec/__init__.py index 9f4061e9..43290c49 100644 --- a/auto-claude/validate_spec/__init__.py +++ b/auto-claude/validate_spec/__init__.py @@ -1,19 +1,43 @@ """ -Spec Validation System -====================== +Backward compatibility shim for validate_spec package. -Validates spec outputs at each checkpoint to ensure reliability. -This is the enforcement layer that catches errors before they propagate. +DEPRECATED: This package has been moved to spec.validate_pkg. -The spec creation process has mandatory checkpoints: -1. Prerequisites (project_index.json exists) -2. Context (context.json created with required fields) -3. Spec document (spec.md with required sections) -4. Implementation plan (implementation_plan.json with valid schema) +Please update your imports: + OLD: from validate_spec import SpecValidator, ValidationResult, auto_fix_plan + NEW: from spec.validate_pkg import SpecValidator, ValidationResult, auto_fix_plan + +This shim provides compatibility but will be removed in a future version. """ -from .auto_fix import auto_fix_plan -from .models import ValidationResult -from .spec_validator import SpecValidator +import sys +from pathlib import Path + +# Lazy import to avoid circular dependencies +def __getattr__(name): + """Lazy import mechanism to avoid circular imports.""" + if name in ("SpecValidator", "ValidationResult", "auto_fix_plan"): + # Add spec directory to path temporarily to allow direct imports + # without triggering spec.__init__ + spec_dir = Path(__file__).parent.parent / "spec" + if str(spec_dir) not in sys.path: + sys.path.insert(0, str(spec_dir)) + + try: + # Import directly from validate_pkg without going through spec package + from validate_pkg import SpecValidator, ValidationResult, auto_fix_plan + + # Cache the imported values in this module + globals()["SpecValidator"] = SpecValidator + globals()["ValidationResult"] = ValidationResult + globals()["auto_fix_plan"] = auto_fix_plan + + return globals()[name] + finally: + # Clean up path modification + if str(spec_dir) in sys.path: + sys.path.remove(str(spec_dir)) + + raise AttributeError(f"module 'validate_spec' has no attribute '{name}'") __all__ = ["SpecValidator", "ValidationResult", "auto_fix_plan"] diff --git a/auto-claude/validate_spec/auto_fix.py b/auto-claude/validate_spec/auto_fix.py index e89e8ca6..bfe2c6b6 100644 --- a/auto-claude/validate_spec/auto_fix.py +++ b/auto-claude/validate_spec/auto_fix.py @@ -1,80 +1,40 @@ """ -Auto-Fix Utilities -================== +Backward compatibility shim for auto_fix module. -Automated fixes for common implementation plan issues. +DEPRECATED: This module has been moved to spec.validate_pkg.auto_fix. + +Please update your imports: + OLD: from validate_spec.auto_fix import auto_fix_plan + NEW: from spec.validate_pkg.auto_fix import auto_fix_plan + +This shim provides compatibility but will be removed in a future version. """ -import json +import sys from pathlib import Path +# Lazy import to avoid circular dependencies +def __getattr__(name): + """Lazy import mechanism to avoid circular imports.""" + if name == "auto_fix_plan": + # Add spec directory to path temporarily to allow direct imports + # without triggering spec.__init__ + spec_dir = Path(__file__).parent.parent / "spec" + if str(spec_dir) not in sys.path: + sys.path.insert(0, str(spec_dir)) -def auto_fix_plan(spec_dir: Path) -> bool: - """Attempt to auto-fix common implementation_plan.json issues. + try: + # Import directly from validate_pkg without going through spec package + from validate_pkg.auto_fix import auto_fix_plan - Args: - spec_dir: Path to the spec directory + # Cache the imported value in this module + globals()["auto_fix_plan"] = auto_fix_plan + return auto_fix_plan + finally: + # Clean up path modification + if str(spec_dir) in sys.path: + sys.path.remove(str(spec_dir)) - Returns: - True if fixes were applied, False otherwise - """ - plan_file = spec_dir / "implementation_plan.json" + raise AttributeError(f"module 'validate_spec.auto_fix' has no attribute '{name}'") - if not plan_file.exists(): - return False - - try: - with open(plan_file) as f: - plan = json.load(f) - except json.JSONDecodeError: - return False - - fixed = False - - # Fix missing top-level fields - if "feature" not in plan: - plan["feature"] = "Unnamed Feature" - fixed = True - - if "workflow_type" not in plan: - plan["workflow_type"] = "feature" - fixed = True - - if "phases" not in plan: - plan["phases"] = [] - fixed = True - - # Fix phases - for i, phase in enumerate(plan.get("phases", [])): - if "phase" not in phase: - phase["phase"] = i + 1 - fixed = True - - if "name" not in phase: - phase["name"] = f"Phase {i + 1}" - fixed = True - - if "subtasks" not in phase: - phase["subtasks"] = [] - fixed = True - - # Fix subtasks - for j, subtask in enumerate(phase.get("subtasks", [])): - if "id" not in subtask: - subtask["id"] = f"subtask-{i + 1}-{j + 1}" - fixed = True - - if "description" not in subtask: - subtask["description"] = "No description" - fixed = True - - if "status" not in subtask: - subtask["status"] = "pending" - fixed = True - - if fixed: - with open(plan_file, "w") as f: - json.dump(plan, f, indent=2) - print(f"Auto-fixed: {plan_file}") - - return fixed +__all__ = ["auto_fix_plan"] diff --git a/auto-claude/validate_spec/spec_validator.py b/auto-claude/validate_spec/spec_validator.py index 1b8064de..1b341bb2 100644 --- a/auto-claude/validate_spec/spec_validator.py +++ b/auto-claude/validate_spec/spec_validator.py @@ -1,80 +1,40 @@ """ -Spec Validator -============== +Backward compatibility shim for spec_validator module. -Main validator class that orchestrates all validation checkpoints. +DEPRECATED: This module has been moved to spec.validate_pkg.spec_validator. + +Please update your imports: + OLD: from validate_spec.spec_validator import SpecValidator + NEW: from spec.validate_pkg.spec_validator import SpecValidator + +This shim provides compatibility but will be removed in a future version. """ +import sys from pathlib import Path -from .models import ValidationResult -from .validators import ( - ContextValidator, - ImplementationPlanValidator, - PrereqsValidator, - SpecDocumentValidator, -) +# Lazy import to avoid circular dependencies +def __getattr__(name): + """Lazy import mechanism to avoid circular imports.""" + if name == "SpecValidator": + # Add spec directory to path temporarily to allow direct imports + # without triggering spec.__init__ + spec_dir = Path(__file__).parent.parent / "spec" + if str(spec_dir) not in sys.path: + sys.path.insert(0, str(spec_dir)) + try: + # Import directly from validate_pkg without going through spec package + from validate_pkg.spec_validator import SpecValidator -class SpecValidator: - """Validates spec outputs at each checkpoint.""" + # Cache the imported value in this module + globals()["SpecValidator"] = SpecValidator + return SpecValidator + finally: + # Clean up path modification + if str(spec_dir) in sys.path: + sys.path.remove(str(spec_dir)) - def __init__(self, spec_dir: Path): - """Initialize the spec validator. + raise AttributeError(f"module 'validate_spec.spec_validator' has no attribute '{name}'") - Args: - spec_dir: Path to the spec directory - """ - self.spec_dir = Path(spec_dir) - - # Initialize individual validators - self._prereqs_validator = PrereqsValidator(self.spec_dir) - self._context_validator = ContextValidator(self.spec_dir) - self._spec_document_validator = SpecDocumentValidator(self.spec_dir) - self._implementation_plan_validator = ImplementationPlanValidator(self.spec_dir) - - def validate_all(self) -> list[ValidationResult]: - """Run all validations. - - Returns: - List of validation results for all checkpoints - """ - results = [ - self.validate_prereqs(), - self.validate_context(), - self.validate_spec_document(), - self.validate_implementation_plan(), - ] - return results - - def validate_prereqs(self) -> ValidationResult: - """Validate prerequisites exist. - - Returns: - ValidationResult for prerequisites checkpoint - """ - return self._prereqs_validator.validate() - - def validate_context(self) -> ValidationResult: - """Validate context.json exists and has required structure. - - Returns: - ValidationResult for context checkpoint - """ - return self._context_validator.validate() - - def validate_spec_document(self) -> ValidationResult: - """Validate spec.md exists and has required sections. - - Returns: - ValidationResult for spec document checkpoint - """ - return self._spec_document_validator.validate() - - def validate_implementation_plan(self) -> ValidationResult: - """Validate implementation_plan.json exists and has valid schema. - - Returns: - ValidationResult for implementation plan checkpoint - """ - return self._implementation_plan_validator.validate() +__all__ = ["SpecValidator"] diff --git a/auto-claude/validation_strategy.py b/auto-claude/validation_strategy.py index cad467e2..a6ee734f 100644 --- a/auto-claude/validation_strategy.py +++ b/auto-claude/validation_strategy.py @@ -1,1033 +1,2 @@ -#!/usr/bin/env python3 -""" -Validation Strategy Module -========================== - -Builds validation strategies based on project type and risk level. -This module determines how the QA agent should validate implementations. - -The validation strategy is used by: -- Planner Agent: To define verification requirements in the implementation plan -- QA Agent: To determine what tests to create and run - -Usage: - from validation_strategy import ValidationStrategyBuilder - - builder = ValidationStrategyBuilder() - strategy = builder.build_strategy(project_dir, spec_dir, "medium") - - for step in strategy: - print(f"Run: {step.command}") -""" - -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from risk_classifier import RiskClassifier - -# ============================================================================= -# DATA CLASSES -# ============================================================================= - - -@dataclass -class ValidationStep: - """ - A single validation step to execute. - - Attributes: - name: Human-readable name of the step - command: Command to execute (or "manual" for manual steps) - expected_outcome: Description of what success looks like - step_type: Type of validation (test, visual, api, security, manual) - required: Whether this step is mandatory - blocking: Whether failure blocks approval - """ - - name: str - command: str - expected_outcome: str - step_type: str # test, visual, api, security, manual - required: bool = True - blocking: bool = True - - -@dataclass -class ValidationStrategy: - """ - Complete validation strategy for a task. - - Attributes: - risk_level: Risk level (trivial, low, medium, high, critical) - project_type: Detected project type - steps: List of validation steps to execute - test_types_required: List of test types to create - security_scan_required: Whether security scanning is needed - staging_deployment_required: Whether staging deployment is needed - skip_validation: Whether validation can be skipped entirely - reasoning: Explanation of the strategy - """ - - risk_level: str - project_type: str - steps: list[ValidationStep] = field(default_factory=list) - test_types_required: list[str] = field(default_factory=list) - security_scan_required: bool = False - staging_deployment_required: bool = False - skip_validation: bool = False - reasoning: str = "" - - -# ============================================================================= -# PROJECT TYPE DETECTION -# ============================================================================= - - -# Project type indicators -PROJECT_TYPE_INDICATORS = { - "html_css": { - "files": ["index.html", "style.css", "styles.css"], - "extensions": [".html", ".css"], - "no_package_manager": True, - }, - "react_spa": { - "dependencies": ["react", "react-dom"], - "files": ["package.json"], - }, - "vue_spa": { - "dependencies": ["vue"], - "files": ["package.json"], - }, - "nextjs": { - "dependencies": ["next"], - "files": ["next.config.js", "next.config.mjs", "next.config.ts"], - }, - "nodejs": { - "files": ["package.json"], - "not_dependencies": ["react", "vue", "next", "angular"], - }, - "python_api": { - "dependencies_python": ["fastapi", "flask", "django"], - "files": ["pyproject.toml", "setup.py", "requirements.txt"], - }, - "python_cli": { - "files": ["pyproject.toml", "setup.py"], - "entry_points": True, - }, - "rust": { - "files": ["Cargo.toml"], - }, - "go": { - "files": ["go.mod"], - }, - "ruby": { - "files": ["Gemfile"], - }, -} - - -def detect_project_type(project_dir: Path) -> str: - """ - Detect the project type based on files and dependencies. - - Args: - project_dir: Path to the project directory - - Returns: - Project type string (e.g., "react_spa", "python_api", "nodejs") - """ - project_dir = Path(project_dir) - - # Check for specific frameworks first - package_json = project_dir / "package.json" - if package_json.exists(): - try: - with open(package_json, encoding="utf-8") as f: - pkg = json.load(f) - deps = pkg.get("dependencies", {}) - dev_deps = pkg.get("devDependencies", {}) - all_deps = {**deps, **dev_deps} - - if "electron" in all_deps: - return "electron" - if "next" in all_deps: - return "nextjs" - if "react" in all_deps: - return "react_spa" - if "vue" in all_deps: - return "vue_spa" - if "@angular/core" in all_deps: - return "angular_spa" - return "nodejs" - except (OSError, json.JSONDecodeError): - return "nodejs" - - # Check for Python projects - pyproject = project_dir / "pyproject.toml" - requirements = project_dir / "requirements.txt" - if pyproject.exists() or requirements.exists(): - # Try to detect API framework - deps_text = "" - if requirements.exists(): - deps_text = requirements.read_text().lower() - if pyproject.exists(): - deps_text += pyproject.read_text().lower() - - if "fastapi" in deps_text or "flask" in deps_text or "django" in deps_text: - return "python_api" - if "click" in deps_text or "typer" in deps_text or "argparse" in deps_text: - return "python_cli" - return "python" - - # Check for other languages - if (project_dir / "Cargo.toml").exists(): - return "rust" - if (project_dir / "go.mod").exists(): - return "go" - if (project_dir / "Gemfile").exists(): - return "ruby" - - # Check for simple HTML/CSS - html_files = list(project_dir.glob("*.html")) - if html_files: - return "html_css" - - return "unknown" - - -# ============================================================================= -# VALIDATION STRATEGY BUILDER -# ============================================================================= - - -class ValidationStrategyBuilder: - """ - Builds validation strategies based on project type and risk level. - - The builder uses the risk assessment from complexity_assessment.json - and adapts the validation strategy to the detected project type. - """ - - def __init__(self) -> None: - """Initialize the strategy builder.""" - self._risk_classifier = RiskClassifier() - - def build_strategy( - self, - project_dir: Path, - spec_dir: Path, - risk_level: str | None = None, - ) -> ValidationStrategy: - """ - Build a validation strategy for the given project and spec. - - Args: - project_dir: Path to the project root - spec_dir: Path to the spec directory - risk_level: Override risk level (if not provided, reads from assessment) - - Returns: - ValidationStrategy with appropriate steps - """ - project_dir = Path(project_dir) - spec_dir = Path(spec_dir) - - # Get risk level from assessment if not provided - if risk_level is None: - assessment = self._risk_classifier.load_assessment(spec_dir) - if assessment: - risk_level = assessment.validation.risk_level - else: - risk_level = "medium" # Default to medium - - # Detect project type - project_type = detect_project_type(project_dir) - - # Build strategy based on project type - strategy_builders = { - "html_css": self._strategy_for_html_css, - "react_spa": self._strategy_for_spa, - "vue_spa": self._strategy_for_spa, - "angular_spa": self._strategy_for_spa, - "nextjs": self._strategy_for_fullstack, - "nodejs": self._strategy_for_nodejs, - "electron": self._strategy_for_electron, - "python_api": self._strategy_for_python_api, - "python_cli": self._strategy_for_cli, - "python": self._strategy_for_python, - "rust": self._strategy_for_rust, - "go": self._strategy_for_go, - "ruby": self._strategy_for_ruby, - } - - builder_func = strategy_builders.get(project_type, self._strategy_default) - strategy = builder_func(project_dir, risk_level) - - # Add security scanning for high+ risk - if risk_level in ["high", "critical"]: - strategy = self._add_security_steps(strategy, project_type) - - # Set common properties - strategy.risk_level = risk_level - strategy.project_type = project_type - strategy.skip_validation = risk_level == "trivial" - - return strategy - - def _strategy_for_html_css( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for simple HTML/CSS projects. - - Focus on visual verification and accessibility. - """ - steps = [ - ValidationStep( - name="Start HTTP Server", - command="python -m http.server 8000 &", - expected_outcome="Server running on port 8000", - step_type="setup", - required=True, - blocking=True, - ), - ValidationStep( - name="Visual Verification", - command="npx playwright screenshot http://localhost:8000 screenshot.png", - expected_outcome="Screenshot captured without errors", - step_type="visual", - required=True, - blocking=False, - ), - ValidationStep( - name="Console Error Check", - command="npx playwright test --grep 'console-errors'", - expected_outcome="No JavaScript console errors", - step_type="test", - required=True, - blocking=True, - ), - ] - - # Add Lighthouse for medium+ risk - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="Lighthouse Audit", - command="npx lighthouse http://localhost:8000 --output=json --output-path=lighthouse.json", - expected_outcome="Performance > 90, Accessibility > 90", - step_type="visual", - required=True, - blocking=risk_level in ["high", "critical"], - ) - ) - - return ValidationStrategy( - risk_level=risk_level, - project_type="html_css", - steps=steps, - test_types_required=["visual"] if risk_level != "trivial" else [], - reasoning="HTML/CSS project requires visual verification and accessibility checks.", - ) - - def _strategy_for_spa( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Single Page Applications (React, Vue, Angular). - - Focus on component tests and E2E testing. - """ - steps = [] - - # Unit/component tests for all non-trivial - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit/Component Tests", - command="npm test", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # E2E tests for medium+ risk - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="E2E Tests", - command="npx playwright test", - expected_outcome="All E2E tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # Browser console check - steps.append( - ValidationStep( - name="Console Error Check", - command="npm run dev & sleep 5 && npx playwright test --grep 'no-console-errors'", - expected_outcome="No console errors in browser", - step_type="test", - required=True, - blocking=risk_level in ["high", "critical"], - ) - ) - - # Determine test types - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - if risk_level in ["high", "critical"]: - test_types.append("e2e") - - return ValidationStrategy( - risk_level=risk_level, - project_type="spa", - steps=steps, - test_types_required=test_types, - reasoning="SPA requires component tests for logic and E2E for user flows.", - ) - - def _strategy_for_fullstack( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for fullstack frameworks (Next.js, Rails, Django). - - Focus on API tests, frontend tests, and integration. - """ - steps = [] - - # Unit tests - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="npm test", - expected_outcome="All unit tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # API tests for medium+ risk - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="API Integration Tests", - command="npm run test:api", - expected_outcome="All API tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # E2E tests for high+ risk - if risk_level in ["high", "critical"]: - steps.append( - ValidationStep( - name="E2E Tests", - command="npm run test:e2e", - expected_outcome="All E2E tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # Database migration check - steps.append( - ValidationStep( - name="Database Migration Check", - command="npm run db:migrate:status", - expected_outcome="All migrations applied successfully", - step_type="api", - required=risk_level in ["medium", "high", "critical"], - blocking=True, - ) - ) - - # Determine test types - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - if risk_level in ["high", "critical"]: - test_types.append("e2e") - - return ValidationStrategy( - risk_level=risk_level, - project_type="fullstack", - steps=steps, - test_types_required=test_types, - reasoning="Fullstack requires API tests, frontend tests, and DB migration checks.", - ) - - def _strategy_for_nodejs( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Node.js backend projects. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="npm test", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="Integration Tests", - command="npm run test:integration", - expected_outcome="All integration tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - - return ValidationStrategy( - risk_level=risk_level, - project_type="nodejs", - steps=steps, - test_types_required=test_types, - reasoning="Node.js backend requires unit and integration tests.", - ) - - def _strategy_for_python_api( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Python API projects (FastAPI, Flask, Django). - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="pytest tests/ -v", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="API Tests", - command="pytest tests/api/ -v", - expected_outcome="All API tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - steps.append( - ValidationStep( - name="Coverage Check", - command="pytest --cov=src --cov-report=term-missing", - expected_outcome="Coverage >= 80%", - step_type="test", - required=True, - blocking=risk_level == "critical", - ) - ) - - if risk_level in ["high", "critical"]: - steps.append( - ValidationStep( - name="Database Migration Check", - command="alembic current && alembic check", - expected_outcome="Migrations are current and valid", - step_type="api", - required=True, - blocking=True, - ) - ) - - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - if risk_level in ["high", "critical"]: - test_types.append("e2e") - - return ValidationStrategy( - risk_level=risk_level, - project_type="python_api", - steps=steps, - test_types_required=test_types, - reasoning="Python API requires pytest tests and migration checks.", - ) - - def _strategy_for_cli( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for CLI tools. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="pytest tests/ -v", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - steps.append( - ValidationStep( - name="CLI Help Check", - command="python -m module_name --help", - expected_outcome="Help text displays without errors", - step_type="test", - required=True, - blocking=True, - ) - ) - - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="CLI Output Verification", - command="python -m module_name --version", - expected_outcome="Version displays correctly", - step_type="test", - required=True, - blocking=False, - ) - ) - - return ValidationStrategy( - risk_level=risk_level, - project_type="python_cli", - steps=steps, - test_types_required=["unit"], - reasoning="CLI tools require output verification and unit tests.", - ) - - def _strategy_for_python( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for generic Python projects. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="pytest tests/ -v", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - - return ValidationStrategy( - risk_level=risk_level, - project_type="python", - steps=steps, - test_types_required=test_types, - reasoning="Python project requires pytest unit tests.", - ) - - def _strategy_for_rust( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Rust projects. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Cargo Test", - command="cargo test", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - steps.append( - ValidationStep( - name="Cargo Clippy", - command="cargo clippy -- -D warnings", - expected_outcome="No clippy warnings", - step_type="test", - required=True, - blocking=risk_level in ["high", "critical"], - ) - ) - - return ValidationStrategy( - risk_level=risk_level, - project_type="rust", - steps=steps, - test_types_required=["unit"], - reasoning="Rust project requires cargo test and clippy checks.", - ) - - def _strategy_for_go( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Go projects. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Go Test", - command="go test ./...", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - steps.append( - ValidationStep( - name="Go Vet", - command="go vet ./...", - expected_outcome="No issues found", - step_type="test", - required=True, - blocking=risk_level in ["high", "critical"], - ) - ) - - return ValidationStrategy( - risk_level=risk_level, - project_type="go", - steps=steps, - test_types_required=["unit"], - reasoning="Go project requires go test and vet checks.", - ) - - def _strategy_for_ruby( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Ruby projects. - """ - steps = [] - - if risk_level != "trivial": - steps.append( - ValidationStep( - name="RSpec Tests", - command="bundle exec rspec", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - return ValidationStrategy( - risk_level=risk_level, - project_type="ruby", - steps=steps, - test_types_required=["unit"], - reasoning="Ruby project requires RSpec tests.", - ) - - def _strategy_for_electron( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Validation strategy for Electron desktop applications. - - Focus on main/renderer process tests, E2E testing, and app packaging. - """ - steps = [] - - # Unit tests for all non-trivial - if risk_level != "trivial": - steps.append( - ValidationStep( - name="Unit Tests", - command="npm test", - expected_outcome="All tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # E2E tests for medium+ risk (Electron apps need GUI testing) - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="E2E Tests", - command="npm run test:e2e", - expected_outcome="All E2E tests pass", - step_type="test", - required=True, - blocking=True, - ) - ) - - # App build/package verification for medium+ risk - if risk_level in ["medium", "high", "critical"]: - steps.append( - ValidationStep( - name="Build Verification", - command="npm run build", - expected_outcome="App builds without errors", - step_type="test", - required=True, - blocking=True, - ) - ) - - # Console error check for high+ risk - if risk_level in ["high", "critical"]: - steps.append( - ValidationStep( - name="Console Error Check", - command="npm run test:console", - expected_outcome="No console errors in main or renderer process", - step_type="test", - required=True, - blocking=True, - ) - ) - - # Determine test types - test_types = ["unit"] - if risk_level in ["medium", "high", "critical"]: - test_types.append("integration") - test_types.append("e2e") - - return ValidationStrategy( - risk_level=risk_level, - project_type="electron", - steps=steps, - test_types_required=test_types, - reasoning="Electron app requires unit tests, E2E tests for GUI, and build verification.", - ) - - def _strategy_default( - self, project_dir: Path, risk_level: str - ) -> ValidationStrategy: - """ - Default validation strategy for unknown project types. - """ - steps = [ - ValidationStep( - name="Manual Verification", - command="manual", - expected_outcome="Code changes reviewed and tested manually", - step_type="manual", - required=True, - blocking=True, - ), - ] - - return ValidationStrategy( - risk_level=risk_level, - project_type="unknown", - steps=steps, - test_types_required=[], - reasoning="Unknown project type - manual verification required.", - ) - - def _add_security_steps( - self, strategy: ValidationStrategy, project_type: str - ) -> ValidationStrategy: - """ - Add security scanning steps to a strategy. - """ - security_steps = [] - - # Secrets scanning (always for high+ risk) - security_steps.append( - ValidationStep( - name="Secrets Scan", - command="python auto-claude/scan_secrets.py --all-files --json", - expected_outcome="No secrets detected", - step_type="security", - required=True, - blocking=True, - ) - ) - - # Language-specific SAST - if project_type in ["python", "python_api", "python_cli"]: - security_steps.append( - ValidationStep( - name="Bandit Security Scan", - command="bandit -r src/ -f json", - expected_outcome="No high severity issues", - step_type="security", - required=True, - blocking=True, - ) - ) - - if project_type in ["nodejs", "react_spa", "vue_spa", "nextjs"]: - security_steps.append( - ValidationStep( - name="npm audit", - command="npm audit --json", - expected_outcome="No critical vulnerabilities", - step_type="security", - required=True, - blocking=True, - ) - ) - - strategy.steps.extend(security_steps) - strategy.security_scan_required = True - - return strategy - - def to_dict(self, strategy: ValidationStrategy) -> dict[str, Any]: - """ - Convert a ValidationStrategy to a dictionary for JSON serialization. - """ - return { - "risk_level": strategy.risk_level, - "project_type": strategy.project_type, - "skip_validation": strategy.skip_validation, - "test_types_required": strategy.test_types_required, - "security_scan_required": strategy.security_scan_required, - "staging_deployment_required": strategy.staging_deployment_required, - "reasoning": strategy.reasoning, - "steps": [ - { - "name": step.name, - "command": step.command, - "expected_outcome": step.expected_outcome, - "type": step.step_type, - "required": step.required, - "blocking": step.blocking, - } - for step in strategy.steps - ], - } - - -# ============================================================================= -# CONVENIENCE FUNCTIONS -# ============================================================================= - - -def build_validation_strategy( - project_dir: Path, - spec_dir: Path, - risk_level: str | None = None, -) -> ValidationStrategy: - """ - Convenience function to build a validation strategy. - - Args: - project_dir: Path to project root - spec_dir: Path to spec directory - risk_level: Optional override for risk level - - Returns: - ValidationStrategy object - """ - builder = ValidationStrategyBuilder() - return builder.build_strategy(project_dir, spec_dir, risk_level) - - -def get_strategy_as_dict( - project_dir: Path, - spec_dir: Path, - risk_level: str | None = None, -) -> dict[str, Any]: - """ - Get validation strategy as a dictionary. - - Args: - project_dir: Path to project root - spec_dir: Path to spec directory - risk_level: Optional override for risk level - - Returns: - Dictionary representation of strategy - """ - builder = ValidationStrategyBuilder() - strategy = builder.build_strategy(project_dir, spec_dir, risk_level) - return builder.to_dict(strategy) - - -# ============================================================================= -# CLI -# ============================================================================= - - -def main() -> None: - """CLI entry point for testing.""" - import argparse - - parser = argparse.ArgumentParser(description="Build validation strategy") - parser.add_argument("project_dir", type=Path, help="Path to project root") - parser.add_argument("--spec-dir", type=Path, help="Path to spec directory") - parser.add_argument("--risk-level", type=str, help="Override risk level") - parser.add_argument("--json", action="store_true", help="Output as JSON") - - args = parser.parse_args() - - spec_dir = args.spec_dir or args.project_dir - builder = ValidationStrategyBuilder() - strategy = builder.build_strategy(args.project_dir, spec_dir, args.risk_level) - - if args.json: - print(json.dumps(builder.to_dict(strategy), indent=2)) - else: - print(f"Project Type: {strategy.project_type}") - print(f"Risk Level: {strategy.risk_level}") - print(f"Skip Validation: {strategy.skip_validation}") - print(f"Test Types: {', '.join(strategy.test_types_required)}") - print(f"Security Scan: {strategy.security_scan_required}") - print(f"Reasoning: {strategy.reasoning}") - print(f"\nValidation Steps ({len(strategy.steps)}):") - for i, step in enumerate(strategy.steps, 1): - print(f" {i}. {step.name}") - print(f" Command: {step.command}") - print(f" Expected: {step.expected_outcome}") - - -if __name__ == "__main__": - main() +"""Backward compatibility shim - import from spec.validation_strategy instead.""" +from spec.validation_strategy import * diff --git a/auto-claude/workspace.py b/auto-claude/workspace.py index 60acb25b..d1498d39 100644 --- a/auto-claude/workspace.py +++ b/auto-claude/workspace.py @@ -1,2399 +1,45 @@ -#!/usr/bin/env python3 """ -Workspace Management - Per-Spec Architecture -============================================= +Backward compatibility shim - import from core.workspace package. -Handles workspace isolation through Git worktrees, where each spec -gets its own isolated worktree in .worktrees/{spec-name}/. +This file exists to maintain backward compatibility for code that imports +from 'workspace' instead of 'core.workspace'. The workspace module has been +refactored into a package (core/workspace/) with multiple sub-modules. -This module has been refactored for better maintainability: -- Models and enums: workspace/models.py -- Git utilities: workspace/git_utils.py -- Setup functions: workspace/setup.py -- Display functions: workspace/display.py -- Finalization: workspace/finalization.py -- Complex merge operations: remain here (workspace.py) +IMPLEMENTATION: To avoid triggering core/__init__.py (which imports modules +with heavy dependencies like claude_agent_sdk), we: +1. Create a minimal fake 'core' module to satisfy Python's import system +2. Load core.workspace package directly using importlib +3. Register it in sys.modules +4. Re-export everything -Public API is exported via workspace/__init__.py for backward compatibility. +This allows 'from workspace import X' to work without requiring all of core's dependencies. """ -import asyncio -import json -import shutil -import subprocess import sys -from dataclasses import dataclass +import importlib.util from pathlib import Path -from typing import Optional - -from ui import ( - Icons, - MenuOption, - bold, - box, - error, - highlight, - icon, - info, - muted, - print_status, - select_menu, - success, - warning, -) -from worktree import WorktreeInfo, WorktreeManager - -# Import debug utilities -try: - from debug import debug, debug_detailed, debug_verbose, debug_success, debug_error, debug_warning, is_debug_enabled -except ImportError: - def debug(*args, **kwargs): pass - def debug_detailed(*args, **kwargs): pass - def debug_verbose(*args, **kwargs): pass - def debug_success(*args, **kwargs): pass - def debug_error(*args, **kwargs): pass - def debug_warning(*args, **kwargs): pass - def is_debug_enabled(): return False - -# Import merge system -from merge import ( - MergeOrchestrator, - MergeDecision, - ConflictSeverity, - FileEvolutionTracker, - FileTimelineTracker, -) - -# Import from refactored modules -from workspace.models import ( - WorkspaceMode, - WorkspaceChoice, - ParallelMergeTask, - ParallelMergeResult, - MergeLock, - MergeLockError, -) - -from workspace.git_utils import ( - has_uncommitted_changes, - get_current_branch, - get_existing_build_worktree, - get_file_content_from_ref as _get_file_content_from_ref, - get_changed_files_from_branch as _get_changed_files_from_branch, - is_process_running as _is_process_running, - is_binary_file as _is_binary_file, - is_lock_file as _is_lock_file, - validate_merged_syntax as _validate_merged_syntax, - create_conflict_file_with_git as _create_conflict_file_with_git, - MAX_FILE_LINES_FOR_AI, - MAX_PARALLEL_AI_MERGES, - MAX_SYNTAX_FIX_RETRIES, - BINARY_EXTENSIONS, - LOCK_FILES, - MERGE_LOCK_TIMEOUT, -) - -from workspace.setup import ( - choose_workspace, - copy_spec_to_worktree, - setup_workspace, - ensure_timeline_hook_installed as _ensure_timeline_hook_installed, - initialize_timeline_tracking as _initialize_timeline_tracking, -) - -from workspace.display import ( - show_build_summary, - show_changed_files, - print_merge_success as _print_merge_success, - print_conflict_info as _print_conflict_info, -) - -from workspace.finalization import ( - finalize_workspace, - handle_workspace_choice, - review_existing_build, - discard_existing_build, - check_existing_build, - list_all_worktrees, - cleanup_all_worktrees, -) - -MODULE = "workspace" - -# The following functions are now imported from refactored modules above. -# They are kept here only to avoid breaking the existing code that still needs -# the complex merge operations below. - -# Remaining complex merge operations that reference each other: -# - merge_existing_build -# - _try_smart_merge -# - _try_smart_merge_inner -# - _check_git_conflicts -# - _resolve_git_conflicts_with_ai -# - _create_async_claude_client -# - _async_ai_call -# - _merge_file_with_ai_async -# - _run_parallel_merges -# - _record_merge_completion -# - _get_task_intent -# - _get_recent_merges_context -# - _merge_file_with_ai -# - _heuristic_merge - -def merge_existing_build( - project_dir: Path, - spec_name: str, - no_commit: bool = False, - use_smart_merge: bool = True, -) -> bool: - """ - Merge an existing build into the project using intent-aware merge. - - Called when user runs: python auto-claude/run.py --spec X --merge - - This uses the MergeOrchestrator to: - 1. Analyze semantic changes from the task - 2. Detect potential conflicts with main branch - 3. Auto-merge compatible changes - 4. Use AI for ambiguous conflicts (if enabled) - 5. Fall back to git merge for remaining changes - - Args: - project_dir: The project directory - spec_name: Name of the spec - no_commit: If True, merge changes but don't commit (stage only for review in IDE) - use_smart_merge: If True, use intent-aware merge (default True) - - Returns: - True if merge succeeded - """ - 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 - - if no_commit: - content = [ - bold(f"{icon(Icons.SUCCESS)} STAGING BUILD FOR REVIEW"), - "", - muted("Changes will be staged but NOT committed."), - muted("Review in your IDE, then commit when ready."), - ] - else: - 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() - - # Try smart merge first if enabled - if use_smart_merge: - smart_result = _try_smart_merge( - project_dir, spec_name, worktree_path, manager, no_commit=no_commit - ) - - if smart_result is not None: - # Smart merge handled it (success or identified conflicts) - if smart_result.get("success"): - # Check if smart merge resolved git conflicts directly - if smart_result.get("stats", {}).get("ai_assisted"): - # AI resolved git conflicts - changes are already staged - _print_merge_success(no_commit, smart_result.get("stats")) - - # Cleanup the worktree since merge is done - try: - manager.remove_worktree(spec_name, delete_branch=True) - except Exception: - pass # Best effort cleanup - - return True - else: - # No git conflicts, do standard git merge - success_result = manager.merge_worktree( - spec_name, delete_after=True, no_commit=no_commit - ) - if success_result: - _print_merge_success(no_commit, smart_result.get("stats")) - return True - elif smart_result.get("git_conflicts"): - # Had git conflicts that AI couldn't fully resolve - resolved = smart_result.get("resolved", []) - remaining = smart_result.get("conflicts", []) - - if resolved: - print() - print_status(f"AI resolved {len(resolved)} file(s)", "success") - - if remaining: - print() - print_status( - f"{len(remaining)} conflict(s) require manual resolution:", - "warning" - ) - _print_conflict_info(smart_result) - - # Changes for resolved files are staged, remaining need manual work - print() - print("The resolved files are staged. For remaining conflicts:") - print(muted(" 1. Manually resolve the conflicting files")) - print(muted(" 2. git add ")) - print(muted(" 3. git commit")) - return False - elif smart_result.get("conflicts"): - # Has semantic conflicts that need resolution - _print_conflict_info(smart_result) - print() - print(muted("Attempting git merge anyway...")) - print() - - # Fall back to standard git merge - success_result = manager.merge_worktree( - spec_name, delete_after=True, no_commit=no_commit - ) - - if success_result: - print() - if no_commit: - print_status("Changes are staged in your working directory.", "success") - print() - print("Review the changes in your IDE, then commit:") - print(highlight(" git commit -m 'your commit message'")) - print() - print("Or discard if not satisfied:") - print(muted(" git reset --hard HEAD")) - else: - 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 _try_smart_merge( - project_dir: Path, - spec_name: str, - worktree_path: Path, - manager: WorktreeManager, - no_commit: bool = False, -) -> Optional[dict]: - """ - Try to use the intent-aware merge system. - - This handles both semantic conflicts (parallel tasks) and git conflicts - (branch divergence) by using AI to intelligently merge files. - - Uses a lock file to prevent concurrent merges for the same spec. - - Returns: - Dict with results, or None if smart merge not applicable - """ - # Quick Win 5: Acquire merge lock to prevent concurrent operations - try: - with MergeLock(project_dir, spec_name): - return _try_smart_merge_inner( - project_dir, spec_name, worktree_path, manager, no_commit - ) - except MergeLockError as e: - print(warning(f" {e}")) - return { - "success": False, - "error": str(e), - "conflicts": [], - } - - -def _try_smart_merge_inner( - project_dir: Path, - spec_name: str, - worktree_path: Path, - manager: WorktreeManager, - no_commit: bool = False, -) -> Optional[dict]: - """Inner implementation of smart merge (called with lock held).""" - debug(MODULE, "=== SMART MERGE START ===", - spec_name=spec_name, - worktree_path=str(worktree_path), - no_commit=no_commit) - - try: - print(muted(" Analyzing changes with intent-aware merge...")) - - # Capture worktree state in FileTimelineTracker before merge - try: - timeline_tracker = FileTimelineTracker(project_dir) - timeline_tracker.capture_worktree_state(spec_name, worktree_path) - debug(MODULE, "Captured worktree state for timeline tracking") - except Exception as e: - debug_warning(MODULE, f"Could not capture worktree state: {e}") - - # Initialize the orchestrator - debug(MODULE, "Initializing MergeOrchestrator", - project_dir=str(project_dir), - enable_ai=True) - orchestrator = MergeOrchestrator( - project_dir, - enable_ai=True, # Enable AI for ambiguous conflicts - dry_run=False, - ) - - # Refresh evolution data from the worktree - debug(MODULE, "Refreshing evolution data from git", - spec_name=spec_name) - orchestrator.evolution_tracker.refresh_from_git(spec_name, worktree_path) - - # Check for git-level conflicts first (branch divergence) - debug(MODULE, "Checking for git-level conflicts") - git_conflicts = _check_git_conflicts(project_dir, spec_name) - - debug_detailed(MODULE, "Git conflict check result", - has_conflicts=git_conflicts.get("has_conflicts"), - conflicting_files=git_conflicts.get("conflicting_files", []), - base_branch=git_conflicts.get("base_branch")) - - if git_conflicts.get("has_conflicts"): - print(muted(f" Branch has diverged from {git_conflicts.get('base_branch', 'main')}")) - print(muted(f" Conflicting files: {len(git_conflicts.get('conflicting_files', []))}")) - - debug(MODULE, "Starting AI conflict resolution", - num_conflicts=len(git_conflicts.get("conflicting_files", []))) - - # Try to resolve git conflicts with AI - resolution_result = _resolve_git_conflicts_with_ai( - project_dir, - spec_name, - worktree_path, - git_conflicts, - orchestrator, - no_commit=no_commit, - ) - - if resolution_result.get("success"): - debug_success(MODULE, "AI conflict resolution succeeded", - resolved_files=resolution_result.get("resolved_files", []), - stats=resolution_result.get("stats", {})) - return resolution_result - else: - # AI couldn't resolve all conflicts - debug_error(MODULE, "AI conflict resolution failed", - remaining_conflicts=resolution_result.get("remaining_conflicts", []), - resolved_files=resolution_result.get("resolved_files", []), - error=resolution_result.get("error")) - return { - "success": False, - "conflicts": resolution_result.get("remaining_conflicts", []), - "resolved": resolution_result.get("resolved_files", []), - "git_conflicts": True, - "error": resolution_result.get("error"), - } - - # No git conflicts - proceed with semantic analysis - debug(MODULE, "No git conflicts, proceeding with semantic analysis") - preview = orchestrator.preview_merge([spec_name]) - - files_to_merge = len(preview.get("files_to_merge", [])) - conflicts = preview.get("conflicts", []) - auto_mergeable = preview.get("summary", {}).get("auto_mergeable", 0) - - print(muted(f" Found {files_to_merge} files to merge")) - - if conflicts: - print(muted(f" Detected {len(conflicts)} potential conflict(s)")) - print(muted(f" Auto-mergeable: {auto_mergeable}/{len(conflicts)}")) - - # Check if any conflicts need human review - needs_human = [ - c for c in conflicts - if not c.get("can_auto_merge") - ] - - if needs_human: - return { - "success": False, - "conflicts": needs_human, - "preview": preview, - } - - # All conflicts can be auto-merged or no conflicts - print(muted(" All changes compatible, proceeding with merge...")) - return { - "success": True, - "stats": { - "files_merged": files_to_merge, - "auto_resolved": auto_mergeable, - }, - } - - except Exception as e: - # If smart merge fails, fall back to git - import traceback - print(muted(f" Smart merge error: {e}")) - traceback.print_exc() - return None - - -def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict: - """ - Check for git-level conflicts WITHOUT modifying the working directory. - - Uses git merge-tree to check conflicts in-memory, avoiding HMR triggers - from file system changes. - - Returns: - Dict with has_conflicts, conflicting_files, etc. - """ - import re - import subprocess - - spec_branch = f"auto-claude/{spec_name}" - result = { - "has_conflicts": False, - "conflicting_files": [], - "base_branch": "main", - "spec_branch": spec_branch, - } - - try: - # Get current branch - base_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) - if base_result.returncode == 0: - result["base_branch"] = base_result.stdout.strip() - - # Get merge base - merge_base_result = subprocess.run( - ["git", "merge-base", result["base_branch"], spec_branch], - cwd=project_dir, - capture_output=True, - text=True, - ) - if merge_base_result.returncode != 0: - debug_warning(MODULE, "Could not find merge base") - return result - - merge_base = merge_base_result.stdout.strip() - - # Get commit hashes - main_commit_result = subprocess.run( - ["git", "rev-parse", result["base_branch"]], - cwd=project_dir, - capture_output=True, - text=True, - ) - spec_commit_result = subprocess.run( - ["git", "rev-parse", spec_branch], - cwd=project_dir, - capture_output=True, - text=True, - ) - - if main_commit_result.returncode != 0 or spec_commit_result.returncode != 0: - debug_warning(MODULE, "Could not resolve branch commits") - return result - - main_commit = main_commit_result.stdout.strip() - spec_commit = spec_commit_result.stdout.strip() - - # Use git merge-tree to check for conflicts WITHOUT touching working directory - merge_tree_result = subprocess.run( - ["git", "merge-tree", "--write-tree", "--no-messages", merge_base, main_commit, spec_commit], - cwd=project_dir, - capture_output=True, - text=True, - ) - - # merge-tree returns exit code 1 if there are conflicts - if merge_tree_result.returncode != 0: - result["has_conflicts"] = True - - # Parse the output for conflicting files - output = merge_tree_result.stdout + merge_tree_result.stderr - for line in output.split("\n"): - if "CONFLICT" in line: - match = re.search(r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()", line) - if match: - file_path = match.group(1).strip() - if file_path and file_path not in result["conflicting_files"]: - result["conflicting_files"].append(file_path) - - # Fallback: if we didn't parse conflicts, use diff to find files changed in both branches - if not result["conflicting_files"]: - main_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, main_commit], - cwd=project_dir, - capture_output=True, - text=True, - ) - main_files = set(main_files_result.stdout.strip().split("\n")) if main_files_result.stdout.strip() else set() - - spec_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, spec_commit], - cwd=project_dir, - capture_output=True, - text=True, - ) - spec_files = set(spec_files_result.stdout.strip().split("\n")) if spec_files_result.stdout.strip() else set() - - # Files modified in both = potential conflicts - conflicting = main_files & spec_files - result["conflicting_files"] = list(conflicting) - - except Exception as e: - print(muted(f" Error checking git conflicts: {e}")) - - return result - - -def _resolve_git_conflicts_with_ai( - project_dir: Path, - spec_name: str, - worktree_path: Path, - git_conflicts: dict, - orchestrator: MergeOrchestrator, - no_commit: bool = False, -) -> dict: - """ - Resolve git-level conflicts using AI. - - This handles the case where main has diverged from the worktree branch. - For each conflicting file, it: - 1. Gets the content from the main branch - 2. Gets the content from the worktree branch - 3. Gets the common ancestor (merge-base) content - 4. Uses AI to intelligently merge them - 5. Writes the merged content to main and stages it - - Returns: - Dict with success, resolved_files, remaining_conflicts - """ - import subprocess - - debug(MODULE, "=== AI CONFLICT RESOLUTION START ===", - spec_name=spec_name, - num_conflicting_files=len(git_conflicts.get("conflicting_files", []))) - - conflicting_files = git_conflicts.get("conflicting_files", []) - base_branch = git_conflicts.get("base_branch", "main") - spec_branch = git_conflicts.get("spec_branch", f"auto-claude/{spec_name}") - - debug_detailed(MODULE, "Conflict resolution params", - base_branch=base_branch, - spec_branch=spec_branch, - conflicting_files=conflicting_files) - - resolved_files = [] - remaining_conflicts = [] - auto_merged_count = 0 - ai_merged_count = 0 - - print() - print_status(f"Resolving {len(conflicting_files)} conflicting file(s) with AI...", "progress") - - # Get merge-base commit - merge_base_result = subprocess.run( - ["git", "merge-base", base_branch, spec_branch], - cwd=project_dir, - capture_output=True, - text=True, - ) - merge_base = merge_base_result.stdout.strip() if merge_base_result.returncode == 0 else None - debug(MODULE, "Found merge-base commit", merge_base=merge_base[:12] if merge_base else None) - - # FIX: Copy NEW files FIRST before resolving conflicts - # This ensures dependencies exist before files that import them are written - changed_files = _get_changed_files_from_branch(project_dir, base_branch, spec_branch) - new_files = [(f, s) for f, s in changed_files if s == "A" and f not in conflicting_files] - - if new_files: - print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)...")) - for file_path, status in new_files: - try: - content = _get_file_content_from_ref(project_dir, spec_branch, file_path) - if content is not None: - target_path = project_dir / file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(content, encoding="utf-8") - subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) - resolved_files.append(file_path) - debug(MODULE, f"Copied new file: {file_path}") - except Exception as e: - debug_warning(MODULE, f"Could not copy new file {file_path}: {e}") - - # Categorize conflicting files for processing - files_needing_ai_merge: list[ParallelMergeTask] = [] - simple_merges: list[tuple[str, Optional[str]]] = [] # (file_path, merged_content or None for delete) - - debug(MODULE, "Categorizing conflicting files for parallel processing") - - for file_path in conflicting_files: - debug(MODULE, f"Categorizing conflicting file: {file_path}") - - try: - # Get content from main branch - main_content = _get_file_content_from_ref(project_dir, base_branch, file_path) - - # Get content from worktree branch - worktree_content = _get_file_content_from_ref(project_dir, spec_branch, file_path) - - # Get content from merge-base (common ancestor) - base_content = None - if merge_base: - base_content = _get_file_content_from_ref(project_dir, merge_base, file_path) - - if main_content is None and worktree_content is None: - # File doesn't exist in either - skip - continue - - if main_content is None: - # File only exists in worktree - it's a new file (no AI needed) - simple_merges.append((file_path, worktree_content)) - debug(MODULE, f" {file_path}: new file (no AI needed)") - elif worktree_content is None: - # File only exists in main - was deleted in worktree (no AI needed) - simple_merges.append((file_path, None)) # None = delete - debug(MODULE, f" {file_path}: deleted (no AI needed)") - else: - # File exists in both - check if it's a lock file - if _is_lock_file(file_path): - # Lock files should never go through AI - just take worktree version - # User can run package manager install to regenerate if needed - simple_merges.append((file_path, worktree_content)) - debug(MODULE, f" {file_path}: lock file (taking worktree version)") - else: - # Regular file - needs AI merge - files_needing_ai_merge.append(ParallelMergeTask( - file_path=file_path, - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - )) - debug(MODULE, f" {file_path}: needs AI merge") - - except Exception as e: - print(error(f" ✗ Failed to categorize {file_path}: {e}")) - remaining_conflicts.append({ - "file": file_path, - "reason": str(e), - "severity": "high", - }) - - # Process simple merges first (fast, no AI) - if simple_merges: - print(muted(f" Processing {len(simple_merges)} simple file(s)...")) - for file_path, merged_content in simple_merges: - try: - if merged_content is not None: - target_path = project_dir / file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(merged_content, encoding="utf-8") - subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) - resolved_files.append(file_path) - # Determine the type for display - if _is_lock_file(file_path): - print(success(f" ✓ {file_path} (lock file - took worktree version)")) - else: - print(success(f" ✓ {file_path} (new file)")) - else: - # Delete the file - target_path = project_dir / file_path - if target_path.exists(): - target_path.unlink() - subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) - resolved_files.append(file_path) - print(success(f" ✓ {file_path} (deleted)")) - except Exception as e: - print(error(f" ✗ {file_path}: {e}")) - remaining_conflicts.append({ - "file": file_path, - "reason": str(e), - "severity": "high", - }) - - # Process AI merges in parallel - if files_needing_ai_merge: - print() - print_status(f"Merging {len(files_needing_ai_merge)} file(s) with AI (parallel)...", "progress") - - import time - start_time = time.time() - - # Run parallel merges - parallel_results = asyncio.run(_run_parallel_merges( - tasks=files_needing_ai_merge, - project_dir=project_dir, - max_concurrent=MAX_PARALLEL_AI_MERGES, - )) - - elapsed = time.time() - start_time - - # Process results - for result in parallel_results: - if result.success: - target_path = project_dir / result.file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(result.merged_content, encoding="utf-8") - subprocess.run(["git", "add", result.file_path], cwd=project_dir, capture_output=True) - resolved_files.append(result.file_path) - - if result.was_auto_merged: - auto_merged_count += 1 - print(success(f" ✓ {result.file_path} (git auto-merged)")) - else: - ai_merged_count += 1 - print(success(f" ✓ {result.file_path} (AI merged)")) - else: - print(error(f" ✗ {result.file_path}: {result.error}")) - remaining_conflicts.append({ - "file": result.file_path, - "reason": result.error or "AI could not resolve the conflict", - "severity": "high", - }) - - # Print summary - print() - print(muted(f" Parallel merge completed in {elapsed:.1f}s")) - print(muted(f" Git auto-merged: {auto_merged_count}")) - print(muted(f" AI merged: {ai_merged_count}")) - if remaining_conflicts: - print(muted(f" Failed: {len(remaining_conflicts)}")) - - # ALWAYS process non-conflicting files, even if some conflicts failed - # This ensures we get as much of the build as possible - # (New files were already copied at the start) - print(muted(" Merging remaining files...")) - - # Get list of modified/deleted files (new files already copied at start) - non_conflicting = [ - (f, s) for f, s in changed_files - if f not in conflicting_files and s != "A" # Skip new files, already copied - ] - - for file_path, status in non_conflicting: - try: - if status == "D": - # Deleted in worktree - target_path = project_dir / file_path - if target_path.exists(): - target_path.unlink() - subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) - else: - # Added or modified - copy from worktree - content = _get_file_content_from_ref(project_dir, spec_branch, file_path) - if content is not None: - target_path = project_dir / file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(content, encoding="utf-8") - subprocess.run(["git", "add", file_path], cwd=project_dir, capture_output=True) - resolved_files.append(file_path) - except Exception as e: - print(muted(f" Warning: Could not process {file_path}: {e}")) - - # V2: Record merge completion in Evolution Tracker for future context - if resolved_files: - _record_merge_completion(project_dir, spec_name, resolved_files) - - # Build result - partial success if some files failed but we got others - result = { - "success": len(remaining_conflicts) == 0, - "resolved_files": resolved_files, - "stats": { - "files_merged": len(resolved_files), - "conflicts_resolved": len(conflicting_files) - len(remaining_conflicts), - "ai_assisted": ai_merged_count, - "auto_merged": auto_merged_count, - "parallel_ai_merges": len(files_needing_ai_merge), - }, - } - - # Add remaining conflicts if any (for UI to show what needs manual attention) - if remaining_conflicts: - result["remaining_conflicts"] = remaining_conflicts - result["partial_success"] = len(resolved_files) > 0 - print() - print(warning(f" ⚠ {len(remaining_conflicts)} file(s) could not be auto-merged:")) - for conflict in remaining_conflicts: - print(muted(f" - {conflict['file']}: {conflict['reason']}")) - print(muted(" These files may need manual review.")) - - return result - - -def _get_file_content_from_ref(project_dir: Path, ref: str, file_path: str) -> Optional[str]: - """Get file content from a git ref (branch, commit, etc.).""" - import subprocess - - result = subprocess.run( - ["git", "show", f"{ref}:{file_path}"], - cwd=project_dir, - capture_output=True, - text=True, - ) - if result.returncode == 0: - return result.stdout - return None - - -def _get_changed_files_from_branch( - project_dir: Path, - base_branch: str, - spec_branch: str, -) -> list[tuple[str, str]]: - """Get list of changed files between branches.""" - import subprocess - - result = subprocess.run( - ["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"], - cwd=project_dir, - capture_output=True, - text=True, - ) - - files = [] - if result.returncode == 0: - for line in result.stdout.strip().split("\n"): - if line: - parts = line.split("\t", 1) - if len(parts) == 2: - files.append((parts[1], parts[0])) # (file_path, status) - return files - - -# Constants for merge limits -MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this -MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations - -BINARY_EXTENSIONS = { - '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.svg', - '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', - '.zip', '.tar', '.gz', '.rar', '.7z', - '.exe', '.dll', '.so', '.dylib', '.bin', - '.mp3', '.mp4', '.wav', '.avi', '.mov', '.mkv', - '.woff', '.woff2', '.ttf', '.otf', '.eot', - '.pyc', '.pyo', '.class', '.o', '.obj', -} - -# Merge lock timeout in seconds -MERGE_LOCK_TIMEOUT = 300 # 5 minutes - - -class MergeLock: - """ - Context manager for merge locking to prevent concurrent merges. - - Uses a lock file in .auto-claude/ to ensure only one merge operation - runs at a time for a given project. - """ - - def __init__(self, project_dir: Path, spec_name: str): - self.project_dir = project_dir - self.spec_name = spec_name - self.lock_dir = project_dir / ".auto-claude" / ".locks" - self.lock_file = self.lock_dir / f"merge-{spec_name}.lock" - self.acquired = False - - def __enter__(self): - """Acquire the merge lock.""" - import time - import os - - self.lock_dir.mkdir(parents=True, exist_ok=True) - - # Check if lock exists and is stale - if self.lock_file.exists(): - try: - lock_data = json.loads(self.lock_file.read_text()) - lock_time = lock_data.get("timestamp", 0) - lock_pid = lock_data.get("pid", 0) - - # Check if lock is stale (older than timeout) - if time.time() - lock_time > MERGE_LOCK_TIMEOUT: - print(muted(f" Removing stale merge lock (older than {MERGE_LOCK_TIMEOUT}s)")) - self.lock_file.unlink() - # Check if locking process is still alive - elif lock_pid and not _is_process_running(lock_pid): - print(muted(f" Removing orphaned merge lock (PID {lock_pid} not running)")) - self.lock_file.unlink() - else: - raise MergeLockError( - f"Another merge operation is in progress for {self.spec_name}. " - f"If this is an error, delete {self.lock_file}" - ) - except json.JSONDecodeError: - # Corrupted lock file, remove it - self.lock_file.unlink() - - # Create lock file - lock_data = { - "spec_name": self.spec_name, - "timestamp": time.time(), - "pid": os.getpid(), - } - self.lock_file.write_text(json.dumps(lock_data)) - self.acquired = True - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Release the merge lock.""" - if self.acquired and self.lock_file.exists(): - try: - self.lock_file.unlink() - except Exception: - pass # Best effort cleanup - return False - - -class MergeLockError(Exception): - """Raised when a merge lock cannot be acquired.""" - pass - - -@dataclass -class ParallelMergeTask: - """A file merge task to be executed in parallel.""" - file_path: str - main_content: str - worktree_content: str - base_content: Optional[str] - spec_name: str - - -@dataclass -class ParallelMergeResult: - """Result of a parallel merge task.""" - file_path: str - merged_content: Optional[str] - success: bool - error: Optional[str] = None - was_auto_merged: bool = False # True if git auto-merged without AI - - -async def _create_async_claude_client(): - """Create an async Claude client for merge resolution.""" - import os - - oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") - if not oauth_token: - debug_warning(MODULE, "CLAUDE_CODE_OAUTH_TOKEN not set") - return None - - try: - from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient - - client = ClaudeSDKClient( - options=ClaudeAgentOptions( - model="sonnet", - system_prompt="You are an expert code merge assistant. Be concise and precise.", - allowed_tools=[], # No tools needed for merge - max_turns=1, - ) - ) - return client - except ImportError: - debug_warning(MODULE, "claude_agent_sdk not installed") - return None - - -async def _async_ai_call(client, system: str, user: str) -> str: - """Make an async AI call using an existing client.""" - try: - await client.query(user) - - response_text = "" - async for msg in client.receive_response(): - msg_type = type(msg).__name__ - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - if hasattr(block, "text"): - response_text += block.text - - return response_text - except Exception as e: - debug_error(MODULE, f"Async AI call failed: {e}") - return "" - - -async def _merge_file_with_ai_async( - task: ParallelMergeTask, - project_dir: Path, - semaphore: asyncio.Semaphore, -) -> ParallelMergeResult: - """ - Async version of file merge with AI. - - Uses a semaphore to limit concurrent AI calls. - """ - from merge.prompts import ( - build_conflict_only_prompt, - parse_conflict_markers, - reassemble_with_resolutions, - extract_conflict_resolutions, - build_simple_merge_prompt, - ) - from merge import AIResolver - - file_path = task.file_path - main_content = task.main_content - worktree_content = task.worktree_content - base_content = task.base_content - spec_name = task.spec_name - - debug(MODULE, f"[PARALLEL] Starting async merge for: {file_path}") - - # Quick checks before acquiring semaphore - if _is_binary_file(file_path): - return ParallelMergeResult( - file_path=file_path, - merged_content=None, - success=False, - error="Binary file - skipped" - ) - - main_lines = main_content.count('\n') if main_content else 0 - worktree_lines = worktree_content.count('\n') if worktree_content else 0 - max_lines = max(main_lines, worktree_lines) - - if max_lines > MAX_FILE_LINES_FOR_AI: - return ParallelMergeResult( - file_path=file_path, - merged_content=None, - success=False, - error=f"File too large ({max_lines} lines)" - ) - - # Try git merge-file first (doesn't need AI) - if base_content: - merged_content, has_conflicts = _create_conflict_file_with_git( - main_content, worktree_content, base_content, project_dir - ) - - # Case 1: Git cleanly merged - no AI needed! - if merged_content and not has_conflicts: - is_valid, error_msg = _validate_merged_syntax(file_path, merged_content, project_dir) - if is_valid: - debug_success(MODULE, f"[PARALLEL] Git auto-merged (no AI): {file_path}") - return ParallelMergeResult( - file_path=file_path, - merged_content=merged_content, - success=True, - was_auto_merged=True - ) - else: - merged_content = None - has_conflicts = False - - # Acquire semaphore for AI calls - async with semaphore: - debug(MODULE, f"[PARALLEL] Acquired semaphore for AI merge: {file_path}") - - # Create client for this task - client = await _create_async_claude_client() - if not client: - return ParallelMergeResult( - file_path=file_path, - merged_content=_heuristic_merge(main_content, worktree_content, base_content), - success=True if _heuristic_merge(main_content, worktree_content, base_content) else False, - error="AI unavailable, used heuristic" - ) - - try: - async with client: - # Determine language - resolver = AIResolver() - language = resolver._infer_language(file_path) - - # Get task intent - task_intent = _get_task_intent(project_dir, spec_name) - - # Case 2: Has conflict markers - use conflict-only AI merge - if merged_content and has_conflicts: - conflicts, _ = parse_conflict_markers(merged_content) - - if conflicts: - total_conflict_lines = sum( - len(c['main_lines'].split('\n')) + len(c['worktree_lines'].split('\n')) - for c in conflicts - ) - savings_pct = 100 - (total_conflict_lines * 100 // max(max_lines, 1)) - - debug(MODULE, f"[PARALLEL] Conflict-only merge for {file_path}", - num_conflicts=len(conflicts), savings_pct=savings_pct) - - prompt = build_conflict_only_prompt( - file_path=file_path, - conflicts=conflicts, - spec_name=spec_name, - language=language, - task_intent=task_intent, - ) - - response = await _async_ai_call( - client, - "You are an expert code merge assistant. Resolve ONLY the specific conflicts shown.", - prompt, - ) - - if response: - resolutions = extract_conflict_resolutions(response, conflicts, language) - if resolutions: - merged = reassemble_with_resolutions(merged_content, conflicts, resolutions) - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - - # Retry loop: if syntax is invalid, give AI feedback to fix it - retry_count = 0 - while not is_valid and retry_count < MAX_SYNTAX_FIX_RETRIES: - retry_count += 1 - debug(MODULE, f"[PARALLEL] Syntax error in merge, retry {retry_count}/{MAX_SYNTAX_FIX_RETRIES}: {file_path}") - - # Build fix prompt with error feedback - fix_prompt = f"""The merged code you produced has a syntax error: - -{error_msg} - -Here is your merged code that has the error: -```{language} -{merged} -``` - -Please fix the syntax error and output the corrected code. Make sure: -1. All lines are properly separated (no concatenated lines) -2. All brackets/braces are properly matched -3. All statements end correctly - -Output ONLY the fixed code, no explanations.""" - - fix_response = await _async_ai_call( - client, - "You are an expert code fixer. Fix the syntax error in the code.", - fix_prompt, - ) - - if fix_response: - fixed = resolver._extract_code_block(fix_response, language) - if not fixed and resolver._looks_like_code(fix_response, language): - fixed = fix_response.strip() - if fixed: - merged = fixed - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - - if is_valid: - debug_success(MODULE, f"[PARALLEL] Conflict-only merge succeeded: {file_path}" + - (f" (after {retry_count} fix retries)" if retry_count > 0 else "")) - return ParallelMergeResult( - file_path=file_path, - merged_content=merged, - success=True - ) - else: - debug(MODULE, f"[PARALLEL] Conflict-only merge failed validation after {retry_count} retries, falling back to full-file: {file_path}") - - # Case 3: Full-file AI merge (fallback) - debug(MODULE, f"[PARALLEL] Full-file merge for: {file_path}") - - prompt = build_simple_merge_prompt( - file_path=file_path, - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - language=language, - task_intent=task_intent, - ) - - response = await _async_ai_call( - client, - "You are an expert code merge assistant. Output only the merged code.", - prompt, - ) - - if response: - merged = resolver._extract_code_block(response, language) - if not merged and resolver._looks_like_code(response, language): - merged = response.strip() - - if merged: - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - - # Retry loop: if syntax is invalid, give AI feedback to fix it - retry_count = 0 - while not is_valid and retry_count < MAX_SYNTAX_FIX_RETRIES: - retry_count += 1 - debug(MODULE, f"[PARALLEL] Syntax error in full-file merge, retry {retry_count}/{MAX_SYNTAX_FIX_RETRIES}: {file_path}") - - # Build fix prompt with error feedback - fix_prompt = f"""The merged code you produced has a syntax error: - -{error_msg} - -Here is your merged code that has the error: -```{language} -{merged} -``` - -Please fix the syntax error and output the corrected code. Make sure: -1. All lines are properly separated (no concatenated lines) -2. All brackets/braces are properly matched -3. All statements end correctly - -Output ONLY the fixed code, no explanations.""" - - fix_response = await _async_ai_call( - client, - "You are an expert code fixer. Fix the syntax error in the code.", - fix_prompt, - ) - - if fix_response: - fixed = resolver._extract_code_block(fix_response, language) - if not fixed and resolver._looks_like_code(fix_response, language): - fixed = fix_response.strip() - if fixed: - merged = fixed - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - - if is_valid: - debug_success(MODULE, f"[PARALLEL] Full-file merge succeeded: {file_path}" + - (f" (after {retry_count} fix retries)" if retry_count > 0 else "")) - return ParallelMergeResult( - file_path=file_path, - merged_content=merged, - success=True - ) - else: - debug_error(MODULE, f"[PARALLEL] Full-file merge failed validation after {retry_count} retries: {file_path}") - - # AI couldn't merge - return ParallelMergeResult( - file_path=file_path, - merged_content=None, - success=False, - error="AI could not merge file - syntax validation failed after retries" - ) - - except Exception as e: - debug_error(MODULE, f"[PARALLEL] Async merge failed for {file_path}: {e}") - return ParallelMergeResult( - file_path=file_path, - merged_content=_heuristic_merge(main_content, worktree_content, base_content), - success=False, - error=str(e) - ) - - -async def _run_parallel_merges( - tasks: list[ParallelMergeTask], - project_dir: Path, - max_concurrent: int = MAX_PARALLEL_AI_MERGES, -) -> list[ParallelMergeResult]: - """ - Run multiple file merges in parallel. - - Uses asyncio.gather with a semaphore to limit concurrency. - - Args: - tasks: List of merge tasks to execute - project_dir: Project directory for validation - max_concurrent: Maximum concurrent AI calls - - Returns: - List of merge results in same order as tasks - """ - if not tasks: - return [] - - debug(MODULE, f"[PARALLEL] Starting {len(tasks)} parallel merges (max concurrent: {max_concurrent})") - - # Create semaphore to limit concurrent AI calls - semaphore = asyncio.Semaphore(max_concurrent) - - # Create coroutines for all tasks - coroutines = [ - _merge_file_with_ai_async(task, project_dir, semaphore) - for task in tasks - ] - - # Run all in parallel - results = await asyncio.gather(*coroutines, return_exceptions=True) - - # Convert exceptions to failed results - processed_results = [] - for i, result in enumerate(results): - if isinstance(result, Exception): - processed_results.append(ParallelMergeResult( - file_path=tasks[i].file_path, - merged_content=None, - success=False, - error=str(result) - )) - else: - processed_results.append(result) - - # Log summary - successful = sum(1 for r in processed_results if r.success) - auto_merged = sum(1 for r in processed_results if r.was_auto_merged) - debug_success(MODULE, f"[PARALLEL] Completed: {successful}/{len(tasks)} successful, {auto_merged} auto-merged") - - return processed_results - - -def _is_process_running(pid: int) -> bool: - """Check if a process with the given PID is running.""" - import os - try: - os.kill(pid, 0) - return True - except (OSError, ProcessLookupError): - return False - - -def _is_binary_file(file_path: str) -> bool: - """Check if a file is binary based on extension.""" - from pathlib import Path - return Path(file_path).suffix.lower() in BINARY_EXTENSIONS - - -def _record_merge_completion( - project_dir: Path, - spec_name: str, - merged_files: list[str], - task_intent: str = "", - merge_commit: str = "", -) -> None: - """ - Record completed merge in both Evolution Tracker and FileTimelineTracker. - - This enables future AI merges to understand the history of file changes, - creating a knowledge chain for intelligent conflict resolution. - - Args: - project_dir: Project root directory - spec_name: The task/spec that was merged - merged_files: List of file paths that were merged - task_intent: Description of what the task accomplished - merge_commit: The commit hash of the merge (for timeline tracking) - """ - # Get intent from implementation plan if not provided - if not task_intent: - intent_data = _get_task_intent(project_dir, spec_name) - if intent_data: - task_intent = intent_data.get("description", "") or intent_data.get("title", spec_name) - - # Track in FileEvolutionTracker (legacy system) - try: - tracker = FileEvolutionTracker(project_dir) - - # Mark the task as completed for all its tracked files - tracker.mark_task_completed(spec_name) - - # Record merge metadata for each file - for file_path in merged_files: - evolution = tracker.get_file_evolution(file_path) - if evolution: - # The task snapshot should already exist from refresh_from_git - # Just ensure it's marked as completed with intent - snapshot = evolution.get_task_snapshot(spec_name) - if snapshot: - snapshot.task_intent = task_intent - - # Save updates - tracker._save_evolutions() - - debug(MODULE, f"Recorded merge in FileEvolutionTracker", - spec_name=spec_name, files=len(merged_files)) - - except Exception as e: - debug_warning(MODULE, f"Could not record in FileEvolutionTracker: {e}") - - # Track in FileTimelineTracker (new intent-aware system) - try: - timeline_tracker = FileTimelineTracker(project_dir) - - # Get merge commit if not provided - if not merge_commit: - import subprocess - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) - merge_commit = result.stdout.strip() if result.returncode == 0 else "unknown" - - # Mark task as merged in timeline tracker - timeline_tracker.on_task_merged(spec_name, merge_commit) - - debug(MODULE, f"Recorded merge in FileTimelineTracker", - spec_name=spec_name, merge_commit=merge_commit[:8]) - print(muted(f" Recorded merge completion for {len(merged_files)} files")) - - except Exception as e: - # Non-fatal - this is supplementary tracking - debug_warning(MODULE, f"Could not record in FileTimelineTracker: {e}") - print(muted(f" Note: Could not record merge completion: {e}")) - - -def _get_task_intent(project_dir: Path, spec_name: str) -> Optional[dict]: - """ - Load task intent from implementation_plan.json. - - Returns dict with: - - title: Task title - - description: What the task does - - files_to_modify: List of files the task planned to modify - - current_subtask: What the agent was working on - """ - try: - # Try worktree location first, then main project - for base_path in [ - project_dir / ".worktrees" / spec_name / ".auto-claude" / "specs" / spec_name, - project_dir / ".auto-claude" / "specs" / spec_name, - ]: - plan_path = base_path / "implementation_plan.json" - if plan_path.exists(): - with open(plan_path) as f: - plan = json.load(f) - - # Extract key intent information - intent = { - "title": plan.get("title", spec_name), - "description": plan.get("description", ""), - "files_to_modify": [], - "subtasks": [], - } - - # Get files_to_modify from phases/subtasks - for phase in plan.get("phases", []): - for subtask in phase.get("subtasks", []): - intent["subtasks"].append({ - "title": subtask.get("title", ""), - "description": subtask.get("description", ""), - "status": subtask.get("status", "pending"), - }) - # Extract files from subtask if present - files = subtask.get("files", []) - intent["files_to_modify"].extend(files) - - # Also check spec.md for high-level context - spec_path = base_path / "spec.md" - if spec_path.exists(): - spec_content = spec_path.read_text() - # Extract first paragraph as summary - lines = spec_content.split("\n\n") - if len(lines) > 1: - intent["spec_summary"] = lines[1][:500] # First content paragraph - - return intent - - return None - except Exception as e: - print(muted(f" Could not load task intent: {e}")) - return None - - -def _get_recent_merges_context(project_dir: Path, file_path: str, limit: int = 3) -> list[dict]: - """ - Get context about recent merges that touched this file. - - Uses the FileEvolutionTracker to retrieve historical information about - recent tasks that have modified this file. This enables the AI to understand - the file's evolution when resolving merge conflicts. - - Args: - project_dir: Project root directory - file_path: Path to the file (relative or absolute) - limit: Maximum number of recent merges to return - - Returns: - List of {task_id, intent, timestamp, changes} for recent tasks that modified this file. - """ - try: - from merge import FileEvolutionTracker - - tracker = FileEvolutionTracker(project_dir) - evolution = tracker.get_file_evolution(file_path) - - if not evolution: - return [] - - # Get task snapshots that have completed modifications - completed_snapshots = [ - ts for ts in evolution.task_snapshots - if ts.completed_at is not None and ts.semantic_changes - ] - - # Sort by completion time (most recent first) - completed_snapshots.sort( - key=lambda ts: ts.completed_at or ts.started_at, - reverse=True - ) - - # Limit results - recent = completed_snapshots[:limit] - - # Build context for each merge - result = [] - for snapshot in recent: - # Summarize the semantic changes - change_summary = [] - for change in snapshot.semantic_changes[:5]: # Limit to 5 changes - change_summary.append( - f"{change.change_type.value}: {change.symbol_name or change.description}" - ) - - result.append({ - "task_id": snapshot.task_id, - "intent": snapshot.task_intent, - "timestamp": (snapshot.completed_at or snapshot.started_at).isoformat(), - "changes": change_summary, - }) - - return result - - except Exception as e: - # Log but don't fail - this is supplementary context - print(muted(f" Could not load merge history for {file_path}: {e}")) - return [] - - -def _validate_merged_syntax(file_path: str, content: str, project_dir: Path) -> tuple[bool, str]: - """ - Validate the syntax of merged code. - - Returns (is_valid, error_message). - """ - import subprocess - import tempfile - from pathlib import Path as P - - ext = P(file_path).suffix.lower() - - # TypeScript/JavaScript validation - if ext in {'.ts', '.tsx', '.js', '.jsx'}: - try: - # Write to temp file in system temp dir (NOT project dir to avoid HMR triggers) - with tempfile.NamedTemporaryFile( - mode='w', - suffix=ext, - delete=False, - # Don't set dir= to avoid writing to project directory which triggers HMR - ) as tmp: - tmp.write(content) - tmp_path = tmp.name - - try: - # Try tsc first (TypeScript) - if ext in {'.ts', '.tsx'}: - result = subprocess.run( - ['npx', 'tsc', '--noEmit', '--skipLibCheck', tmp_path], - cwd=project_dir, - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode != 0: - # Filter out npm warnings (they go to stderr but aren't errors) - error_lines = [ - line for line in result.stderr.strip().split('\n') - if line and not line.startswith('npm warn') and not line.startswith('npm WARN') - ] - # Only treat as error if there are actual TypeScript errors - if error_lines: - return False, '\n'.join(error_lines[:3]) - # No actual errors, just npm warnings - syntax is valid - - # Try eslint for all JS/TS - result = subprocess.run( - ['npx', 'eslint', '--no-eslintrc', '--parser', '@typescript-eslint/parser', tmp_path], - cwd=project_dir, - capture_output=True, - text=True, - timeout=30, - ) - # eslint exit 1 for errors, 0 for clean - if result.returncode > 1: # 2+ is config error, ignore - pass - elif result.returncode == 1 and 'Parsing error' in result.stdout: - return False, "Syntax error in merged code" - - finally: - P(tmp_path).unlink(missing_ok=True) - - return True, "" - - except subprocess.TimeoutExpired: - return True, "" # Timeout = assume ok - except FileNotFoundError: - return True, "" # No tsc/eslint = skip validation - except Exception as e: - return True, "" # Other errors = skip validation - - # Python validation - elif ext == '.py': - try: - compile(content, file_path, 'exec') - return True, "" - except SyntaxError as e: - return False, f"Python syntax error: {e.msg} at line {e.lineno}" - - # JSON validation - elif ext == '.json': - try: - json.loads(content) - return True, "" - except json.JSONDecodeError as e: - return False, f"JSON error: {e.msg} at line {e.lineno}" - - # Other file types - skip validation - return True, "" - - -def _create_conflict_file_with_git( - main_content: str, - worktree_content: str, - base_content: Optional[str], - project_dir: Path, -) -> tuple[Optional[str], bool]: - """ - Use git merge-file to create a file with conflict markers. - - This produces a file with standard git conflict markers that can be - parsed to extract only the conflicting regions. - - Returns: - Tuple of (merged_content, has_conflicts): - - (content, True) if there are conflict markers - - (content, False) if git cleanly merged (USE THIS RESULT!) - - (None, False) if merge failed - """ - import subprocess - import tempfile - - if not base_content: - # Without a base, we can't do proper 3-way merge with markers - debug(MODULE, "git merge-file: no base content available") - return None, False - - try: - # Create temp files for git merge-file - with tempfile.NamedTemporaryFile(mode='w', suffix='.main', delete=False) as main_f: - main_f.write(main_content) - main_path = main_f.name - - with tempfile.NamedTemporaryFile(mode='w', suffix='.base', delete=False) as base_f: - base_f.write(base_content) - base_path = base_f.name - - with tempfile.NamedTemporaryFile(mode='w', suffix='.worktree', delete=False) as worktree_f: - worktree_f.write(worktree_content) - worktree_path = worktree_f.name - - try: - # git merge-file modifies the first file in place - # Returns 0 if no conflicts, >0 if conflicts - result = subprocess.run( - ['git', 'merge-file', '-p', main_path, base_path, worktree_path], - cwd=project_dir, - capture_output=True, - text=True, - timeout=30, - ) - - debug(MODULE, "git merge-file result", - return_code=result.returncode, - output_length=len(result.stdout) if result.stdout else 0, - has_conflict_markers='<<<<<<' in result.stdout if result.stdout else False) - - # Return code > 0 means conflicts exist - if result.returncode > 0 and '<<<<<<' in result.stdout: - debug(MODULE, "git merge-file: has conflicts") - return result.stdout, True - elif result.returncode == 0: - # Clean merge, no conflicts - THIS IS STILL USEFUL! - debug(MODULE, "git merge-file: clean merge (no conflicts)") - return result.stdout, False - else: - # Some error occurred - debug_warning(MODULE, "git merge-file: unexpected result", - return_code=result.returncode, - stderr=result.stderr[:200] if result.stderr else None) - return None, False - - finally: - # Cleanup temp files - Path(main_path).unlink(missing_ok=True) - Path(base_path).unlink(missing_ok=True) - Path(worktree_path).unlink(missing_ok=True) - - except Exception as e: - debug_warning(MODULE, f"git merge-file failed: {e}") - - return None, False - - -def _merge_file_with_ai( - file_path: str, - main_content: str, - worktree_content: str, - base_content: Optional[str], - spec_name: str, - orchestrator: MergeOrchestrator, - project_dir: Optional[Path] = None, -) -> Optional[str]: - """ - Use AI to merge a conflicting file. - - OPTIMIZED: First tries to identify specific conflict regions and only - sends those to the AI, rather than regenerating the entire file. - - This enhanced version includes: - - Conflict-region-only merging (FAST - only sends conflict lines) - - Fallback to full-file merge for complex cases - - FileTimelineTracker context for full situational awareness - - Task intent from implementation_plan.json - - Binary file detection - - File size limits - - Syntax validation after merge - - Returns merged content, or None if AI couldn't resolve. - """ - from merge import create_claude_resolver - from merge.prompts import ( - build_timeline_merge_prompt, - build_simple_merge_prompt, - build_conflict_only_prompt, - parse_conflict_markers, - reassemble_with_resolutions, - extract_conflict_resolutions, - ) - - debug(MODULE, f"AI merge starting for: {file_path}", - spec_name=spec_name, - has_base_content=base_content is not None) - - # Quick Win 2: Binary file detection - if _is_binary_file(file_path): - debug_warning(MODULE, "Skipping binary file", file_path=file_path) - print(warning(f" Binary file detected, skipping AI merge")) - return None - - # Quick Win 4: File size limit - main_lines = main_content.count('\n') if main_content else 0 - worktree_lines = worktree_content.count('\n') if worktree_content else 0 - max_lines = max(main_lines, worktree_lines) - - debug_detailed(MODULE, "File size check", - main_lines=main_lines, - worktree_lines=worktree_lines, - max_allowed=MAX_FILE_LINES_FOR_AI) - - if max_lines > MAX_FILE_LINES_FOR_AI: - debug_warning(MODULE, "File too large for AI merge", - max_lines=max_lines, - limit=MAX_FILE_LINES_FOR_AI) - print(warning(f" File too large ({max_lines} lines > {MAX_FILE_LINES_FOR_AI}), skipping AI merge")) - return None - - # Create an AI resolver - resolver = create_claude_resolver() - - if not resolver.ai_call_fn: - debug_warning(MODULE, "AI not available, using heuristic merge") - print(muted(f" AI not available, trying heuristic merge...")) - return _heuristic_merge(main_content, worktree_content, base_content) - - # Determine language - language = resolver._infer_language(file_path) - debug(MODULE, "Detected language", language=language) - - # Get task intent for context - task_intent = None - if project_dir: - task_intent = _get_task_intent(project_dir, spec_name) - - # OPTIMIZATION: Try git merge-file first - # This can either: - # 1. Cleanly merge (no AI needed - FASTEST) - # 2. Produce conflict markers (only send conflict regions to AI - FAST) - # 3. Fail (fall back to full-file AI merge - SLOW) - if project_dir and base_content: - merged_content, has_conflicts = _create_conflict_file_with_git( - main_content, worktree_content, base_content, project_dir - ) - - # Case 1: Git cleanly merged - no AI needed! - if merged_content and not has_conflicts: - debug_success(MODULE, "Git merge-file cleanly merged (no AI needed)", - file_path=file_path) - print(success(f" ✓ Git auto-merged (no conflicts)")) - - # Validate syntax before returning - is_valid, error_msg = _validate_merged_syntax(file_path, merged_content, project_dir) - if is_valid: - return merged_content - else: - debug_warning(MODULE, "Git auto-merge produced invalid syntax, falling back to AI", - error=error_msg) - print(muted(f" Git auto-merge had syntax issues, trying AI...")) - - # Case 2: Has conflict markers - use conflict-only AI merge (FAST) - if merged_content and has_conflicts: - conflicts, _ = parse_conflict_markers(merged_content) - - if conflicts: - # Calculate how much smaller this approach is - total_conflict_lines = sum( - len(c['main_lines'].split('\n')) + len(c['worktree_lines'].split('\n')) - for c in conflicts - ) - savings_pct = 100 - (total_conflict_lines * 100 // max(max_lines, 1)) - - debug(MODULE, "Using conflict-only merge (optimized)", - num_conflicts=len(conflicts), - conflict_lines=total_conflict_lines, - file_lines=max_lines, - savings_pct=savings_pct) - print(muted(f" Found {len(conflicts)} conflict region(s) ({total_conflict_lines} lines vs {max_lines} total - {savings_pct}% smaller prompt)")) - - # Build focused prompt with only conflict regions - prompt = build_conflict_only_prompt( - file_path=file_path, - conflicts=conflicts, - spec_name=spec_name, - language=language, - task_intent=task_intent, - ) - - try: - debug(MODULE, "Calling AI for conflict-only merge") - response = resolver.ai_call_fn( - "You are an expert code merge assistant. Resolve ONLY the specific conflicts shown. Output the resolved code for each conflict.", - prompt, - ) - - if response: - debug(MODULE, "Conflict-only AI response received", - response_length=len(response), - preview=response[:200] if len(response) > 200 else response) - - # Extract resolutions for each conflict - resolutions = extract_conflict_resolutions(response, conflicts, language) - - if resolutions: - debug(MODULE, "Extracted conflict resolutions", - num_resolutions=len(resolutions), - expected=len(conflicts)) - - # Reassemble the file with resolved conflicts - merged = reassemble_with_resolutions(merged_content, conflicts, resolutions) - - # Validate syntax - if project_dir: - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - if is_valid: - debug_success(MODULE, "Conflict-only merge succeeded", - file_path=file_path, - conflicts_resolved=len(resolutions)) - print(success(f" ✓ Resolved {len(resolutions)} conflict(s)")) - return merged - else: - debug_warning(MODULE, "Conflict-only merge produced invalid syntax, falling back", - error=error_msg) - print(muted(f" Conflict-only merge had syntax issues, trying full-file merge...")) - else: - return merged - else: - debug_warning(MODULE, "No resolutions extracted from AI response", - response_preview=response[:500] if len(response) > 500 else response) - print(muted(f" Could not extract conflict resolutions from AI response, trying full-file merge...")) - else: - debug_warning(MODULE, "Conflict-only AI returned empty response") - print(muted(f" AI returned empty response, trying full-file merge...")) - - except Exception as e: - debug_warning(MODULE, f"Conflict-only merge failed: {e}, falling back to full-file") - print(muted(f" Conflict-only merge failed, trying full-file merge...")) - - # FALLBACK: Full-file merge approach (slower but more comprehensive) - print(muted(f" Using full-file AI merge...")) - - # Try to get timeline context for richer merge prompt - timeline_context = None - if project_dir: - try: - tracker = FileTimelineTracker(project_dir) - timeline_context = tracker.get_merge_context(spec_name, file_path) - if timeline_context: - debug(MODULE, "Using FileTimelineTracker context", - commits_behind=timeline_context.total_commits_behind, - pending_tasks=timeline_context.total_pending_tasks, - main_events=len(timeline_context.main_evolution)) - except Exception as e: - debug_warning(MODULE, f"Could not get timeline context: {e}") - - # Build prompt - use timeline context if available, fallback to simple prompt - if timeline_context and timeline_context.total_commits_behind > 0: - # Use the rich timeline-based prompt with full situational awareness - debug(MODULE, "Building timeline-based merge prompt", - commits_behind=timeline_context.total_commits_behind, - main_events=len(timeline_context.main_evolution), - pending_tasks=timeline_context.total_pending_tasks) - print(muted(f" Using timeline context ({timeline_context.total_commits_behind} commits behind, {timeline_context.total_pending_tasks} pending tasks)")) - prompt = build_timeline_merge_prompt(timeline_context) - else: - # Fallback to simple three-way merge prompt - debug(MODULE, "Building simple merge prompt (no timeline context)") - - if task_intent: - debug(MODULE, "Loaded task intent", - title=task_intent.get('title'), - num_subtasks=len(task_intent.get('subtasks', []))) - - prompt = build_simple_merge_prompt( - file_path=file_path, - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - language=language, - task_intent=task_intent, - ) - - try: - debug(MODULE, "Calling AI for full-file merge", - file_path=file_path, - has_timeline_context=timeline_context is not None) - - response = resolver.ai_call_fn( - "You are an expert code merge assistant. Output only the merged code. The code MUST be syntactically valid.", - prompt, - ) - - debug_detailed(MODULE, "AI response received", - response_length=len(response) if response else 0) - - # Log response content for debugging (truncated) - if response: - preview = response[:200] if len(response) > 200 else response - print(f" [DEBUG] AI response preview: {repr(preview)}", file=sys.stderr) - else: - print(f" [DEBUG] AI response was empty", file=sys.stderr) - - # Extract code from response - merged = resolver._extract_code_block(response, language) - if not merged: - # If extraction failed, try using the whole response if it looks like code - if resolver._looks_like_code(response, language): - merged = response.strip() - - if not merged: - debug_error(MODULE, "Could not extract merged code from AI response") - print(muted(f" Could not extract merged code from AI response")) - return None - - debug(MODULE, "Extracted merged code", - merged_lines=merged.count('\n') + 1) - - # Quick Win 3: Validate syntax before returning - if project_dir: - debug(MODULE, "Validating merged syntax") - is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir) - if not is_valid: - debug_warning(MODULE, "AI merge produced invalid syntax", - error=error_msg) - print(warning(f" AI merge produced invalid syntax: {error_msg}")) - print(muted(f" Retrying with syntax fix...")) - - # Try once more with explicit syntax fix request - retry_prompt = f'''The previous merge attempt produced invalid {language} code. -Error: {error_msg} - -Please fix the syntax error and output valid {language} code: - -{merged} - -Output ONLY the fixed code, wrapped in triple backticks: -```{language} -fixed code here -``` -''' - retry_response = resolver.ai_call_fn( - f"Fix the syntax error in this {language} code. Output only valid code.", - retry_prompt, - ) - retry_merged = resolver._extract_code_block(retry_response, language) - if retry_merged: - is_valid, _ = _validate_merged_syntax(file_path, retry_merged, project_dir) - if is_valid: - debug_success(MODULE, "Syntax fix retry succeeded", file_path=file_path) - return retry_merged - else: - debug_error(MODULE, "Syntax fix retry also failed", file_path=file_path) - print(warning(f" Retry also produced invalid syntax")) - return None - else: - debug_error(MODULE, "Could not extract code from retry response") - return None - - debug_success(MODULE, "AI merge completed successfully", - file_path=file_path, - merged_lines=merged.count('\n') + 1) - return merged - - except Exception as e: - debug_error(MODULE, "AI merge failed with exception", - file_path=file_path, - error=str(e)) - print(muted(f" AI merge failed: {e}")) - return _heuristic_merge(main_content, worktree_content, base_content) - - -def _heuristic_merge( - main_content: str, - worktree_content: str, - base_content: Optional[str], -) -> Optional[str]: - """ - Try a simple heuristic merge when AI is unavailable. - - This uses Python's difflib to attempt a three-way merge. - """ - import difflib - - if base_content is None: - # Without a base, we can't do a proper three-way merge - # Just prefer worktree content (the feature being merged) - return worktree_content - - try: - # Use diff3-style merge - main_lines = main_content.splitlines(keepends=True) - worktree_lines = worktree_content.splitlines(keepends=True) - base_lines = base_content.splitlines(keepends=True) - - # Simple approach: find what's changed in each branch and try to combine - # This is a simplified version - real diff3 is more complex - - # Get diffs from base to each branch - main_diff = list(difflib.unified_diff(base_lines, main_lines)) - worktree_diff = list(difflib.unified_diff(base_lines, worktree_lines)) - - # If one has no changes, use the other - if not main_diff: - return worktree_content - if not worktree_diff: - return main_content - - # If both have changes, this simple heuristic won't work reliably - # Return None to indicate AI is needed - return None - - except Exception: - return None - - -def _print_merge_success(no_commit: bool, stats: Optional[dict] = None) -> None: - """Print success message after merge.""" - print() - if stats: - print(muted(f" Files merged: {stats.get('files_merged', 0)}")) - if stats.get('auto_resolved'): - print(muted(f" Conflicts auto-resolved: {stats.get('auto_resolved', 0)}")) - print() - - if no_commit: - print_status("Changes are staged in your working directory.", "success") - print() - print("Review the changes in your IDE, then commit:") - print(highlight(" git commit -m 'your commit message'")) - print() - print("Or discard if not satisfied:") - print(muted(" git reset --hard HEAD")) - else: - print_status("Your feature has been added to your project.", "success") - - -def _print_conflict_info(result: dict) -> None: - """Print information about detected conflicts.""" - conflicts = result.get("conflicts", []) - - print() - print_status(f"Detected {len(conflicts)} conflict(s) that need attention:", "warning") - print() - - for i, conflict in enumerate(conflicts[:5], 1): # Show first 5 - file_path = conflict.get("file", "unknown") - location = conflict.get("location", "") - reason = conflict.get("reason", "") - severity = conflict.get("severity", "unknown") - - print(f" {i}. {highlight(file_path)}") - if location: - print(f" Location: {muted(location)}") - if reason: - print(f" Reason: {muted(reason)}") - print(f" Severity: {severity}") - print() - - if len(conflicts) > 5: - print(muted(f" ... and {len(conflicts) - 5} more")) - - -def review_existing_build(project_dir: Path, spec_name: str) -> bool: - """ - Show what an existing build contains. - - Called when user runs: python auto-claude/run.py --spec X --review - - Args: - project_dir: The project directory - spec_name: Name of the spec - - Returns: - True if build exists - """ - 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) - worktree_info = 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 worktree_info: - print(muted(f" git diff {worktree_info.base_branch}...{worktree_info.branch}")) - print() - - return True - - -def discard_existing_build(project_dir: Path, spec_name: str) -> bool: - """ - Discard an existing build (with confirmation). - - Called when user runs: python auto-claude/run.py --spec X --discard - - Requires typing "delete" to confirm - prevents accidents. - - Args: - project_dir: The project directory - spec_name: Name of the spec - - Returns: - True if discarded - """ - 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 - - # Actually delete - 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. - - Returns True if user wants to continue with existing build, - False if they want to start fresh (after discarding). - """ - worktree_path = get_existing_build_worktree(project_dir, spec_name) - - if not worktree_path: - return False # No existing build - - 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 # Continue with existing - elif choice == "review": - review_existing_build(project_dir, spec_name) - print() - input("Press Enter to continue building...") - return True - elif choice == "merge": - merge_existing_build(project_dir, spec_name) - return False # Start fresh after merge - elif choice == "fresh": - discarded = discard_existing_build(project_dir, spec_name) - return not discarded # If discarded, start fresh - else: - return True # Default to continue - - -def list_all_worktrees(project_dir: Path) -> list[WorktreeInfo]: - """ - List all spec worktrees in the project. - - Args: - project_dir: Main project directory - - Returns: - List of WorktreeInfo for each spec worktree - """ - manager = WorktreeManager(project_dir) - return manager.list_all_worktrees() - - -def cleanup_all_worktrees(project_dir: Path, confirm: bool = True) -> bool: - """ - Remove all worktrees and their branches. - - Args: - project_dir: Main project directory - confirm: Whether to ask for confirmation - - Returns: - True if cleanup succeeded - """ - manager = WorktreeManager(project_dir) - worktrees = manager.list_all_worktrees() - - if not worktrees: - print_status("No worktrees found.", "info") - return True - - print() - print("=" * 70) - print(" CLEANUP ALL WORKTREES") - print("=" * 70) - - content = [ - warning(f"{icon(Icons.WARNING)} THIS WILL DELETE ALL BUILD WORKTREES"), - "", - "The following will be removed:", - ] - for wt in worktrees: - content.append(f" - {wt.spec_name} ({wt.branch})") - - print() - print(box(content, width=70, style="heavy")) - - if confirm: - print() - response = input(" Type 'cleanup' to confirm: ").strip() - if response != "cleanup": - print_status("Cleanup cancelled.", "info") - return False - - manager.cleanup_all() - - print() - print_status("All worktrees cleaned up.", "success") - return True +from types import ModuleType + +# Ensure auto-claude is in sys.path +_auto_claude_dir = Path(__file__).parent +if str(_auto_claude_dir) not in sys.path: + sys.path.insert(0, str(_auto_claude_dir)) + +# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py) +if 'core' not in sys.modules: + _core_module = ModuleType('core') + _core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py') + _core_module.__path__ = [str(_auto_claude_dir / 'core')] + sys.modules['core'] = _core_module + +# Now load core.workspace package directly +_workspace_init = _auto_claude_dir / 'core' / 'workspace' / '__init__.py' +_spec = importlib.util.spec_from_file_location('core.workspace', _workspace_init) +_workspace_module = importlib.util.module_from_spec(_spec) +sys.modules['core.workspace'] = _workspace_module +_spec.loader.exec_module(_workspace_module) + +# Re-export everything from core.workspace +from core.workspace import * # noqa: F401, F403 + +__all__ = _workspace_module.__all__ diff --git a/auto-claude/worktree.py b/auto-claude/worktree.py index 2aaae53b..86205746 100644 --- a/auto-claude/worktree.py +++ b/auto-claude/worktree.py @@ -1,561 +1,42 @@ -#!/usr/bin/env python3 """ -Git Worktree Manager - Per-Spec Architecture -============================================= +Backward compatibility shim - import from core.worktree. -Each spec gets its own worktree: -- Worktree path: .worktrees/{spec-name}/ -- Branch name: auto-claude/{spec-name} +This file exists to maintain backward compatibility for code that imports +from 'worktree' instead of 'core.worktree'. -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 +IMPLEMENTATION: To avoid triggering core/__init__.py (which imports modules +with heavy dependencies like claude_agent_sdk), we: +1. Create a minimal fake 'core' module to satisfy Python's import system +2. Load core.worktree directly using importlib +3. Register it in sys.modules +4. Re-export everything + +This allows 'from worktree import X' to work without requiring all of core's dependencies. """ -import asyncio -import re -import shutil -import subprocess -from dataclasses import dataclass +import sys +import importlib.util from pathlib import Path - - -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: str | None = 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: Path | None = 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 _unstage_gitignored_files(self) -> None: - """ - Unstage any staged files that are gitignored in the current branch. - - This is needed after a --no-commit merge because files that exist in the - source branch (like spec files in auto-claude/specs/) get staged even if - they're gitignored in the target branch. - """ - # Get list of staged files - result = self._run_git(["diff", "--cached", "--name-only"]) - if result.returncode != 0 or not result.stdout.strip(): - return - - staged_files = result.stdout.strip().split("\n") - - # Check which staged files are gitignored - # git check-ignore returns the files that ARE ignored - result = self._run_git(["check-ignore", "--stdin"], cwd=self.project_dir) - # We need to pass the files via stdin - result = subprocess.run( - ["git", "check-ignore", "--stdin"], - cwd=self.project_dir, - input="\n".join(staged_files), - capture_output=True, - text=True, - ) - - if not result.stdout.strip(): - return - - ignored_files = result.stdout.strip().split("\n") - - if ignored_files: - print(f"Unstaging {len(ignored_files)} gitignored file(s)...") - # Unstage each ignored file - for file in ignored_files: - if file.strip(): - self._run_git(["reset", "HEAD", "--", file.strip()]) - - 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) -> WorktreeInfo | None: - """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, no_commit: 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 - no_commit: If True, merge changes but don't commit (stage only for review) - - 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 - - if no_commit: - print( - f"Merging {info.branch} into {self.base_branch} (staged, not committed)..." - ) - else: - 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 - merge_args = ["merge", "--no-ff", info.branch] - if no_commit: - # --no-commit stages the merge but doesn't create the commit - merge_args.append("--no-commit") - else: - merge_args.extend(["-m", f"auto-claude: Merge {info.branch}"]) - - result = self._run_git(merge_args) - - if result.returncode != 0: - print("Merge conflict! Aborting merge...") - self._run_git(["merge", "--abort"]) - return False - - if no_commit: - # Unstage any files that are gitignored in the main branch - # These get staged during merge because they exist in the worktree branch - self._unstage_gitignored_files() - print( - f"Changes from {info.branch} are now staged in your working directory." - ) - print("Review the changes, then commit when ready:") - print(" git commit -m 'your commit message'") - else: - 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(): - 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"]) - - 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 - - # ==================== Backward Compatibility ==================== - # These methods provide backward compatibility with the old single-worktree API - - def get_staging_path(self) -> Path | None: - """ - Backward compatibility: Get path to any existing spec worktree. - Prefer using get_worktree_path(spec_name) instead. - """ - worktrees = self.list_all_worktrees() - if worktrees: - return worktrees[0].path - return None - - def get_staging_info(self) -> WorktreeInfo | None: - """ - Backward compatibility: Get info about any existing spec worktree. - Prefer using get_worktree_info(spec_name) instead. - """ - worktrees = self.list_all_worktrees() - if worktrees: - return worktrees[0] - return None - - def merge_staging(self, delete_after: bool = True) -> bool: - """ - Backward compatibility: Merge first found worktree. - Prefer using merge_worktree(spec_name) instead. - """ - worktrees = self.list_all_worktrees() - if worktrees: - return self.merge_worktree(worktrees[0].spec_name, delete_after) - return False - - def remove_staging(self, delete_branch: bool = True) -> None: - """ - Backward compatibility: Remove first found worktree. - Prefer using remove_worktree(spec_name) instead. - """ - worktrees = self.list_all_worktrees() - if worktrees: - self.remove_worktree(worktrees[0].spec_name, delete_branch) - - def get_or_create_staging(self, spec_name: str) -> WorktreeInfo: - """ - Backward compatibility: Alias for get_or_create_worktree. - """ - return self.get_or_create_worktree(spec_name) - - def staging_exists(self) -> bool: - """ - Backward compatibility: Check if any spec worktree exists. - Prefer using worktree_exists(spec_name) instead. - """ - return len(self.list_all_worktrees()) > 0 - - def commit_in_staging(self, message: str) -> bool: - """ - Backward compatibility: Commit in first found worktree. - Prefer using commit_in_worktree(spec_name, message) instead. - """ - worktrees = self.list_all_worktrees() - if worktrees: - return self.commit_in_worktree(worktrees[0].spec_name, message) - return False - - def has_uncommitted_changes(self, in_staging: bool = False) -> bool: - """Check if there are uncommitted changes.""" - worktrees = self.list_all_worktrees() - if in_staging and worktrees: - cwd = worktrees[0].path - else: - cwd = None - result = self._run_git(["status", "--porcelain"], cwd=cwd) - return bool(result.stdout.strip()) - - -# Keep STAGING_WORKTREE_NAME for backward compatibility in imports -STAGING_WORKTREE_NAME = "auto-claude" +from types import ModuleType + +# Ensure auto-claude is in sys.path +_auto_claude_dir = Path(__file__).parent +if str(_auto_claude_dir) not in sys.path: + sys.path.insert(0, str(_auto_claude_dir)) + +# Create a minimal 'core' module if it doesn't exist (to avoid importing core/__init__.py) +if 'core' not in sys.modules: + _core_module = ModuleType('core') + _core_module.__file__ = str(_auto_claude_dir / 'core' / '__init__.py') + _core_module.__path__ = [str(_auto_claude_dir / 'core')] + sys.modules['core'] = _core_module + +# Now load core.worktree directly +_worktree_file = _auto_claude_dir / 'core' / 'worktree.py' +_spec = importlib.util.spec_from_file_location('core.worktree', _worktree_file) +_worktree_module = importlib.util.module_from_spec(_spec) +sys.modules['core.worktree'] = _worktree_module +_spec.loader.exec_module(_worktree_module) + +# Re-export everything from core.worktree +from core.worktree import * # noqa: F401, F403 diff --git a/tests/conftest.py b/tests/conftest.py index 524caaad..846b7c48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,30 @@ from unittest.mock import MagicMock import pytest +# ============================================================================= +# PRE-MOCK EXTERNAL SDK MODULES - Must happen BEFORE adding auto-claude to path +# ============================================================================= +# These SDK modules may not be installed, so we mock them before any imports +# that might trigger loading code that depends on them. + +def _create_sdk_mock(): + """Create a comprehensive mock for SDK modules.""" + mock = MagicMock() + mock.ClaudeAgentOptions = MagicMock + mock.ClaudeSDKClient = MagicMock + mock.HookMatcher = MagicMock + return mock + +# Pre-mock claude_agent_sdk if not installed +if 'claude_agent_sdk' not in sys.modules: + sys.modules['claude_agent_sdk'] = _create_sdk_mock() + sys.modules['claude_agent_sdk.types'] = MagicMock() + +# Pre-mock claude_code_sdk if not installed +if 'claude_code_sdk' not in sys.modules: + sys.modules['claude_code_sdk'] = _create_sdk_mock() + sys.modules['claude_code_sdk.types'] = MagicMock() + # Add auto-claude directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude")) @@ -85,7 +109,7 @@ def pytest_runtest_setup(item): 'test_qa_report_project_detection': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'}, 'test_qa_report_manual_plan': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'}, 'test_qa_report_config': {'claude_agent_sdk', 'ui', 'progress', 'task_logger', 'linear_updater', 'client'}, - 'test_qa_loop': {'claude_code_sdk', 'claude_code_sdk.types'}, + 'test_qa_loop': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'}, 'test_spec_pipeline': {'claude_code_sdk', 'claude_code_sdk.types', 'init', 'client', 'review', 'task_logger', 'ui', 'validate_spec'}, 'test_spec_complexity': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'claude_agent_sdk.types'}, 'test_spec_phases': {'claude_code_sdk', 'claude_code_sdk.types', 'claude_agent_sdk', 'graphiti_providers', 'validate_spec', 'client'}, @@ -1000,109 +1024,8 @@ except ImportError: # Module will be available when tests run pass -# Sample React component code -SAMPLE_REACT_COMPONENT = '''import React from 'react'; -import { useState } from 'react'; - -function App() { - const [count, setCount] = useState(0); - - return ( -
-

Hello World

- -
- ); -} - -export default App; -''' - -SAMPLE_REACT_WITH_HOOK = '''import React from 'react'; -import { useState } from 'react'; -import { useAuth } from './hooks/useAuth'; - -function App() { - const [count, setCount] = useState(0); - const { user } = useAuth(); - - return ( -
-

Hello World

- -
- ); -} - -export default App; -''' - -# Sample Python module code -SAMPLE_PYTHON_MODULE = '''"""Sample Python module.""" -import os -from pathlib import Path - -def hello(): - """Say hello.""" - print("Hello") - -def goodbye(): - """Say goodbye.""" - print("Goodbye") - -class Greeter: - """A greeter class.""" - - def greet(self, name: str) -> str: - return f"Hello, {name}" -''' - -SAMPLE_PYTHON_WITH_NEW_IMPORT = '''"""Sample Python module.""" -import os -import logging -from pathlib import Path - -def hello(): - """Say hello.""" - print("Hello") - -def goodbye(): - """Say goodbye.""" - print("Goodbye") - -class Greeter: - """A greeter class.""" - - def greet(self, name: str) -> str: - return f"Hello, {name}" -''' - -SAMPLE_PYTHON_WITH_NEW_FUNCTION = '''"""Sample Python module.""" -import os -from pathlib import Path - -def hello(): - """Say hello.""" - print("Hello") - -def goodbye(): - """Say goodbye.""" - print("Goodbye") - -def new_function(): - """A new function.""" - return 42 - -class Greeter: - """A greeter class.""" - - def greet(self, name: str) -> str: - return f"Hello, {name}" -''' +# Sample data constants moved to test_fixtures.py +# Import from there if needed in test files @pytest.fixture @@ -1152,3 +1075,36 @@ def mock_ai_resolver(): code += "return
Merged
;" return code return AIResolver(ai_call_fn=mock_ai_call) + + +@pytest.fixture +def temp_project(temp_git_repo: Path): + """ + Create a temporary project with mixed language files for testing file tracker. + + Creates: + - src/App.tsx (React component) + - src/utils.py (Python module) + """ + from tests.test_fixtures import SAMPLE_REACT_COMPONENT, SAMPLE_PYTHON_MODULE + + # Create src directory + src_dir = temp_git_repo / "src" + src_dir.mkdir(parents=True, exist_ok=True) + + # Create App.tsx + app_tsx = src_dir / "App.tsx" + app_tsx.write_text(SAMPLE_REACT_COMPONENT) + + # Create utils.py + utils_py = src_dir / "utils.py" + utils_py.write_text(SAMPLE_PYTHON_MODULE) + + # Commit the files + subprocess.run(["git", "add", "."], cwd=temp_git_repo, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Add source files"], + cwd=temp_git_repo, capture_output=True + ) + + return temp_git_repo diff --git a/tests/test_agent_architecture.py b/tests/test_agent_architecture.py index 0c081b72..c03ce7d8 100644 --- a/tests/test_agent_architecture.py +++ b/tests/test_agent_architecture.py @@ -168,7 +168,7 @@ class TestModuleIntegrity: def test_no_coordinator_imports(self): """Core modules don't import coordinator.""" - for filename in ["run.py", "agent.py"]: + for filename in ["run.py", "core/agent.py"]: filepath = Path(__file__).parent.parent / "auto-claude" / filename content = filepath.read_text() @@ -181,7 +181,7 @@ class TestModuleIntegrity: def test_no_task_tool_imports(self): """Core modules don't import task_tool.""" - for filename in ["run.py", "agent.py"]: + for filename in ["run.py", "core/agent.py"]: filepath = Path(__file__).parent.parent / "auto-claude" / filename content = filepath.read_text() @@ -305,23 +305,24 @@ class TestSubtaskTerminology: def test_implementation_plan_uses_subtask_class(self): """Implementation plan uses Subtask class.""" - impl_plan_path = Path(__file__).parent.parent / "auto-claude" / "implementation_plan.py" + impl_plan_path = Path(__file__).parent.parent / "auto-claude" / "implementation_plan" / "main.py" content = impl_plan_path.read_text() - assert "class Subtask" in content, ( - "implementation_plan.py should define 'class Subtask'" + # Check that it re-exports or imports Subtask and SubtaskStatus + assert "Subtask" in content, ( + "implementation_plan/main.py should reference 'Subtask'" ) assert "SubtaskStatus" in content, ( - "implementation_plan.py should define SubtaskStatus enum" + "implementation_plan/main.py should reference SubtaskStatus enum" ) def test_progress_uses_subtask_terminology(self): """Progress module uses subtask terminology.""" - progress_path = Path(__file__).parent.parent / "auto-claude" / "progress.py" + progress_path = Path(__file__).parent.parent / "auto-claude" / "core" / "progress.py" content = progress_path.read_text() assert "subtask" in content.lower(), ( - "progress.py should use subtask terminology" + "core/progress.py should use subtask terminology" ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 00000000..70b1dd36 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Test Fixtures - Sample Data Constants +====================================== + +Sample code snippets and data used across multiple test files. +These are separated from conftest.py to allow direct imports. +""" + +# Sample React component code +SAMPLE_REACT_COMPONENT = '''import React from 'react'; +import { useState } from 'react'; + +function App() { + const [count, setCount] = useState(0); + + return ( +
+

Hello World

+ +
+ ); +} + +export default App; +''' + +SAMPLE_REACT_WITH_HOOK = '''import React from 'react'; +import { useState } from 'react'; +import { useAuth } from './hooks/useAuth'; + +function App() { + const [count, setCount] = useState(0); + const { user } = useAuth(); + + return ( +
+

Hello World

+ +
+ ); +} + +export default App; +''' + +# Sample Python module code +SAMPLE_PYTHON_MODULE = '''"""Sample Python module.""" +import os +from pathlib import Path + +def hello(): + """Say hello.""" + print("Hello") + +def goodbye(): + """Say goodbye.""" + print("Goodbye") + +class Greeter: + """A greeter class.""" + + def greet(self, name: str) -> str: + return f"Hello, {name}" +''' + +SAMPLE_PYTHON_WITH_NEW_IMPORT = '''"""Sample Python module.""" +import os +import logging +from pathlib import Path + +def hello(): + """Say hello.""" + print("Hello") + +def goodbye(): + """Say goodbye.""" + print("Goodbye") + +class Greeter: + """A greeter class.""" + + def greet(self, name: str) -> str: + return f"Hello, {name}" +''' + +SAMPLE_PYTHON_WITH_NEW_FUNCTION = '''"""Sample Python module.""" +import os +from pathlib import Path + +def hello(): + """Say hello.""" + print("Hello") + +def goodbye(): + """Say goodbye.""" + print("Goodbye") + +def new_function(): + """A new function.""" + return 42 + +class Greeter: + """A greeter class.""" + + def greet(self, name: str) -> str: + return f"Hello, {name}" +''' diff --git a/tests/test_graphiti.py b/tests/test_graphiti.py index 9a8a81d7..32cf9ed3 100644 --- a/tests/test_graphiti.py +++ b/tests/test_graphiti.py @@ -356,7 +356,7 @@ class TestGraphitiProviders: def test_create_llm_client_missing_openai_key(self): """create_llm_client raises ProviderError when OpenAI key missing.""" - from graphiti_providers import ProviderError + from graphiti_providers import ProviderError, ProviderNotInstalled, create_llm_client with patch.dict(os.environ, { "GRAPHITI_ENABLED": "true", @@ -364,17 +364,18 @@ class TestGraphitiProviders: }, clear=True): config = GraphitiConfig.from_env() - # Only import if graphiti_core is available + # Test raises ProviderError for missing API key, or skip if graphiti-core not installed try: - from graphiti_providers import create_llm_client - with pytest.raises(ProviderError, match="OPENAI_API_KEY"): - create_llm_client(config) - except ImportError: + create_llm_client(config) + pytest.fail("Expected ProviderError to be raised for missing OPENAI_API_KEY") + except ProviderNotInstalled: pytest.skip("graphiti-core not installed") + except ProviderError as e: + assert "OPENAI_API_KEY" in str(e) def test_create_embedder_missing_ollama_model(self): """create_embedder raises ProviderError when Ollama model missing.""" - from graphiti_providers import ProviderError + from graphiti_providers import ProviderError, ProviderNotInstalled, create_embedder with patch.dict(os.environ, { "GRAPHITI_ENABLED": "true", @@ -383,12 +384,14 @@ class TestGraphitiProviders: }, clear=True): config = GraphitiConfig.from_env() + # Test raises ProviderError for missing model config, or skip if graphiti-core not installed try: - from graphiti_providers import create_embedder - with pytest.raises(ProviderError, match="OLLAMA_EMBEDDING_MODEL"): - create_embedder(config) - except ImportError: + create_embedder(config) + pytest.fail("Expected ProviderError to be raised for missing OLLAMA_EMBEDDING_MODEL") + except ProviderNotInstalled: pytest.skip("graphiti-core not installed") + except ProviderError as e: + assert "OLLAMA_EMBEDDING_MODEL" in str(e) def test_embedding_dimensions_lookup(self): """get_expected_embedding_dim returns correct dimensions.""" diff --git a/tests/test_merge_file_tracker.py b/tests/test_merge_file_tracker.py index a5f81101..656fa1a1 100644 --- a/tests/test_merge_file_tracker.py +++ b/tests/test_merge_file_tracker.py @@ -22,8 +22,10 @@ import pytest # Add auto-claude directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude")) +# Add tests directory to path for test_fixtures +sys.path.insert(0, str(Path(__file__).parent)) -from conftest import ( +from test_fixtures import ( SAMPLE_PYTHON_MODULE, SAMPLE_PYTHON_WITH_NEW_FUNCTION, SAMPLE_PYTHON_WITH_NEW_IMPORT, diff --git a/tests/test_merge_orchestrator.py b/tests/test_merge_orchestrator.py index 3f0a9114..b6aca437 100644 --- a/tests/test_merge_orchestrator.py +++ b/tests/test_merge_orchestrator.py @@ -17,14 +17,20 @@ Covers: """ import json +import sys from pathlib import Path import pytest +# Add auto-claude directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude")) +# Add tests directory to path for test_fixtures +sys.path.insert(0, str(Path(__file__).parent)) + from merge import MergeOrchestrator from merge.orchestrator import TaskMergeRequest -from conftest import ( +from test_fixtures import ( SAMPLE_PYTHON_MODULE, SAMPLE_PYTHON_WITH_NEW_FUNCTION, SAMPLE_PYTHON_WITH_NEW_IMPORT, @@ -38,7 +44,8 @@ class TestOrchestratorInitialization: """Orchestrator initializes with all components.""" orchestrator = MergeOrchestrator(temp_project) - assert orchestrator.project_dir == temp_project + # Use resolve() to handle symlinks on macOS (/var vs /private/var) + assert orchestrator.project_dir.resolve() == temp_project.resolve() assert orchestrator.analyzer is not None assert orchestrator.conflict_detector is not None assert orchestrator.auto_merger is not None @@ -112,20 +119,21 @@ class TestSingleTaskMerge: """Integration tests for single task merge.""" def test_full_merge_pipeline_single_task(self, temp_project): - """Full pipeline works for single task merge.""" + """Full pipeline works for single task merge (with git-committed changes).""" + import subprocess + orchestrator = MergeOrchestrator(temp_project, dry_run=True) - # Setup: capture baseline and make changes + # Setup: capture baseline files = [temp_project / "src" / "utils.py"] orchestrator.evolution_tracker.capture_baselines("task-001", files, intent="Add new function") - # Simulate task making changes - orchestrator.evolution_tracker.record_modification( - "task-001", - "src/utils.py", - SAMPLE_PYTHON_MODULE, - SAMPLE_PYTHON_WITH_NEW_FUNCTION, - ) + # Create a task branch with actual git changes (the merge pipeline uses git diff main...HEAD) + subprocess.run(["git", "checkout", "-b", "auto-claude/task-001"], cwd=temp_project, capture_output=True) + utils_file = temp_project / "src" / "utils.py" + utils_file.write_text(SAMPLE_PYTHON_WITH_NEW_FUNCTION) + subprocess.run(["git", "add", "."], cwd=temp_project, capture_output=True) + subprocess.run(["git", "commit", "-m", "Add new function"], cwd=temp_project, capture_output=True) # Execute merge - provide worktree_path to avoid lookup report = orchestrator.merge_task("task-001", worktree_path=temp_project) @@ -133,6 +141,7 @@ class TestSingleTaskMerge: # Verify results assert report.success is True assert "task-001" in report.tasks_merged + # The pipeline should detect and process the modified file assert report.stats.files_processed >= 1 diff --git a/tests/test_merge_parallel.py b/tests/test_merge_parallel.py index 20fb3893..109d3a8d 100644 --- a/tests/test_merge_parallel.py +++ b/tests/test_merge_parallel.py @@ -98,10 +98,10 @@ class TestParallelMergeDataclasses: class TestParallelMergeRunner: """Tests for the parallel merge runner.""" - def test_run_parallel_merges_empty_list(self, temp_project): + def test_run_parallel_merges_empty_list(self, project_dir): """Running with empty task list returns empty results.""" import asyncio - results = asyncio.run(_run_parallel_merges([], temp_project)) + results = asyncio.run(_run_parallel_merges([], project_dir)) assert results == [] def test_parallel_merge_task_with_data(self): diff --git a/tests/test_merge_semantic_analyzer.py b/tests/test_merge_semantic_analyzer.py index 60bf2538..6afc049b 100644 --- a/tests/test_merge_semantic_analyzer.py +++ b/tests/test_merge_semantic_analyzer.py @@ -20,9 +20,11 @@ import pytest # Add auto-claude directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "auto-claude")) +# Add tests directory to path for test_fixtures +sys.path.insert(0, str(Path(__file__).parent)) from merge import ChangeType -from conftest import ( +from test_fixtures import ( SAMPLE_PYTHON_MODULE, SAMPLE_PYTHON_WITH_NEW_IMPORT, SAMPLE_PYTHON_WITH_NEW_FUNCTION, diff --git a/tests/test_qa_loop.py b/tests/test_qa_loop.py index 3c61db68..269aabe9 100644 --- a/tests/test_qa_loop.py +++ b/tests/test_qa_loop.py @@ -21,21 +21,31 @@ _original_modules = {} _mocked_module_names = [ 'claude_code_sdk', 'claude_code_sdk.types', + 'claude_agent_sdk', + 'claude_agent_sdk.types', ] for name in _mocked_module_names: if name in sys.modules: _original_modules[name] = sys.modules[name] -# Mock claude_code_sdk and its submodules before importing qa_loop -# The SDK isn't available in the test environment -mock_sdk = MagicMock() -mock_sdk.ClaudeSDKClient = MagicMock() -mock_sdk.ClaudeCodeOptions = MagicMock() -mock_types = MagicMock() -mock_types.HookMatcher = MagicMock() -sys.modules['claude_code_sdk'] = mock_sdk -sys.modules['claude_code_sdk.types'] = mock_types +# Mock claude_code_sdk and claude_agent_sdk before importing qa_loop +# The SDKs aren't available in the test environment +mock_code_sdk = MagicMock() +mock_code_sdk.ClaudeSDKClient = MagicMock() +mock_code_sdk.ClaudeCodeOptions = MagicMock() +mock_code_types = MagicMock() +mock_code_types.HookMatcher = MagicMock() +sys.modules['claude_code_sdk'] = mock_code_sdk +sys.modules['claude_code_sdk.types'] = mock_code_types + +mock_agent_sdk = MagicMock() +mock_agent_sdk.ClaudeSDKClient = MagicMock() +mock_agent_sdk.ClaudeCodeOptions = MagicMock() +mock_agent_types = MagicMock() +mock_agent_types.HookMatcher = MagicMock() +sys.modules['claude_agent_sdk'] = mock_agent_sdk +sys.modules['claude_agent_sdk.types'] = mock_agent_types from qa_loop import ( load_implementation_plan, diff --git a/tests/test_spec_pipeline.py b/tests/test_spec_pipeline.py index 8b9c603d..7b8d0bd6 100644 --- a/tests/test_spec_pipeline.py +++ b/tests/test_spec_pipeline.py @@ -115,7 +115,7 @@ class TestGetSpecsDir: def test_calls_init_auto_claude_dir(self, temp_dir: Path): """Initializes auto-claude directory.""" - with patch('spec.pipeline.init_auto_claude_dir') as mock_init: + with patch('spec.pipeline.models.init_auto_claude_dir') as mock_init: mock_init.return_value = (temp_dir / ".auto-claude", False) get_specs_dir(temp_dir)