code-context-control 2.38.1__tar.gz → 2.39.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {code_context_control-2.38.1/code_context_control.egg-info → code_context_control-2.39.1}/PKG-INFO +1 -1
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/_hook_utils.py +39 -2
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/c3.py +60 -35
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_edit_ledger.py +9 -3
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_edit_unlock.py +9 -1
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_pretool_enforce.py +23 -7
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/delegate.py +106 -22
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/edit.py +65 -18
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/memory.py +4 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/read.py +27 -5
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/validate.py +43 -15
- {code_context_control-2.38.1 → code_context_control-2.39.1/code_context_control.egg-info}/PKG-INFO +1 -1
- {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/SOURCES.txt +5 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/core/mcp_toml.py +29 -2
- {code_context_control-2.38.1 → code_context_control-2.39.1}/core/web_security.py +6 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/mcp_oracle.py +18 -3
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/oracle_server.py +54 -14
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/activity_reporter.py +29 -4
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/c3_bridge.py +29 -2
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/chat_engine.py +6 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/pyproject.toml +1 -1
- code_context_control-2.39.1/services/circuit_breaker.py +86 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/claude_md.py +26 -11
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/compressor.py +5 -1
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/context_snapshot.py +39 -9
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/conversation_store.py +99 -48
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/edit_ledger.py +58 -27
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/file_memory.py +77 -6
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/parser.py +32 -6
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_activity_reporter.py +15 -0
- code_context_control-2.39.1/tests/test_circuit_breaker.py +103 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_claude_md_merge.py +39 -0
- code_context_control-2.39.1/tests/test_edit_ledger_hook.py +88 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_edit_normalization.py +121 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_enforcement_flip.py +67 -2
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_toml.py +39 -0
- code_context_control-2.39.1/tests/test_oracle_security_fixes.py +159 -0
- code_context_control-2.39.1/tests/test_service_durability.py +186 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_web_security.py +31 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/LICENSE +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/README.md +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/common.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/parser.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/docs.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/edits.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/bitbucket.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/getting-started.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/index.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/oracle.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/shared.css +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/tools.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/workflow.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_auto_snapshot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_c3_signal.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_c3read.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_ghost_files.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_read.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_session_stats.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_terse_advisor.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hub.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hub_server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/mcp_proxy.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/mcp_server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/_helpers.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/agent.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/bitbucket.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/compress.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/edits.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/impact.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/project.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/search.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/session.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/shell.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/status.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/api.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/app.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/bitbucket.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/chat.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/dashboard.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/edits.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/instructions.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/memory.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/sessions.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/settings.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/sidebar.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/icons.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/shared.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/theme.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui_legacy.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui_nano.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/dependency_links.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/entry_points.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/requires.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/top_level.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/core/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/core/config.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/core/ide.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/config.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/oracle.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/api_auth.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/chat_store.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/cross_memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/federated_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/health_checker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/insight_engine.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/memory_reader.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/memory_writer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/ollama_bridge.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/project_scanner.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/review_agent.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/tool_executor.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/tool_registry.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/activity_log.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/agent_base.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/agents.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/auto_memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/aider_polyglot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/swe_bench.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/benchmark_dashboard.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bitbucket_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bitbucket_credentials.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/doc_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_evaluator.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_tasks.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/embedding_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/error_reporting.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/git_context.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/hub_service.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/indexer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_consolidator.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_grounder.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_scorer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/metrics.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/notifications.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/ollama_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/output_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/project_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/project_runtime.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/protocol.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/proxy_state.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/retrieval_broker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/router.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/runtime.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_preloader.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/text_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/tool_classifier.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/transcript_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/validation_cache.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/vector_store.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/version_tracker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/services/watcher.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/setup.cfg +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_aider_polyglot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_cli_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_credentials.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_tool.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_c3_shell.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_cli_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_e2e_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_federated_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_ghost_files.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_git_branch_awareness.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_hub_server_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_install_mcp_entrypoint.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_lazy_store_init.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_host_guard.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_server_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_memory_graph_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_memory_system.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_notification_discipline.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_api_auth.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_apikey_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_discovery_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_output_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_permissions.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_manager_merge.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_tool.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_read_coercion.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_session_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_session_budget.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_shell_robustness.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_swe_bench.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_tool_registry.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_upgrade_and_version.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_validate.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_windows_reliability.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/backend.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/main.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/benchmark_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/claudemd_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/compress_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/index_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/init_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/mcp_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/optimize_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/pipe_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/projects_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/search_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/session_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/stats.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/ui_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/theme.tcss +0 -0
{code_context_control-2.38.1/code_context_control.egg-info → code_context_control-2.39.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.39.1
|
|
4
4
|
Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -74,9 +74,46 @@ def get_tool_output(data: dict) -> tuple:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def get_tool_input_path(data: dict) -> str:
|
|
77
|
-
"""Extract file path from tool_input, handling
|
|
77
|
+
"""Extract file path from tool_input, handling Claude (file_path),
|
|
78
|
+
Gemini (path), and NotebookEdit (notebook_path)."""
|
|
78
79
|
tool_input = data.get("tool_input", {})
|
|
79
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
tool_input.get("file_path", "")
|
|
82
|
+
or tool_input.get("path", "")
|
|
83
|
+
or tool_input.get("notebook_path", "")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def record_json_unlocks(editable: list, project_path: Path | None = None) -> None:
|
|
88
|
+
"""Record file paths as read+edit unlocked in .c3/unlocked_files.json.
|
|
89
|
+
|
|
90
|
+
This is the map that hook_pretool_enforce.py actually reads (the plain
|
|
91
|
+
.txt unlock list is not consumed by any hook). Mirrors the behaviour of
|
|
92
|
+
cli/hook_c3read._record_json_unlocks so c3_compress/c3_agent sticky
|
|
93
|
+
unlocks reach the enforcer. Fails silently on I/O errors.
|
|
94
|
+
"""
|
|
95
|
+
base = project_path if project_path is not None else Path.cwd()
|
|
96
|
+
json_path = base / ".c3" / "unlocked_files.json"
|
|
97
|
+
try:
|
|
98
|
+
existing: dict = {}
|
|
99
|
+
if json_path.exists():
|
|
100
|
+
try:
|
|
101
|
+
existing = json.loads(json_path.read_text(encoding="utf-8"))
|
|
102
|
+
if not isinstance(existing, dict):
|
|
103
|
+
existing = {}
|
|
104
|
+
except Exception:
|
|
105
|
+
existing = {}
|
|
106
|
+
for fp in editable:
|
|
107
|
+
if not fp:
|
|
108
|
+
continue
|
|
109
|
+
normalized = str(Path(fp).resolve())
|
|
110
|
+
cats = set(existing.get(normalized, []))
|
|
111
|
+
cats.update({"read", "edit"})
|
|
112
|
+
existing[normalized] = sorted(cats)
|
|
113
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
json_path.write_text(json.dumps(existing), encoding="utf-8")
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
80
117
|
|
|
81
118
|
|
|
82
119
|
def emit_additional_context(text: str, is_gemini: bool) -> None:
|
|
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
|
|
|
85
85
|
# Config
|
|
86
86
|
CONFIG_DIR = ".c3"
|
|
87
87
|
CONFIG_FILE = ".c3/config.json"
|
|
88
|
-
__version__ = "2.
|
|
88
|
+
__version__ = "2.39.0"
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def _command_deps() -> CommandDeps:
|
|
@@ -4084,7 +4084,11 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
|
4084
4084
|
content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
|
|
4085
4085
|
header = f"[{section}]"
|
|
4086
4086
|
|
|
4087
|
-
# Strip existing section (header + its key=value lines)
|
|
4087
|
+
# Strip existing section (header + its key=value lines). Also strip any
|
|
4088
|
+
# dotted child subtables (e.g. "[mcp_servers.c3.env]" under
|
|
4089
|
+
# "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
|
|
4090
|
+
# section, which would corrupt the file on re-run.
|
|
4091
|
+
child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
|
|
4088
4092
|
lines = content.splitlines()
|
|
4089
4093
|
new_lines: list[str] = []
|
|
4090
4094
|
skip = False
|
|
@@ -4094,7 +4098,8 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
|
|
|
4094
4098
|
skip = True
|
|
4095
4099
|
continue
|
|
4096
4100
|
if skip and stripped.startswith("["):
|
|
4097
|
-
|
|
4101
|
+
if not stripped.startswith(child_prefix):
|
|
4102
|
+
skip = False
|
|
4098
4103
|
if not skip:
|
|
4099
4104
|
new_lines.append(line)
|
|
4100
4105
|
|
|
@@ -4577,41 +4582,48 @@ def _ensure_global_claude_md() -> None:
|
|
|
4577
4582
|
|
|
4578
4583
|
existing = global_md.read_text(encoding="utf-8")
|
|
4579
4584
|
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
return
|
|
4585
|
+
# The C3-managed region is delimited by explicit BEGIN/END sentinels (the
|
|
4586
|
+
# same ones used for project instruction docs). This is unambiguous, so
|
|
4587
|
+
# user-written content outside the markers — including H1 headings that
|
|
4588
|
+
# happen to mention "C3" or "Tool Discipline" — is never swallowed.
|
|
4589
|
+
from services.claude_md import C3_BLOCK_BEGIN, C3_BLOCK_END, merge_c3_block
|
|
4586
4590
|
|
|
4587
|
-
|
|
4588
|
-
# Find the C3 section boundaries: starts at the marker, ends at next # heading or EOF
|
|
4589
|
-
start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
|
|
4590
|
-
# Find the next top-level heading after the C3 section
|
|
4591
|
-
rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
|
|
4592
|
-
lines_after = rest.split("\n")
|
|
4593
|
-
end_offset = len(rest) # default: to EOF
|
|
4594
|
-
running = 0
|
|
4595
|
-
for line in lines_after:
|
|
4596
|
-
running += len(line) + 1
|
|
4597
|
-
# A top-level heading that's NOT part of C3's sub-headings
|
|
4598
|
-
if line.startswith("# ") and "C3" not in line and "Tool Discipline" not in line:
|
|
4599
|
-
end_offset = running - len(line) - 1
|
|
4600
|
-
break
|
|
4601
|
-
|
|
4602
|
-
end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
|
|
4603
|
-
before = existing[:start].rstrip()
|
|
4604
|
-
after = existing[end:].lstrip()
|
|
4591
|
+
wrapped = f"{C3_BLOCK_BEGIN}\n{_GLOBAL_CLAUDE_MD_CONTENT.strip()}\n{C3_BLOCK_END}"
|
|
4605
4592
|
|
|
4606
|
-
|
|
4607
|
-
if
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
parts.append(after)
|
|
4593
|
+
# Markers already present → surgical, marker-bounded replacement.
|
|
4594
|
+
if C3_BLOCK_BEGIN in existing:
|
|
4595
|
+
global_md.write_text(merge_c3_block(existing, wrapped), encoding="utf-8")
|
|
4596
|
+
print(f"Updated {global_md} (refreshed C3 enforcement)")
|
|
4597
|
+
return
|
|
4612
4598
|
|
|
4613
|
-
|
|
4614
|
-
|
|
4599
|
+
# Legacy marker-less C3 region → one-time migration into the marked block.
|
|
4600
|
+
# Bound the region from the legacy heading to the NEXT top-level (``# ``)
|
|
4601
|
+
# heading. C3's own content has exactly one H1 (the legacy heading itself),
|
|
4602
|
+
# so the next H1 reliably marks where user content resumes; we deliberately
|
|
4603
|
+
# do NOT skip H1s containing "C3"/"Tool Discipline" (the old heuristic did,
|
|
4604
|
+
# which is what swallowed user headings).
|
|
4605
|
+
if _GLOBAL_CLAUDE_MD_MARKER in existing:
|
|
4606
|
+
start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
|
|
4607
|
+
rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
|
|
4608
|
+
end_offset = len(rest) # default: to EOF
|
|
4609
|
+
running = 0
|
|
4610
|
+
for line in rest.split("\n"):
|
|
4611
|
+
running += len(line) + 1
|
|
4612
|
+
if line.startswith("# "):
|
|
4613
|
+
end_offset = running - len(line) - 1
|
|
4614
|
+
break
|
|
4615
|
+
end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
|
|
4616
|
+
before = existing[:start].rstrip()
|
|
4617
|
+
after = existing[end:].lstrip()
|
|
4618
|
+
parts = [p for p in (before, wrapped, after) if p]
|
|
4619
|
+
global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
|
|
4620
|
+
print(f"Updated {global_md} (migrated C3 enforcement to markers)")
|
|
4621
|
+
return
|
|
4622
|
+
|
|
4623
|
+
# User has their own CLAUDE.md with no C3 content — append the marked block.
|
|
4624
|
+
merged = existing.rstrip() + "\n\n" + wrapped + "\n"
|
|
4625
|
+
global_md.write_text(merged, encoding="utf-8")
|
|
4626
|
+
print(f"Updated {global_md} (appended C3 enforcement)")
|
|
4615
4627
|
|
|
4616
4628
|
|
|
4617
4629
|
def _instruction_documents_for_project() -> list[tuple[str, str]]:
|
|
@@ -5019,6 +5031,8 @@ def cmd_install_mcp(args):
|
|
|
5019
5031
|
glob_matcher = "find_files"
|
|
5020
5032
|
edit_matcher = "edit_file"
|
|
5021
5033
|
write_matcher = "write_file"
|
|
5034
|
+
# Gemini has no MultiEdit / NotebookEdit equivalents.
|
|
5035
|
+
extra_edit_matchers = []
|
|
5022
5036
|
else:
|
|
5023
5037
|
shell_matcher = "Bash"
|
|
5024
5038
|
read_matcher = "Read"
|
|
@@ -5026,6 +5040,9 @@ def cmd_install_mcp(args):
|
|
|
5026
5040
|
glob_matcher = "Glob"
|
|
5027
5041
|
edit_matcher = "Edit"
|
|
5028
5042
|
write_matcher = "Write"
|
|
5043
|
+
# Claude Code also exposes MultiEdit (batch edits) and NotebookEdit;
|
|
5044
|
+
# both bypass enforcement/logging unless their matchers are registered.
|
|
5045
|
+
extra_edit_matchers = ["MultiEdit", "NotebookEdit"]
|
|
5029
5046
|
|
|
5030
5047
|
# ── PostToolUse hooks ──
|
|
5031
5048
|
desired_post_hooks = [
|
|
@@ -5120,6 +5137,10 @@ def cmd_install_mcp(args):
|
|
|
5120
5137
|
"matcher": write_matcher,
|
|
5121
5138
|
"hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
|
|
5122
5139
|
},
|
|
5140
|
+
*[
|
|
5141
|
+
{"matcher": m, "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]}
|
|
5142
|
+
for m in extra_edit_matchers
|
|
5143
|
+
],
|
|
5123
5144
|
]
|
|
5124
5145
|
|
|
5125
5146
|
# ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ──
|
|
@@ -5144,6 +5165,10 @@ def cmd_install_mcp(args):
|
|
|
5144
5165
|
"matcher": write_matcher,
|
|
5145
5166
|
"hooks": [{"type": "command", "command": hook_enforce_cmd}]
|
|
5146
5167
|
},
|
|
5168
|
+
*[
|
|
5169
|
+
{"matcher": m, "hooks": [{"type": "command", "command": hook_enforce_cmd}]}
|
|
5170
|
+
for m in extra_edit_matchers
|
|
5171
|
+
],
|
|
5147
5172
|
]
|
|
5148
5173
|
|
|
5149
5174
|
# Merge: replace existing C3 hooks (so re-running install-mcp updates commands),
|
|
@@ -10,6 +10,7 @@ EditLedgerEnricherAgent running in the MCP server background.
|
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
|
+
import uuid
|
|
13
14
|
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
@@ -25,7 +26,7 @@ EDITABLE_EXTS = {
|
|
|
25
26
|
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
26
27
|
".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
|
|
27
28
|
".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
|
|
28
|
-
".sh", ".bat", ".ps1", ".r",
|
|
29
|
+
".sh", ".bat", ".ps1", ".r", ".ipynb",
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
# How many tail lines to scan for version/seq (avoids full-file parse)
|
|
@@ -95,8 +96,11 @@ def _next_seq(ledger_file: Path, now: datetime) -> int:
|
|
|
95
96
|
continue
|
|
96
97
|
eid = entry.get("id", "")
|
|
97
98
|
if eid.startswith(prefix):
|
|
99
|
+
# ids may carry a random suffix ("..._001_a1b2"); take the leading
|
|
100
|
+
# numeric run after the prefix so same-second seq counting survives.
|
|
101
|
+
seq_part = eid[len(prefix):].split("_", 1)[0]
|
|
98
102
|
try:
|
|
99
|
-
max_seq = max(max_seq, int(
|
|
103
|
+
max_seq = max(max_seq, int(seq_part))
|
|
100
104
|
except ValueError:
|
|
101
105
|
pass
|
|
102
106
|
return max_seq + 1
|
|
@@ -172,7 +176,9 @@ def main():
|
|
|
172
176
|
git_pending = tracking_level != "minimal"
|
|
173
177
|
|
|
174
178
|
entry = {
|
|
175
|
-
|
|
179
|
+
# Random suffix prevents id collisions when the hook process and the
|
|
180
|
+
# server process (services/edit_ledger.py) write within the same second.
|
|
181
|
+
"id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}",
|
|
176
182
|
"timestamp": now.isoformat(),
|
|
177
183
|
"session_id": "",
|
|
178
184
|
"file": rel,
|
|
@@ -14,7 +14,11 @@ from pathlib import Path
|
|
|
14
14
|
|
|
15
15
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
16
16
|
|
|
17
|
-
from cli._hook_utils import
|
|
17
|
+
from cli._hook_utils import ( # noqa: E402
|
|
18
|
+
emit_additional_context,
|
|
19
|
+
log_hook_error,
|
|
20
|
+
record_json_unlocks,
|
|
21
|
+
)
|
|
18
22
|
|
|
19
23
|
EDITABLE_EXTS = {
|
|
20
24
|
".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
|
|
@@ -144,6 +148,10 @@ def main():
|
|
|
144
148
|
except Exception:
|
|
145
149
|
pass
|
|
146
150
|
|
|
151
|
+
# Fix 2: also write the .json unlock map — the .txt list above is read
|
|
152
|
+
# by NO hook; hook_pretool_enforce.py only consults unlocked_files.json.
|
|
153
|
+
record_json_unlocks(editable)
|
|
154
|
+
|
|
147
155
|
# Emit batched nudge with all pending files
|
|
148
156
|
# Prefer c3_edit (no unlock needed). Native Edit is also unlocked via sticky file set.
|
|
149
157
|
if len(pending) == 1:
|
|
@@ -180,24 +180,30 @@ def _is_file_unlocked(project_path: Path, file_path: str, category: str) -> bool
|
|
|
180
180
|
return category in cats or "both" in cats
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def _check_signal_file(project_path: Path) -> tuple[bool, bool]:
|
|
183
|
+
def _check_signal_file(project_path: Path) -> tuple[bool, bool, str]:
|
|
184
184
|
"""Read last_c3_call.json written by hook_c3_signal.py.
|
|
185
185
|
|
|
186
|
-
Returns (recent, read_unlocked):
|
|
186
|
+
Returns (recent, read_unlocked, c3_tool):
|
|
187
187
|
recent: True if a c3_* tool completed within _SIGNAL_MAX_AGE_SECS
|
|
188
188
|
read_unlocked: True if that tool was c3_search/c3_compress/c3_filter
|
|
189
|
+
c3_tool: short name of the c3 tool that wrote the signal (e.g.
|
|
190
|
+
"c3_edit"), or "" if recent is False / unparseable.
|
|
191
|
+
|
|
192
|
+
Fails closed: on any parse error, returns (False, False, "").
|
|
189
193
|
"""
|
|
190
194
|
signal_path = project_path / _SIGNAL_FILE
|
|
191
195
|
if not signal_path.exists():
|
|
192
|
-
return False, False
|
|
196
|
+
return False, False, ""
|
|
193
197
|
try:
|
|
194
198
|
data = json.loads(signal_path.read_text(encoding="utf-8"))
|
|
195
199
|
ts = datetime.fromisoformat(data["timestamp"])
|
|
196
200
|
age = (datetime.now(timezone.utc) - ts).total_seconds()
|
|
197
201
|
recent = age <= _SIGNAL_MAX_AGE_SECS
|
|
198
|
-
|
|
202
|
+
if not recent:
|
|
203
|
+
return False, False, ""
|
|
204
|
+
return True, bool(data.get("read_unlocked", False)), str(data.get("tool", ""))
|
|
199
205
|
except Exception:
|
|
200
|
-
return False, False
|
|
206
|
+
return False, False, ""
|
|
201
207
|
|
|
202
208
|
|
|
203
209
|
def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tuple[bool, str]:
|
|
@@ -223,10 +229,20 @@ def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tupl
|
|
|
223
229
|
required_cat = _TOOL_CATEGORY.get(tool_name, "read")
|
|
224
230
|
|
|
225
231
|
# ── Fix 4: signal file — primary, fast, reliable ─────────────────────────
|
|
226
|
-
signal_recent, signal_read_unlocked = _check_signal_file(project_path)
|
|
232
|
+
signal_recent, signal_read_unlocked, signal_tool = _check_signal_file(project_path)
|
|
227
233
|
if signal_recent:
|
|
234
|
+
# Bypass fix: for write-class tools (Edit/Write/MultiEdit), the signal
|
|
235
|
+
# may only unlock them when the c3 tool that wrote it actually satisfies
|
|
236
|
+
# this tool's prereqs (e.g. c3_edit/c3_edits/c3_agent). A read-class
|
|
237
|
+
# signal (c3_status, c3_search, …) must NOT unlock a native write.
|
|
238
|
+
if tool_name in _BLOCKED_TOOLS:
|
|
239
|
+
if signal_tool in allowed:
|
|
240
|
+
if native_target:
|
|
241
|
+
_record_unlock(project_path, native_target, required_cat)
|
|
242
|
+
return True, "signal"
|
|
243
|
+
# Fresh signal exists but it's not a write-prereq tool — fall through
|
|
228
244
|
# Fix 5: Grep/Glob without file path needs a read-unlocking tool
|
|
229
|
-
|
|
245
|
+
elif not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
|
|
230
246
|
if signal_read_unlocked:
|
|
231
247
|
return True, "signal"
|
|
232
248
|
# Signal exists but not read-unlocking (e.g. c3_memory) — fall through
|
|
@@ -11,10 +11,12 @@ import os
|
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
13
|
import sys
|
|
14
|
+
import threading
|
|
14
15
|
import time
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
17
18
|
from core import count_tokens
|
|
19
|
+
from services.circuit_breaker import CircuitBreaker
|
|
18
20
|
|
|
19
21
|
log = logging.getLogger(__name__)
|
|
20
22
|
|
|
@@ -40,6 +42,7 @@ def _kill_proc_tree(proc):
|
|
|
40
42
|
subprocess.run(
|
|
41
43
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
42
44
|
capture_output=True, stdin=subprocess.DEVNULL,
|
|
45
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
43
46
|
)
|
|
44
47
|
else:
|
|
45
48
|
proc.kill()
|
|
@@ -51,8 +54,10 @@ def _kill_proc_tree(proc):
|
|
|
51
54
|
def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
|
|
52
55
|
"""communicate() replacement with idle-activity watchdog.
|
|
53
56
|
|
|
54
|
-
Monitors stderr for activity. If
|
|
55
|
-
kills the process early (catches MCP startup
|
|
57
|
+
Monitors both stdout and stderr for activity. If neither stream produces
|
|
58
|
+
output for idle_timeout seconds, kills the process early (catches MCP startup
|
|
59
|
+
hangs) without killing a backend that streams its answer only on stdout.
|
|
60
|
+
Also enforces total timeout.
|
|
56
61
|
|
|
57
62
|
Returns (stdout, stderr, status) where status is 'ok', 'timeout', or 'idle_timeout'.
|
|
58
63
|
"""
|
|
@@ -71,7 +76,7 @@ def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
|
|
|
71
76
|
except (ValueError, OSError):
|
|
72
77
|
pass
|
|
73
78
|
|
|
74
|
-
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
|
|
79
|
+
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
|
|
75
80
|
t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
|
|
76
81
|
t_out.start()
|
|
77
82
|
t_err.start()
|
|
@@ -218,10 +223,17 @@ def _run_claude(task: str, context: str, cwd: str | None = None,
|
|
|
218
223
|
cmd,
|
|
219
224
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
220
225
|
stdin=subprocess.DEVNULL,
|
|
221
|
-
text=True, cwd=cwd,
|
|
226
|
+
text=True, encoding="utf-8", errors="replace", cwd=cwd,
|
|
222
227
|
**_popen_kwargs(),
|
|
223
228
|
)
|
|
224
|
-
output, err = _communicate_with_heartbeat(
|
|
229
|
+
output, err, status = _communicate_with_heartbeat(
|
|
230
|
+
proc, timeout=timeout, idle_timeout=idle_timeout,
|
|
231
|
+
)
|
|
232
|
+
if status == "idle_timeout":
|
|
233
|
+
return (f"[claude:idle_timeout] No stderr activity for {idle_timeout}s "
|
|
234
|
+
f"(likely MCP startup hang)"), False
|
|
235
|
+
if status == "timeout":
|
|
236
|
+
return f"[claude:timeout] No response after {timeout}s", False
|
|
225
237
|
if proc.returncode == 0 and output.strip():
|
|
226
238
|
return output.strip(), True
|
|
227
239
|
return f"[claude:error] {(err or '').strip() or 'no output'}", False
|
|
@@ -242,11 +254,20 @@ def _handle_claude_delegate(task: str, task_type: str, context: str,
|
|
|
242
254
|
file_path: str, svc, dcfg: dict, finalize) -> str:
|
|
243
255
|
"""Handle delegation via Claude Code CLI."""
|
|
244
256
|
timeout = int(dcfg.get("claude_timeout", 90))
|
|
257
|
+
breaker = _backend_breaker("claude", dcfg)
|
|
258
|
+
if not breaker.allow():
|
|
259
|
+
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
|
|
260
|
+
"[delegate:degraded] Claude skipped after repeated failures; retrying in "
|
|
261
|
+
f"~{breaker.cooldown_remaining()}s. Run 'claude --version' to diagnose.",
|
|
262
|
+
"degraded")
|
|
245
263
|
_log_progress(svc, f"[delegate] Routing {task_type} → Claude CLI...")
|
|
246
264
|
output, ok = _run_claude(task, context, cwd=str(svc.project_path), timeout=timeout)
|
|
247
265
|
if not ok:
|
|
266
|
+
if breaker.record_failure():
|
|
267
|
+
_notify_backend_degraded(svc, "claude", breaker)
|
|
248
268
|
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
|
|
249
269
|
output, "error")
|
|
270
|
+
breaker.record_success()
|
|
250
271
|
return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
|
|
251
272
|
output, "ok")
|
|
252
273
|
|
|
@@ -307,7 +328,7 @@ def _start_gemini_early(model: str, timeout: int = 45, idle_timeout: int = 15,
|
|
|
307
328
|
cmd,
|
|
308
329
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
309
330
|
stdin=subprocess.PIPE,
|
|
310
|
-
text=True,
|
|
331
|
+
text=True, encoding="utf-8", errors="replace",
|
|
311
332
|
cwd=cwd,
|
|
312
333
|
**_popen_kwargs(),
|
|
313
334
|
)
|
|
@@ -344,7 +365,7 @@ def _finish_gemini_early(proc, task: str, context: str,
|
|
|
344
365
|
except (ValueError, OSError):
|
|
345
366
|
pass
|
|
346
367
|
|
|
347
|
-
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
|
|
368
|
+
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
|
|
348
369
|
t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
|
|
349
370
|
t_out.start()
|
|
350
371
|
t_err.start()
|
|
@@ -448,7 +469,7 @@ def _run_gemini(task: str, context: str, model: str,
|
|
|
448
469
|
cmd,
|
|
449
470
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
450
471
|
stdin=subprocess.DEVNULL,
|
|
451
|
-
text=True,
|
|
472
|
+
text=True, encoding="utf-8", errors="replace",
|
|
452
473
|
cwd=cwd,
|
|
453
474
|
**_popen_kwargs(),
|
|
454
475
|
)
|
|
@@ -563,7 +584,7 @@ def _run_codex(task: str, context: str, model: str, sandbox: str,
|
|
|
563
584
|
cmd,
|
|
564
585
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
565
586
|
stdin=subprocess.DEVNULL,
|
|
566
|
-
text=True,
|
|
587
|
+
text=True, encoding="utf-8", errors="replace",
|
|
567
588
|
cwd=cwd,
|
|
568
589
|
**_popen_kwargs(),
|
|
569
590
|
)
|
|
@@ -592,25 +613,18 @@ def _run_codex_resume(follow_up: str, timeout: int = 120,
|
|
|
592
613
|
"""Resume last Codex session with a follow-up prompt."""
|
|
593
614
|
cmd = ["codex", "exec", "--skip-git-repo-check", "resume", "--last"]
|
|
594
615
|
try:
|
|
595
|
-
import sys
|
|
596
616
|
proc = subprocess.Popen(
|
|
597
617
|
cmd,
|
|
598
618
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
599
619
|
stdin=subprocess.PIPE,
|
|
600
|
-
text=True,
|
|
620
|
+
text=True, encoding="utf-8", errors="replace",
|
|
601
621
|
cwd=cwd,
|
|
622
|
+
**_popen_kwargs(),
|
|
602
623
|
)
|
|
603
624
|
try:
|
|
604
625
|
stdout, stderr = proc.communicate(input=follow_up, timeout=timeout)
|
|
605
626
|
except subprocess.TimeoutExpired:
|
|
606
|
-
|
|
607
|
-
subprocess.run(
|
|
608
|
-
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
609
|
-
capture_output=True, stdin=subprocess.DEVNULL,
|
|
610
|
-
)
|
|
611
|
-
else:
|
|
612
|
-
proc.kill()
|
|
613
|
-
proc.wait(timeout=5)
|
|
627
|
+
_kill_proc_tree(proc)
|
|
614
628
|
return f"[codex:timeout] Resume timed out after {timeout}s", False
|
|
615
629
|
|
|
616
630
|
if proc.returncode != 0:
|
|
@@ -678,6 +692,51 @@ DELEGATE_TASKS = {
|
|
|
678
692
|
_delegate_cache: dict[str, tuple[str, int]] = {}
|
|
679
693
|
_delegate_metrics = {"total_calls": 0, "tokens_saved": 0}
|
|
680
694
|
|
|
695
|
+
# Per-backend runtime circuit breakers. Distinct from the install-status flags
|
|
696
|
+
# (_gemini_available etc., which only answer "is the CLI on PATH"): these track
|
|
697
|
+
# *runtime* health so a broken-but-installed backend (expired auth, repeated
|
|
698
|
+
# timeouts) stops re-spawning a 90-120s subprocess on every call. Keyed by
|
|
699
|
+
# backend name and intentionally process-global — backend health (auth, CLI
|
|
700
|
+
# version) is a property of the host, not of any single project.
|
|
701
|
+
_backend_breakers: dict[str, CircuitBreaker] = {}
|
|
702
|
+
_backend_breakers_lock = threading.Lock()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _backend_breaker(name: str, dcfg: dict | None = None) -> CircuitBreaker:
|
|
706
|
+
"""Return (creating on first use) the runtime circuit breaker for a backend."""
|
|
707
|
+
with _backend_breakers_lock:
|
|
708
|
+
breaker = _backend_breakers.get(name)
|
|
709
|
+
if breaker is None:
|
|
710
|
+
cfg = dcfg or {}
|
|
711
|
+
breaker = CircuitBreaker(
|
|
712
|
+
name,
|
|
713
|
+
failure_threshold=int(cfg.get("breaker_failure_threshold", 3) or 3),
|
|
714
|
+
cooldown_seconds=float(cfg.get("breaker_cooldown_seconds", 60) or 60),
|
|
715
|
+
)
|
|
716
|
+
_backend_breakers[name] = breaker
|
|
717
|
+
return breaker
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _notify_backend_degraded(svc, name: str, breaker: CircuitBreaker) -> None:
|
|
721
|
+
"""Surface a backend trip via the NotificationStore (best-effort, never raises)."""
|
|
722
|
+
notifications = getattr(svc, "notifications", None)
|
|
723
|
+
if notifications is None:
|
|
724
|
+
return
|
|
725
|
+
try:
|
|
726
|
+
notifications.add(
|
|
727
|
+
agent="c3",
|
|
728
|
+
severity="warning",
|
|
729
|
+
title=f"Delegate backend degraded: {name}",
|
|
730
|
+
message=(
|
|
731
|
+
f"{name} failed {breaker.failure_threshold}x consecutively; c3_delegate "
|
|
732
|
+
f"will skip it for ~{int(breaker.cooldown_seconds)}s instead of re-spawning "
|
|
733
|
+
f"the CLI. Run '{name} --version' to diagnose."
|
|
734
|
+
),
|
|
735
|
+
replace_if_unacked=True,
|
|
736
|
+
)
|
|
737
|
+
except Exception:
|
|
738
|
+
pass
|
|
739
|
+
|
|
681
740
|
|
|
682
741
|
def get_delegate_metrics() -> dict:
|
|
683
742
|
return dict(_delegate_metrics)
|
|
@@ -762,6 +821,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
|
|
|
762
821
|
"[delegate:error] Codex CLI not available. Run 'codex --version' to diagnose.",
|
|
763
822
|
"unavailable")
|
|
764
823
|
|
|
824
|
+
breaker = _backend_breaker("codex", dcfg)
|
|
825
|
+
if not breaker.allow():
|
|
826
|
+
return finalize("c3_delegate", {"task_type": task_type, "backend": "codex"},
|
|
827
|
+
"[delegate:degraded] Codex skipped after repeated failures; retrying in "
|
|
828
|
+
f"~{breaker.cooldown_remaining()}s. Run 'codex --version' to diagnose.",
|
|
829
|
+
"degraded")
|
|
830
|
+
|
|
765
831
|
# Resolve model/sandbox/reasoning from config or defaults
|
|
766
832
|
cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
|
|
767
833
|
model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
|
|
@@ -804,10 +870,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
|
|
|
804
870
|
elapsed = round(time.monotonic() - t0, 1)
|
|
805
871
|
|
|
806
872
|
if not ok:
|
|
873
|
+
if breaker.record_failure():
|
|
874
|
+
_notify_backend_degraded(svc, "codex", breaker)
|
|
807
875
|
return finalize("c3_delegate",
|
|
808
876
|
{"task_type": task_type, "backend": "codex", "model": model, "elapsed": f"{elapsed}s"},
|
|
809
877
|
output, "error")
|
|
810
878
|
|
|
879
|
+
breaker.record_success()
|
|
811
880
|
_delegate_metrics["total_calls"] += 1
|
|
812
881
|
_delegate_cache[ckey] = (output, count_tokens(output))
|
|
813
882
|
|
|
@@ -877,6 +946,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
|
|
|
877
946
|
"[delegate:error] Gemini CLI not available. Run 'gemini --version' to diagnose.",
|
|
878
947
|
"unavailable")
|
|
879
948
|
|
|
949
|
+
breaker = _backend_breaker("gemini", dcfg)
|
|
950
|
+
if not breaker.allow():
|
|
951
|
+
return finalize("c3_delegate", {"task_type": task_type, "backend": "gemini"},
|
|
952
|
+
"[delegate:degraded] Gemini skipped after repeated failures; retrying in "
|
|
953
|
+
f"~{breaker.cooldown_remaining()}s. Run 'gemini --version' to diagnose.",
|
|
954
|
+
"degraded")
|
|
955
|
+
|
|
880
956
|
# Resolve model from config or defaults
|
|
881
957
|
gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
|
|
882
958
|
model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
|
|
@@ -916,10 +992,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
|
|
|
916
992
|
elapsed = round(time.monotonic() - t0, 1)
|
|
917
993
|
|
|
918
994
|
if not ok:
|
|
995
|
+
if breaker.record_failure():
|
|
996
|
+
_notify_backend_degraded(svc, "gemini", breaker)
|
|
919
997
|
return finalize("c3_delegate",
|
|
920
998
|
{"task_type": task_type, "backend": "gemini", "model": model, "elapsed": f"{elapsed}s"},
|
|
921
999
|
output, "error")
|
|
922
1000
|
|
|
1001
|
+
breaker.record_success()
|
|
923
1002
|
_delegate_metrics["total_calls"] += 1
|
|
924
1003
|
_delegate_cache[ckey] = (output, count_tokens(output))
|
|
925
1004
|
|
|
@@ -1065,9 +1144,11 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
|
|
|
1065
1144
|
_gemini_avail = (_gemini_available is True) or (
|
|
1066
1145
|
_gemini_available is None and task_type not in _light_tasks and _is_gemini_on_path()
|
|
1067
1146
|
)
|
|
1068
|
-
if task_type in heavy_codex and _codex_avail and _codex_available is not False
|
|
1147
|
+
if (task_type in heavy_codex and _codex_avail and _codex_available is not False
|
|
1148
|
+
and _backend_breaker("codex", dcfg).allow()):
|
|
1069
1149
|
backend = "codex"
|
|
1070
|
-
elif task_type in heavy_gemini and _gemini_avail and _gemini_available is not False
|
|
1150
|
+
elif (task_type in heavy_gemini and _gemini_avail and _gemini_available is not False
|
|
1151
|
+
and _backend_breaker("gemini", dcfg).allow()):
|
|
1071
1152
|
backend = "gemini"
|
|
1072
1153
|
else:
|
|
1073
1154
|
backend = "ollama"
|
|
@@ -1166,7 +1247,10 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
|
|
|
1166
1247
|
model=fallback, system=tdef["system"],
|
|
1167
1248
|
temperature=tdef.get("temperature", 0.3),
|
|
1168
1249
|
max_tokens=int(dcfg.get("max_tokens", 512) or 512),
|
|
1169
|
-
|
|
1250
|
+
timeout=timeout_s)
|
|
1251
|
+
if retry_resp is None:
|
|
1252
|
+
# Timeout/failure on the fallback — not a valid empty answer.
|
|
1253
|
+
continue
|
|
1170
1254
|
retry_conf = _estimate_confidence(task_type, retry_resp, count_tokens(retry_resp))
|
|
1171
1255
|
if retry_conf != "low":
|
|
1172
1256
|
resp = retry_resp
|