code-context-control 2.38.1__tar.gz → 2.39.0__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.0}/PKG-INFO +1 -1
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/_hook_utils.py +39 -2
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/c3.py +60 -35
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_edit_ledger.py +9 -3
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_edit_unlock.py +9 -1
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_pretool_enforce.py +23 -7
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/delegate.py +26 -20
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/edit.py +65 -18
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/memory.py +4 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/read.py +27 -5
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/validate.py +43 -15
- {code_context_control-2.38.1 → code_context_control-2.39.0/code_context_control.egg-info}/PKG-INFO +1 -1
- {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/SOURCES.txt +3 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/core/mcp_toml.py +29 -2
- {code_context_control-2.38.1 → code_context_control-2.39.0}/core/web_security.py +6 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/mcp_oracle.py +18 -3
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/oracle_server.py +54 -14
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/activity_reporter.py +29 -4
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/c3_bridge.py +29 -2
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/chat_engine.py +6 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/pyproject.toml +1 -1
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/claude_md.py +26 -11
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/compressor.py +5 -1
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/context_snapshot.py +39 -9
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/conversation_store.py +99 -48
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/edit_ledger.py +58 -27
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/file_memory.py +77 -6
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/parser.py +32 -6
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_activity_reporter.py +15 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_claude_md_merge.py +39 -0
- code_context_control-2.39.0/tests/test_edit_ledger_hook.py +88 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_edit_normalization.py +121 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_enforcement_flip.py +67 -2
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_toml.py +39 -0
- code_context_control-2.39.0/tests/test_oracle_security_fixes.py +159 -0
- code_context_control-2.39.0/tests/test_service_durability.py +186 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_web_security.py +31 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/LICENSE +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/README.md +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/common.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/commands/parser.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/docs.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/edits.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/bitbucket.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/getting-started.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/index.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/oracle.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/shared.css +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/tools.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/guide/workflow.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_auto_snapshot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_c3_signal.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_c3read.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_ghost_files.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_read.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_session_stats.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hook_terse_advisor.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hub.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/hub_server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/mcp_proxy.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/mcp_server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/server.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/_helpers.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/agent.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/bitbucket.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/compress.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/edits.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/impact.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/project.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/search.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/session.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/shell.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/tools/status.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/api.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/app.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/bitbucket.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/chat.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/dashboard.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/edits.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/instructions.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/memory.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/sessions.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/settings.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/components/sidebar.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/icons.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/shared.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui/theme.js +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui_legacy.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/cli/ui_nano.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/dependency_links.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/entry_points.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/requires.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/code_context_control.egg-info/top_level.txt +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/core/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/core/config.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/core/ide.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/config.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/oracle.html +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/api_auth.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/chat_store.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/cross_memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/federated_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/health_checker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/insight_engine.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/memory_reader.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/memory_writer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/ollama_bridge.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/project_scanner.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/review_agent.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/tool_executor.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/oracle/services/tool_registry.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/activity_log.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/agent_base.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/agents.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/auto_memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/aider_polyglot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bench/external/swe_bench.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/benchmark_dashboard.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bitbucket_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/bitbucket_credentials.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/doc_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_evaluator.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/e2e_tasks.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/embedding_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/error_reporting.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/git_context.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/hub_service.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/indexer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_consolidator.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_grounder.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/memory_scorer.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/metrics.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/notifications.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/ollama_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/output_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/project_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/project_runtime.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/protocol.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/proxy_state.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/retrieval_broker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/router.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/runtime.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/session_preloader.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/text_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/tool_classifier.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/transcript_index.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/validation_cache.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/vector_store.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/version_tracker.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/services/watcher.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/setup.cfg +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_aider_polyglot.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_cli_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_client.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_credentials.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_bitbucket_tool.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_c3_shell.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_cli_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_e2e_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_federated_graph.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_ghost_files.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_git_branch_awareness.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_hub_server_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_install_mcp_entrypoint.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_lazy_store_init.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_host_guard.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_mcp_server_smoke.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_memory_graph_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_memory_system.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_notification_discipline.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_api_auth.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_apikey_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_oracle_discovery_api.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_output_filter.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_permissions.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_manager.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_manager_merge.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_project_tool.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_read_coercion.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_session_benchmark.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_session_budget.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_shell_robustness.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_swe_bench.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_tool_registry.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_upgrade_and_version.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_validate.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tests/test_windows_reliability.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/backend.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/main.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/__init__.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/benchmark_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/claudemd_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/compress_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/index_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/init_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/mcp_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/optimize_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/pipe_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/projects_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/search_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/session_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/stats.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/screens/ui_view.py +0 -0
- {code_context_control-2.38.1 → code_context_control-2.39.0}/tui/theme.tcss +0 -0
{code_context_control-2.38.1/code_context_control.egg-info → code_context_control-2.39.0}/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.0
|
|
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
|
|
@@ -40,6 +40,7 @@ def _kill_proc_tree(proc):
|
|
|
40
40
|
subprocess.run(
|
|
41
41
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
42
42
|
capture_output=True, stdin=subprocess.DEVNULL,
|
|
43
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
43
44
|
)
|
|
44
45
|
else:
|
|
45
46
|
proc.kill()
|
|
@@ -51,8 +52,10 @@ def _kill_proc_tree(proc):
|
|
|
51
52
|
def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
|
|
52
53
|
"""communicate() replacement with idle-activity watchdog.
|
|
53
54
|
|
|
54
|
-
Monitors stderr for activity. If
|
|
55
|
-
kills the process early (catches MCP startup
|
|
55
|
+
Monitors both stdout and stderr for activity. If neither stream produces
|
|
56
|
+
output for idle_timeout seconds, kills the process early (catches MCP startup
|
|
57
|
+
hangs) without killing a backend that streams its answer only on stdout.
|
|
58
|
+
Also enforces total timeout.
|
|
56
59
|
|
|
57
60
|
Returns (stdout, stderr, status) where status is 'ok', 'timeout', or 'idle_timeout'.
|
|
58
61
|
"""
|
|
@@ -71,7 +74,7 @@ def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
|
|
|
71
74
|
except (ValueError, OSError):
|
|
72
75
|
pass
|
|
73
76
|
|
|
74
|
-
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
|
|
77
|
+
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
|
|
75
78
|
t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
|
|
76
79
|
t_out.start()
|
|
77
80
|
t_err.start()
|
|
@@ -218,10 +221,17 @@ def _run_claude(task: str, context: str, cwd: str | None = None,
|
|
|
218
221
|
cmd,
|
|
219
222
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
220
223
|
stdin=subprocess.DEVNULL,
|
|
221
|
-
text=True, cwd=cwd,
|
|
224
|
+
text=True, encoding="utf-8", errors="replace", cwd=cwd,
|
|
222
225
|
**_popen_kwargs(),
|
|
223
226
|
)
|
|
224
|
-
output, err = _communicate_with_heartbeat(
|
|
227
|
+
output, err, status = _communicate_with_heartbeat(
|
|
228
|
+
proc, timeout=timeout, idle_timeout=idle_timeout,
|
|
229
|
+
)
|
|
230
|
+
if status == "idle_timeout":
|
|
231
|
+
return (f"[claude:idle_timeout] No stderr activity for {idle_timeout}s "
|
|
232
|
+
f"(likely MCP startup hang)"), False
|
|
233
|
+
if status == "timeout":
|
|
234
|
+
return f"[claude:timeout] No response after {timeout}s", False
|
|
225
235
|
if proc.returncode == 0 and output.strip():
|
|
226
236
|
return output.strip(), True
|
|
227
237
|
return f"[claude:error] {(err or '').strip() or 'no output'}", False
|
|
@@ -307,7 +317,7 @@ def _start_gemini_early(model: str, timeout: int = 45, idle_timeout: int = 15,
|
|
|
307
317
|
cmd,
|
|
308
318
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
309
319
|
stdin=subprocess.PIPE,
|
|
310
|
-
text=True,
|
|
320
|
+
text=True, encoding="utf-8", errors="replace",
|
|
311
321
|
cwd=cwd,
|
|
312
322
|
**_popen_kwargs(),
|
|
313
323
|
)
|
|
@@ -344,7 +354,7 @@ def _finish_gemini_early(proc, task: str, context: str,
|
|
|
344
354
|
except (ValueError, OSError):
|
|
345
355
|
pass
|
|
346
356
|
|
|
347
|
-
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
|
|
357
|
+
t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
|
|
348
358
|
t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
|
|
349
359
|
t_out.start()
|
|
350
360
|
t_err.start()
|
|
@@ -448,7 +458,7 @@ def _run_gemini(task: str, context: str, model: str,
|
|
|
448
458
|
cmd,
|
|
449
459
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
450
460
|
stdin=subprocess.DEVNULL,
|
|
451
|
-
text=True,
|
|
461
|
+
text=True, encoding="utf-8", errors="replace",
|
|
452
462
|
cwd=cwd,
|
|
453
463
|
**_popen_kwargs(),
|
|
454
464
|
)
|
|
@@ -563,7 +573,7 @@ def _run_codex(task: str, context: str, model: str, sandbox: str,
|
|
|
563
573
|
cmd,
|
|
564
574
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
565
575
|
stdin=subprocess.DEVNULL,
|
|
566
|
-
text=True,
|
|
576
|
+
text=True, encoding="utf-8", errors="replace",
|
|
567
577
|
cwd=cwd,
|
|
568
578
|
**_popen_kwargs(),
|
|
569
579
|
)
|
|
@@ -592,25 +602,18 @@ def _run_codex_resume(follow_up: str, timeout: int = 120,
|
|
|
592
602
|
"""Resume last Codex session with a follow-up prompt."""
|
|
593
603
|
cmd = ["codex", "exec", "--skip-git-repo-check", "resume", "--last"]
|
|
594
604
|
try:
|
|
595
|
-
import sys
|
|
596
605
|
proc = subprocess.Popen(
|
|
597
606
|
cmd,
|
|
598
607
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
599
608
|
stdin=subprocess.PIPE,
|
|
600
|
-
text=True,
|
|
609
|
+
text=True, encoding="utf-8", errors="replace",
|
|
601
610
|
cwd=cwd,
|
|
611
|
+
**_popen_kwargs(),
|
|
602
612
|
)
|
|
603
613
|
try:
|
|
604
614
|
stdout, stderr = proc.communicate(input=follow_up, timeout=timeout)
|
|
605
615
|
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)
|
|
616
|
+
_kill_proc_tree(proc)
|
|
614
617
|
return f"[codex:timeout] Resume timed out after {timeout}s", False
|
|
615
618
|
|
|
616
619
|
if proc.returncode != 0:
|
|
@@ -1166,7 +1169,10 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
|
|
|
1166
1169
|
model=fallback, system=tdef["system"],
|
|
1167
1170
|
temperature=tdef.get("temperature", 0.3),
|
|
1168
1171
|
max_tokens=int(dcfg.get("max_tokens", 512) or 512),
|
|
1169
|
-
|
|
1172
|
+
timeout=timeout_s)
|
|
1173
|
+
if retry_resp is None:
|
|
1174
|
+
# Timeout/failure on the fallback — not a valid empty answer.
|
|
1175
|
+
continue
|
|
1170
1176
|
retry_conf = _estimate_confidence(task_type, retry_resp, count_tokens(retry_resp))
|
|
1171
1177
|
if retry_conf != "low":
|
|
1172
1178
|
resp = retry_resp
|
|
@@ -25,6 +25,37 @@ def _get_file_lock(path: Path) -> threading.Lock:
|
|
|
25
25
|
return _file_locks[key]
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _read_preserving_newlines(path: Path) -> tuple[str, str]:
|
|
29
|
+
"""Read a file's text and detect its dominant newline style.
|
|
30
|
+
|
|
31
|
+
Returns (content, newline) where `content` has all line endings
|
|
32
|
+
normalized to ``\n`` (so existing replace logic is unchanged) and
|
|
33
|
+
`newline` is the EOL to write back: ``\r\n`` if CRLF dominates the
|
|
34
|
+
file, otherwise ``\n``. This avoids Python's text-mode write rewriting
|
|
35
|
+
every line to ``os.linesep`` on Windows.
|
|
36
|
+
"""
|
|
37
|
+
raw = path.read_bytes()
|
|
38
|
+
crlf = raw.count(b"\r\n")
|
|
39
|
+
lf_only = raw.count(b"\n") - crlf
|
|
40
|
+
newline = "\r\n" if crlf > lf_only else "\n"
|
|
41
|
+
content = raw.decode("utf-8")
|
|
42
|
+
# Normalize to \n internally so replacement matching is EOL-agnostic.
|
|
43
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
44
|
+
return content, newline
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_preserving_newlines(path: Path, content: str, newline: str) -> None:
|
|
48
|
+
"""Write `content` (which uses ``\n``) back using the original EOL style.
|
|
49
|
+
|
|
50
|
+
Uses ``newline=""`` so Python performs no translation; we emit the
|
|
51
|
+
detected EOL explicitly so an LF-only file stays LF-only on Windows.
|
|
52
|
+
"""
|
|
53
|
+
if newline != "\n":
|
|
54
|
+
content = content.replace("\n", newline)
|
|
55
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
56
|
+
fh.write(content)
|
|
57
|
+
|
|
58
|
+
|
|
28
59
|
# Unicode lookalike substitutions used as a fallback when the literal
|
|
29
60
|
# old_string is not found. Strictly 1:1 (same-length) substitutions so
|
|
30
61
|
# positions are preserved — we locate the match on the normalized string
|
|
@@ -131,7 +162,10 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
131
162
|
|
|
132
163
|
try:
|
|
133
164
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
|
|
165
|
+
# newline="" → write content exactly as given; no os.linesep
|
|
166
|
+
# translation, so the caller's line endings are preserved verbatim.
|
|
167
|
+
with open(path, "w", encoding="utf-8", newline="") as fh:
|
|
168
|
+
fh.write(new_string)
|
|
135
169
|
except Exception as e:
|
|
136
170
|
return finalize("c3_edit", {"file": file_path},
|
|
137
171
|
f"Create error: {e}", "create error")
|
|
@@ -161,15 +195,22 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
161
195
|
return finalize("c3_edit", {"file": file_path},
|
|
162
196
|
"edits must be a non-empty JSON list", "bad edits param")
|
|
163
197
|
|
|
198
|
+
if not all(isinstance(p, dict) for p in edit_list):
|
|
199
|
+
return finalize("c3_edit", {"file": file_path},
|
|
200
|
+
"edits must be a JSON list of objects "
|
|
201
|
+
"({old_string, new_string, ...}); a non-object element was found",
|
|
202
|
+
"bad edits param")
|
|
203
|
+
|
|
164
204
|
with file_lock:
|
|
165
205
|
try:
|
|
166
|
-
content = path
|
|
206
|
+
content, _newline = _read_preserving_newlines(path)
|
|
167
207
|
except Exception as e:
|
|
168
208
|
return finalize("c3_edit", {"file": file_path},
|
|
169
209
|
f"Read error: {e}", "read error")
|
|
170
210
|
|
|
171
211
|
results = []
|
|
172
212
|
any_normalized = False
|
|
213
|
+
any_applied = False
|
|
173
214
|
for i, patch in enumerate(edit_list):
|
|
174
215
|
old = patch.get("old_string", "")
|
|
175
216
|
new = patch.get("new_string", "")
|
|
@@ -190,6 +231,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
190
231
|
continue
|
|
191
232
|
|
|
192
233
|
content = new_content
|
|
234
|
+
any_applied = True
|
|
193
235
|
n = count if r_all else 1
|
|
194
236
|
if used_fallback:
|
|
195
237
|
any_normalized = True
|
|
@@ -202,22 +244,27 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
202
244
|
+ (" [norm]" if used_fallback else "")
|
|
203
245
|
+ f" | {desc}")
|
|
204
246
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
247
|
+
# Only touch the file when at least one patch actually changed it —
|
|
248
|
+
# avoids rewriting (and re-EOL-normalizing) an unchanged file and
|
|
249
|
+
# logging a phantom ledger entry when every patch missed.
|
|
250
|
+
if any_applied:
|
|
251
|
+
try:
|
|
252
|
+
_write_preserving_newlines(path, content, _newline)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
return finalize("c3_edit", {"file": file_path},
|
|
255
|
+
f"Write error: {e}", "write error")
|
|
210
256
|
|
|
211
257
|
# Log batch to ledger as one entry (store each patch's old/new for diff view)
|
|
212
|
-
|
|
213
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
if any_applied:
|
|
259
|
+
batch_detail = {"patches": [
|
|
260
|
+
{
|
|
261
|
+
"old_string": p.get("old_string", "")[:_DETAIL_CAP],
|
|
262
|
+
"new_string": p.get("new_string", "")[:_DETAIL_CAP],
|
|
263
|
+
**({"summary": p["summary"]} if p.get("summary") else {}),
|
|
264
|
+
}
|
|
265
|
+
for p in edit_list if p.get("old_string") is not None
|
|
266
|
+
]}
|
|
267
|
+
_log_to_ledger(rel, summary or f"Batch edit: {len(edit_list)} patches", tag_list, svc, detail=batch_detail)
|
|
221
268
|
|
|
222
269
|
applied = sum(1 for r in results if "NOT FOUND" not in r and "AMBIGUOUS" not in r and "skipped" not in r)
|
|
223
270
|
norm_tag = " [unicode-normalized]" if any_normalized else ""
|
|
@@ -234,7 +281,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
234
281
|
|
|
235
282
|
with file_lock:
|
|
236
283
|
try:
|
|
237
|
-
content = path
|
|
284
|
+
content, _newline = _read_preserving_newlines(path)
|
|
238
285
|
except Exception as e:
|
|
239
286
|
return finalize("c3_edit", {"file": file_path},
|
|
240
287
|
f"Read error: {e}", "read error")
|
|
@@ -260,7 +307,7 @@ def handle_edit(file_path: str, old_string: str, new_string: str,
|
|
|
260
307
|
occurrences = count if replace_all else 1
|
|
261
308
|
|
|
262
309
|
try:
|
|
263
|
-
path
|
|
310
|
+
_write_preserving_newlines(path, new_content, _newline)
|
|
264
311
|
except Exception as e:
|
|
265
312
|
return finalize("c3_edit", {"file": file_path},
|
|
266
313
|
f"Write error: {e}", "write error")
|
|
@@ -5,6 +5,10 @@ from datetime import datetime, timezone
|
|
|
5
5
|
def handle_memory(action: str, query: str, fact: str, category: str,
|
|
6
6
|
top_k: int, svc, finalize, fact_id: str = "") -> str:
|
|
7
7
|
if action == "add":
|
|
8
|
+
if not fact or not fact.strip():
|
|
9
|
+
return finalize("c3_memory", {"action": action},
|
|
10
|
+
"fact is required to add a memory (got empty/whitespace)",
|
|
11
|
+
"missing fact")
|
|
8
12
|
sid = (svc.session_mgr.current_session or {}).get("id", "")
|
|
9
13
|
res = svc.memory.remember(fact, category or "general", sid)
|
|
10
14
|
return finalize("c3_memory", {"action": action},
|