alysis-code 0.13.0__py3-none-any.whl
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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
"""IDE bridge support for Forge swarm jobs: events, result payloads, reconcile.
|
|
2
|
+
|
|
3
|
+
Kept separate from ``forge_protocol.py`` on purpose: that module is pinned to
|
|
4
|
+
never reference swarm or subprocess machinery. Everything here is either a
|
|
5
|
+
pure payload builder, a trace-to-protocol event adapter, or an explicitly
|
|
6
|
+
invoked reconcile action over run artifacts.
|
|
7
|
+
|
|
8
|
+
Reconcile is READ-ONLY by default: it enumerates plan state, run artifacts,
|
|
9
|
+
and preserved task worktrees without touching the backend ensure/reuse
|
|
10
|
+
lifecycle. Mutation happens only through the explicit ``harvest`` (write
|
|
11
|
+
per-task diff artifacts under the run) and ``discard`` (drop preserved
|
|
12
|
+
worktrees) actions, both idempotent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import stat
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..forge import RunPaths, find_task, now_iso, save_plan, set_task_status
|
|
25
|
+
from ..git_ops import (
|
|
26
|
+
GitOpsError,
|
|
27
|
+
_run_git_checked,
|
|
28
|
+
branch_exists,
|
|
29
|
+
current_branch,
|
|
30
|
+
untracked_files,
|
|
31
|
+
)
|
|
32
|
+
from ..git_worktrees import prune_worktrees, remove_task_worktree
|
|
33
|
+
from ..swarm_scheduler import canonical_task_status
|
|
34
|
+
from ..swarm_trace import SwarmTraceEvent
|
|
35
|
+
from .forge_protocol import ForgePlanRecord, forge_artifact_root_name
|
|
36
|
+
from .protocol import ProtocolError, redact_secrets
|
|
37
|
+
|
|
38
|
+
SWARM_EVENT_MESSAGE_MAX_CHARS = 300
|
|
39
|
+
SWARM_TASK_EVENT_STATES = (
|
|
40
|
+
"scheduled",
|
|
41
|
+
"started",
|
|
42
|
+
"progress",
|
|
43
|
+
"approval_pending", # reserved placeholder; emitted once approval routing lands
|
|
44
|
+
"interrupted",
|
|
45
|
+
"failed",
|
|
46
|
+
"merged",
|
|
47
|
+
)
|
|
48
|
+
RECONCILE_ACTIONS = ("report", "harvest", "discard")
|
|
49
|
+
_HARVEST_DIR_NAME = "harvest"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def working_tree_diff_against(root: Path, *, base: str) -> str:
|
|
53
|
+
"""Return committed and tracked working-tree changes against ``base``.
|
|
54
|
+
|
|
55
|
+
This IDE-only compatibility helper uses the current hardened git runner;
|
|
56
|
+
untracked files remain a separate, explicit part of the review payload.
|
|
57
|
+
"""
|
|
58
|
+
completed = _run_git_checked(
|
|
59
|
+
root,
|
|
60
|
+
["diff", "--binary", "-M", base, "--"],
|
|
61
|
+
error_message="failed to generate working tree diff",
|
|
62
|
+
timeout_s=5.0,
|
|
63
|
+
)
|
|
64
|
+
return completed.stdout
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def apply_patch_file(root: Path, *, patch_path: Path) -> None:
|
|
68
|
+
"""Apply one reviewed patch after a fail-closed ``git apply --check``."""
|
|
69
|
+
_run_git_checked(
|
|
70
|
+
root,
|
|
71
|
+
["apply", "--check", "--whitespace=nowarn", str(patch_path)],
|
|
72
|
+
error_message="patch does not apply cleanly to the working tree",
|
|
73
|
+
)
|
|
74
|
+
_run_git_checked(
|
|
75
|
+
root,
|
|
76
|
+
["apply", "--whitespace=nowarn", str(patch_path)],
|
|
77
|
+
error_message="failed to apply patch to the working tree",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _remove_readonly_for_rmtree(function: Any, path: str, _exc_info: Any) -> None:
|
|
82
|
+
"""Retry removal after clearing Windows' read-only file attribute."""
|
|
83
|
+
os.chmod(path, stat.S_IWRITE)
|
|
84
|
+
function(path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bounded_redacted(message: str) -> str:
|
|
88
|
+
clean = str(redact_secrets(str(message or "")))
|
|
89
|
+
if len(clean) > SWARM_EVENT_MESSAGE_MAX_CHARS:
|
|
90
|
+
return clean[: SWARM_EVENT_MESSAGE_MAX_CHARS - 3] + "..."
|
|
91
|
+
return clean
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BridgeSwarmTraceSink:
|
|
95
|
+
"""Map engine swarm-trace events onto typed protocol surface events.
|
|
96
|
+
|
|
97
|
+
Task-scoped lifecycle lands on the already-registered
|
|
98
|
+
``swarm_worker_state_changed`` envelope (worker_id = task id, state from
|
|
99
|
+
``SWARM_TASK_EVENT_STATES``); warnings land on ``warning_emitted``. The
|
|
100
|
+
surface records every event into the session's bounded replay ring, so
|
|
101
|
+
reconnecting IDE clients replay swarm progress. Run-level started/
|
|
102
|
+
completed/cancelled events are emitted by the bridge job runner itself.
|
|
103
|
+
|
|
104
|
+
Thread-safety: ``emit`` is called from worker threads and the
|
|
105
|
+
orchestrator thread; the underlying surface serializes writes.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, surface: Any) -> None:
|
|
109
|
+
self._surface = surface
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def emit(self, event: SwarmTraceEvent) -> None:
|
|
115
|
+
task_id = str(event.task_id or "").strip()
|
|
116
|
+
phase = str(event.phase or "")
|
|
117
|
+
message = _bounded_redacted(event.message)
|
|
118
|
+
state: str | None = None
|
|
119
|
+
if task_id:
|
|
120
|
+
if phase == "worktree.lifecycle" and message.startswith("Preparing worktree"):
|
|
121
|
+
state = "scheduled"
|
|
122
|
+
elif phase == "worker.lifecycle" and message.startswith("Worker started"):
|
|
123
|
+
state = "started"
|
|
124
|
+
elif phase == "worker.lifecycle" and message.startswith("Worker interrupted"):
|
|
125
|
+
state = "interrupted"
|
|
126
|
+
elif phase == "worker.lifecycle" and message.startswith("Worker finished"):
|
|
127
|
+
state = "progress"
|
|
128
|
+
elif phase == "worker.error":
|
|
129
|
+
state = "failed"
|
|
130
|
+
elif phase == "merge.lifecycle" and (
|
|
131
|
+
"merged" in message or "applied" in message or "already-satisfied" in message
|
|
132
|
+
):
|
|
133
|
+
state = "merged"
|
|
134
|
+
elif phase in {"scope.violation", "worker.warning"}:
|
|
135
|
+
self._surface.emit_warning(f"[{task_id}] {message}")
|
|
136
|
+
return
|
|
137
|
+
elif phase == "worker.warning":
|
|
138
|
+
self._surface.emit_warning(message)
|
|
139
|
+
return
|
|
140
|
+
if state is not None:
|
|
141
|
+
self._surface.emit_swarm_worker_state_changed(task_id, state, role="forge_swarm")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def swarm_run_summary_payload(paths: RunPaths) -> dict[str, Any]:
|
|
145
|
+
summary_path = paths.execution_dir / "swarm_summary.json"
|
|
146
|
+
if not summary_path.exists():
|
|
147
|
+
return {}
|
|
148
|
+
try:
|
|
149
|
+
payload = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
150
|
+
except (OSError, ValueError):
|
|
151
|
+
return {}
|
|
152
|
+
return payload if isinstance(payload, dict) else {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def forge_swarm_result_payload(
|
|
156
|
+
record: ForgePlanRecord,
|
|
157
|
+
plan: dict[str, Any],
|
|
158
|
+
*,
|
|
159
|
+
exit_code: int,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
summary = swarm_run_summary_payload(record.paths)
|
|
162
|
+
counts: dict[str, int] = {}
|
|
163
|
+
for task in plan.get("tasks") or []:
|
|
164
|
+
if not isinstance(task, dict):
|
|
165
|
+
continue
|
|
166
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
167
|
+
counts[status] = counts.get(status, 0) + 1
|
|
168
|
+
summary_md = record.paths.execution_dir / "swarm_summary.md"
|
|
169
|
+
return {
|
|
170
|
+
"session_id": record.session_id,
|
|
171
|
+
"plan_id": record.plan_id,
|
|
172
|
+
"exit_code": exit_code,
|
|
173
|
+
"run_status": str(summary.get("status") or ("clean" if exit_code == 0 else "unknown")),
|
|
174
|
+
"clean": bool(summary.get("clean", exit_code == 0)),
|
|
175
|
+
"interrupted": bool(summary.get("interrupted", False)),
|
|
176
|
+
"interrupted_task_ids": [str(item) for item in (summary.get("interrupted_task_ids") or [])],
|
|
177
|
+
"verification_status": str(summary.get("verification_status") or "unknown"),
|
|
178
|
+
"reason_codes": [str(item) for item in (summary.get("reason_codes") or [])],
|
|
179
|
+
"task_status_counts": counts,
|
|
180
|
+
"summary_artifact_present": summary_md.exists(),
|
|
181
|
+
"redacted": True,
|
|
182
|
+
"secret_values_included": False,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _task_worktree_path(paths: RunPaths, task_id: str) -> Path:
|
|
187
|
+
return paths.run_dir / "worktrees" / task_id / "repo"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _merge_result_payload(paths: RunPaths, task_id: str) -> dict[str, Any] | None:
|
|
191
|
+
merge_path = paths.execution_dir / "merge_results" / f"{task_id}.json"
|
|
192
|
+
if not merge_path.exists():
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(merge_path.read_text(encoding="utf-8"))
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
return None
|
|
198
|
+
return payload if isinstance(payload, dict) else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _harvest_dir(paths: RunPaths) -> Path:
|
|
202
|
+
return paths.execution_dir / _HARVEST_DIR_NAME
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _classify_task(
|
|
206
|
+
paths: RunPaths,
|
|
207
|
+
task: dict[str, Any],
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
task_id = str(task.get("id") or "").strip()
|
|
210
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
211
|
+
worktree = _task_worktree_path(paths, task_id)
|
|
212
|
+
worktree_present = worktree.is_dir()
|
|
213
|
+
merge_payload = _merge_result_payload(paths, task_id)
|
|
214
|
+
merged = status == "done" or bool(merge_payload and merge_payload.get("success"))
|
|
215
|
+
patch_artifact = paths.execution_patches_dir / f"{task_id}.diff"
|
|
216
|
+
harvest_artifact = _harvest_dir(paths) / f"{task_id}.diff"
|
|
217
|
+
if merged:
|
|
218
|
+
state = "merged"
|
|
219
|
+
elif status == "interrupted":
|
|
220
|
+
state = "interrupted"
|
|
221
|
+
elif status in {"planned", "todo"} and not worktree_present:
|
|
222
|
+
state = "unstarted"
|
|
223
|
+
else:
|
|
224
|
+
state = status
|
|
225
|
+
diff_available = worktree_present or patch_artifact.exists() or harvest_artifact.exists()
|
|
226
|
+
return {
|
|
227
|
+
"task_id": task_id,
|
|
228
|
+
"status": status,
|
|
229
|
+
"state": state,
|
|
230
|
+
"worktree_present": worktree_present,
|
|
231
|
+
"worktree_path": (
|
|
232
|
+
os.fspath(worktree.relative_to(paths.root))
|
|
233
|
+
if worktree_present and worktree.is_relative_to(paths.root)
|
|
234
|
+
else (os.fspath(worktree) if worktree_present else None)
|
|
235
|
+
),
|
|
236
|
+
"diff_available": diff_available,
|
|
237
|
+
"patch_artifact_present": patch_artifact.exists(),
|
|
238
|
+
"harvest_artifact_present": harvest_artifact.exists(),
|
|
239
|
+
"merge_commit_hash": (
|
|
240
|
+
str(merge_payload.get("merge_commit_hash"))
|
|
241
|
+
if merge_payload and merge_payload.get("merge_commit_hash")
|
|
242
|
+
else None
|
|
243
|
+
),
|
|
244
|
+
"branch": str(task.get("branch") or "") or None,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _resolve_reconcile_base_branch(paths: RunPaths, base_branch: str | None) -> str:
|
|
249
|
+
if base_branch:
|
|
250
|
+
return base_branch
|
|
251
|
+
try:
|
|
252
|
+
return current_branch(paths.root)
|
|
253
|
+
except GitOpsError:
|
|
254
|
+
return "main"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _resolve_worktree_base_branch(
|
|
258
|
+
paths: RunPaths,
|
|
259
|
+
worktree: Path,
|
|
260
|
+
base_branch: str | None,
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Pick the diff base that actually exists in the task worktree.
|
|
263
|
+
|
|
264
|
+
Snapshot-backend worktrees carry a synthetic ``snapshot-base`` branch;
|
|
265
|
+
git-worktree backends share branches with the control repo.
|
|
266
|
+
"""
|
|
267
|
+
candidates: list[str] = []
|
|
268
|
+
if base_branch:
|
|
269
|
+
candidates.append(base_branch)
|
|
270
|
+
try:
|
|
271
|
+
candidates.append(current_branch(paths.root))
|
|
272
|
+
except GitOpsError:
|
|
273
|
+
pass
|
|
274
|
+
candidates.extend(["snapshot-base", "main"])
|
|
275
|
+
for candidate in candidates:
|
|
276
|
+
clean = str(candidate or "").strip()
|
|
277
|
+
if not clean:
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
if branch_exists(worktree, clean):
|
|
281
|
+
return clean
|
|
282
|
+
except GitOpsError:
|
|
283
|
+
continue
|
|
284
|
+
return base_branch or "main"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _harvest_task(
|
|
288
|
+
paths: RunPaths,
|
|
289
|
+
entry: dict[str, Any],
|
|
290
|
+
*,
|
|
291
|
+
base_branch: str,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
task_id = str(entry["task_id"])
|
|
294
|
+
worktree = _task_worktree_path(paths, task_id)
|
|
295
|
+
harvest_dir = _harvest_dir(paths)
|
|
296
|
+
harvest_dir.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
diff_path = harvest_dir / f"{task_id}.diff"
|
|
298
|
+
meta_path = harvest_dir / f"{task_id}.json"
|
|
299
|
+
if not worktree.is_dir():
|
|
300
|
+
return {
|
|
301
|
+
"task_id": task_id,
|
|
302
|
+
"harvested": False,
|
|
303
|
+
"reason": "worktree_absent",
|
|
304
|
+
"diff_artifact": (
|
|
305
|
+
os.fspath(diff_path.relative_to(paths.root)) if diff_path.exists() else None
|
|
306
|
+
),
|
|
307
|
+
}
|
|
308
|
+
resolved_base = _resolve_worktree_base_branch(paths, worktree, base_branch)
|
|
309
|
+
try:
|
|
310
|
+
diff_text = working_tree_diff_against(worktree, base=resolved_base)
|
|
311
|
+
untracked = untracked_files(worktree)
|
|
312
|
+
except GitOpsError as exc:
|
|
313
|
+
return {
|
|
314
|
+
"task_id": task_id,
|
|
315
|
+
"harvested": False,
|
|
316
|
+
"reason": f"git_error: {redact_secrets(str(exc))}",
|
|
317
|
+
"diff_artifact": None,
|
|
318
|
+
}
|
|
319
|
+
diff_path.write_text(
|
|
320
|
+
diff_text if diff_text else "(empty diff against base)\n", encoding="utf-8"
|
|
321
|
+
)
|
|
322
|
+
meta_path.write_text(
|
|
323
|
+
json.dumps(
|
|
324
|
+
{
|
|
325
|
+
"task_id": task_id,
|
|
326
|
+
"base_branch": resolved_base,
|
|
327
|
+
"diff_bytes": len(diff_text.encode("utf-8", errors="replace")),
|
|
328
|
+
"untracked_files": sorted(untracked),
|
|
329
|
+
"source_worktree": os.fspath(worktree),
|
|
330
|
+
},
|
|
331
|
+
ensure_ascii=True,
|
|
332
|
+
indent=2,
|
|
333
|
+
sort_keys=True,
|
|
334
|
+
)
|
|
335
|
+
+ "\n",
|
|
336
|
+
encoding="utf-8",
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
"task_id": task_id,
|
|
340
|
+
"harvested": True,
|
|
341
|
+
"diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
|
|
342
|
+
"diff_bytes": len(diff_text.encode("utf-8", errors="replace")),
|
|
343
|
+
"untracked_file_count": len(untracked),
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _discard_task_worktree(paths: RunPaths, entry: dict[str, Any]) -> dict[str, Any]:
|
|
348
|
+
task_id = str(entry["task_id"])
|
|
349
|
+
worktree = _task_worktree_path(paths, task_id)
|
|
350
|
+
if not worktree.is_dir():
|
|
351
|
+
return {"task_id": task_id, "discarded": False, "reason": "already_absent"}
|
|
352
|
+
try:
|
|
353
|
+
remove_task_worktree(root=paths.root, worktree_repo_path=worktree, force=True)
|
|
354
|
+
except GitOpsError:
|
|
355
|
+
# Snapshot-backend workspaces and synthetic dirs are not registered
|
|
356
|
+
# git worktrees; fall back to a contained recursive delete strictly
|
|
357
|
+
# under the run's worktrees directory.
|
|
358
|
+
resolved = worktree.resolve()
|
|
359
|
+
worktrees_root = (paths.run_dir / "worktrees").resolve()
|
|
360
|
+
if not resolved.is_relative_to(worktrees_root):
|
|
361
|
+
return {
|
|
362
|
+
"task_id": task_id,
|
|
363
|
+
"discarded": False,
|
|
364
|
+
"reason": "refused_outside_run_worktrees_dir",
|
|
365
|
+
}
|
|
366
|
+
try:
|
|
367
|
+
shutil.rmtree(resolved, onerror=_remove_readonly_for_rmtree)
|
|
368
|
+
except OSError as exc:
|
|
369
|
+
return {
|
|
370
|
+
"task_id": task_id,
|
|
371
|
+
"discarded": False,
|
|
372
|
+
"reason": "worktree_delete_failed",
|
|
373
|
+
"error": str(redact_secrets(str(exc))),
|
|
374
|
+
}
|
|
375
|
+
if resolved.exists():
|
|
376
|
+
return {
|
|
377
|
+
"task_id": task_id,
|
|
378
|
+
"discarded": False,
|
|
379
|
+
"reason": "worktree_delete_incomplete",
|
|
380
|
+
}
|
|
381
|
+
try:
|
|
382
|
+
prune_worktrees(paths.root)
|
|
383
|
+
except GitOpsError:
|
|
384
|
+
pass
|
|
385
|
+
parent = worktree.parent
|
|
386
|
+
if parent.is_dir() and not any(parent.iterdir()):
|
|
387
|
+
parent.rmdir()
|
|
388
|
+
return {"task_id": task_id, "discarded": True}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def forge_swarm_reconcile_result(
|
|
392
|
+
record: ForgePlanRecord,
|
|
393
|
+
plan: dict[str, Any],
|
|
394
|
+
*,
|
|
395
|
+
action: str = "report",
|
|
396
|
+
task_ids: list[str] | None = None,
|
|
397
|
+
base_branch: str | None = None,
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
clean_action = str(action or "report").strip().lower()
|
|
400
|
+
if clean_action not in RECONCILE_ACTIONS:
|
|
401
|
+
raise ProtocolError(
|
|
402
|
+
"invalid_reconcile_action",
|
|
403
|
+
"forge.swarm.reconcile action must be report, harvest, or discard.",
|
|
404
|
+
)
|
|
405
|
+
paths = record.paths
|
|
406
|
+
wanted = {str(item).strip() for item in (task_ids or []) if str(item).strip()} or None
|
|
407
|
+
if wanted is not None:
|
|
408
|
+
for task_id in sorted(wanted):
|
|
409
|
+
if find_task(plan, task_id) is None:
|
|
410
|
+
raise ProtocolError("task_not_found", f"Unknown task id for reconcile: {task_id}")
|
|
411
|
+
entries = [
|
|
412
|
+
_classify_task(paths, task)
|
|
413
|
+
for task in (plan.get("tasks") or [])
|
|
414
|
+
if isinstance(task, dict)
|
|
415
|
+
and str(task.get("id") or "").strip()
|
|
416
|
+
and (wanted is None or str(task.get("id") or "").strip() in wanted)
|
|
417
|
+
]
|
|
418
|
+
actions: list[dict[str, Any]] = []
|
|
419
|
+
if clean_action == "harvest":
|
|
420
|
+
resolved_base = _resolve_reconcile_base_branch(paths, base_branch)
|
|
421
|
+
actions = [
|
|
422
|
+
_harvest_task(paths, entry, base_branch=resolved_base)
|
|
423
|
+
for entry in entries
|
|
424
|
+
if entry["worktree_present"]
|
|
425
|
+
]
|
|
426
|
+
# Refresh classification so harvest artifacts show in the report.
|
|
427
|
+
entries = [
|
|
428
|
+
_classify_task(paths, task)
|
|
429
|
+
for task in (plan.get("tasks") or [])
|
|
430
|
+
if isinstance(task, dict)
|
|
431
|
+
and str(task.get("id") or "").strip()
|
|
432
|
+
and (wanted is None or str(task.get("id") or "").strip() in wanted)
|
|
433
|
+
]
|
|
434
|
+
elif clean_action == "discard":
|
|
435
|
+
actions = [
|
|
436
|
+
_discard_task_worktree(paths, entry) for entry in entries if entry["worktree_present"]
|
|
437
|
+
]
|
|
438
|
+
entries = [
|
|
439
|
+
_classify_task(paths, task)
|
|
440
|
+
for task in (plan.get("tasks") or [])
|
|
441
|
+
if isinstance(task, dict)
|
|
442
|
+
and str(task.get("id") or "").strip()
|
|
443
|
+
and (wanted is None or str(task.get("id") or "").strip() in wanted)
|
|
444
|
+
]
|
|
445
|
+
counts: dict[str, int] = {}
|
|
446
|
+
for entry in entries:
|
|
447
|
+
counts[str(entry["state"])] = counts.get(str(entry["state"]), 0) + 1
|
|
448
|
+
return {
|
|
449
|
+
"session_id": record.session_id,
|
|
450
|
+
"plan_id": record.plan_id,
|
|
451
|
+
"action": clean_action,
|
|
452
|
+
"tasks": entries,
|
|
453
|
+
"state_counts": counts,
|
|
454
|
+
"actions": actions,
|
|
455
|
+
"read_only": clean_action == "report",
|
|
456
|
+
"idempotent": True,
|
|
457
|
+
"redacted": True,
|
|
458
|
+
"secret_values_included": False,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_REVIEW_APPLY_DIR_NAME = "review_apply"
|
|
463
|
+
_REVIEW_DISCARD_DIR_NAME = "review_discard"
|
|
464
|
+
_EMPTY_DIFF_SENTINEL = "(empty diff against base)"
|
|
465
|
+
_REVIEW_RECOVERY_STATES = frozenset(
|
|
466
|
+
{"failed", "verify_failed", "candidate_rejected", "interrupted"}
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _review_marker_path(paths: RunPaths, dir_name: str, task_id: str) -> Path:
|
|
471
|
+
return paths.execution_dir / dir_name / f"{task_id}.json"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _read_json_marker(path: Path) -> dict[str, Any] | None:
|
|
475
|
+
if not path.exists():
|
|
476
|
+
return None
|
|
477
|
+
try:
|
|
478
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
479
|
+
except (OSError, ValueError):
|
|
480
|
+
return None
|
|
481
|
+
return payload if isinstance(payload, dict) else None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _write_json_marker(path: Path, payload: dict[str, Any]) -> None:
|
|
485
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
486
|
+
path.write_text(
|
|
487
|
+
json.dumps(payload, ensure_ascii=True, indent=2, sort_keys=True) + "\n",
|
|
488
|
+
encoding="utf-8",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _harvest_meta(paths: RunPaths, task_id: str) -> dict[str, Any] | None:
|
|
493
|
+
return _read_json_marker(_harvest_dir(paths) / f"{task_id}.json")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _untracked_note(untracked: list[str]) -> str | None:
|
|
497
|
+
if not untracked:
|
|
498
|
+
return None
|
|
499
|
+
preview = ", ".join(untracked[:10])
|
|
500
|
+
if len(untracked) > 10:
|
|
501
|
+
preview += ", ..."
|
|
502
|
+
return f"untracked files created: {preview}"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def harvest_ready_review_diffs(
|
|
506
|
+
record: ForgePlanRecord,
|
|
507
|
+
plan: dict[str, Any],
|
|
508
|
+
*,
|
|
509
|
+
base_branch: str | None = None,
|
|
510
|
+
) -> list[dict[str, Any]]:
|
|
511
|
+
"""Produce per-task review diffs for every ready_for_merge task worktree."""
|
|
512
|
+
paths = record.paths
|
|
513
|
+
harvested: list[dict[str, Any]] = []
|
|
514
|
+
for task in plan.get("tasks") or []:
|
|
515
|
+
if not isinstance(task, dict):
|
|
516
|
+
continue
|
|
517
|
+
if canonical_task_status(str(task.get("status") or "")) != "ready_for_merge":
|
|
518
|
+
continue
|
|
519
|
+
entry = _classify_task(paths, task)
|
|
520
|
+
if not entry["worktree_present"]:
|
|
521
|
+
continue
|
|
522
|
+
harvested.append(_harvest_task(paths, entry, base_branch=base_branch or ""))
|
|
523
|
+
return harvested
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _recovery_offer(task: dict[str, Any], state: str) -> dict[str, Any] | None:
|
|
527
|
+
if state not in _REVIEW_RECOVERY_STATES:
|
|
528
|
+
return None
|
|
529
|
+
task_id = str(task.get("id") or "")
|
|
530
|
+
title = str(redact_secrets(str(task.get("title") or ""))).strip()
|
|
531
|
+
scope = [
|
|
532
|
+
str(item).strip()
|
|
533
|
+
for item in (task.get("write_scope") or task.get("estimated_files") or [])
|
|
534
|
+
if str(item).strip()
|
|
535
|
+
]
|
|
536
|
+
offer: dict[str, Any] = {
|
|
537
|
+
"kind": "regenerate_subtree",
|
|
538
|
+
"start_method": "forge.plan.regenerate.start",
|
|
539
|
+
"suggested_params": {
|
|
540
|
+
"instruction": str(
|
|
541
|
+
redact_secrets(
|
|
542
|
+
f"Regenerate the plan subtree for task {task_id}"
|
|
543
|
+
+ (f" ({title})" if title else "")
|
|
544
|
+
+ f"; the previous attempt ended {state}."
|
|
545
|
+
)
|
|
546
|
+
),
|
|
547
|
+
"focus": str(redact_secrets(", ".join(scope[:5]))) or None,
|
|
548
|
+
},
|
|
549
|
+
}
|
|
550
|
+
if state == "interrupted":
|
|
551
|
+
offer["harvest_method"] = "forge.swarm.reconcile"
|
|
552
|
+
return offer
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def forge_swarm_review_result(
|
|
556
|
+
record: ForgePlanRecord,
|
|
557
|
+
plan: dict[str, Any],
|
|
558
|
+
) -> dict[str, Any]:
|
|
559
|
+
"""Read-only per-task review listing for the review merge strategy."""
|
|
560
|
+
paths = record.paths
|
|
561
|
+
root_name = forge_artifact_root_name(record.plan_id)
|
|
562
|
+
items: list[dict[str, Any]] = []
|
|
563
|
+
for task in plan.get("tasks") or []:
|
|
564
|
+
if not isinstance(task, dict):
|
|
565
|
+
continue
|
|
566
|
+
task_id = str(task.get("id") or "").strip()
|
|
567
|
+
if not task_id:
|
|
568
|
+
continue
|
|
569
|
+
entry = _classify_task(paths, task)
|
|
570
|
+
applied_marker = _read_json_marker(
|
|
571
|
+
_review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)
|
|
572
|
+
)
|
|
573
|
+
discarded_marker = _read_json_marker(
|
|
574
|
+
_review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)
|
|
575
|
+
)
|
|
576
|
+
state = str(entry["state"])
|
|
577
|
+
if applied_marker is not None:
|
|
578
|
+
state = "applied"
|
|
579
|
+
elif discarded_marker is not None:
|
|
580
|
+
state = "discarded"
|
|
581
|
+
meta = _harvest_meta(paths, task_id)
|
|
582
|
+
untracked = [str(item) for item in ((meta or {}).get("untracked_files") or [])]
|
|
583
|
+
diff_path = _harvest_dir(paths) / f"{task_id}.diff"
|
|
584
|
+
items.append(
|
|
585
|
+
{
|
|
586
|
+
"task_id": task_id,
|
|
587
|
+
"title": str(redact_secrets(str(task.get("title") or ""))),
|
|
588
|
+
"status": str(entry["status"]),
|
|
589
|
+
"state": state,
|
|
590
|
+
"reviewable": state == "ready_for_merge",
|
|
591
|
+
"diff_available": bool(entry["diff_available"]),
|
|
592
|
+
"diff_artifact_id": (
|
|
593
|
+
f"{root_name}:execution/{_HARVEST_DIR_NAME}/{task_id}.diff"
|
|
594
|
+
if diff_path.exists()
|
|
595
|
+
else None
|
|
596
|
+
),
|
|
597
|
+
"untracked_files": untracked,
|
|
598
|
+
"untracked_files_note": _untracked_note(untracked),
|
|
599
|
+
"worktree_present": bool(entry["worktree_present"]),
|
|
600
|
+
"applied": applied_marker is not None,
|
|
601
|
+
"discarded": discarded_marker is not None,
|
|
602
|
+
"recovery": _recovery_offer(task, state),
|
|
603
|
+
}
|
|
604
|
+
)
|
|
605
|
+
counts: dict[str, int] = {}
|
|
606
|
+
for item in items:
|
|
607
|
+
counts[str(item["state"])] = counts.get(str(item["state"]), 0) + 1
|
|
608
|
+
return {
|
|
609
|
+
"session_id": record.session_id,
|
|
610
|
+
"plan_id": record.plan_id,
|
|
611
|
+
"items": items,
|
|
612
|
+
"state_counts": counts,
|
|
613
|
+
"pending_review_task_ids": [item["task_id"] for item in items if item["reviewable"]],
|
|
614
|
+
"apply_method": "forge.swarm.apply",
|
|
615
|
+
"discard_method": "forge.swarm.discard",
|
|
616
|
+
"working_tree_untouched_until_apply": True,
|
|
617
|
+
"redacted": True,
|
|
618
|
+
"secret_values_included": False,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _ordered_requested_tasks(
|
|
623
|
+
plan: dict[str, Any],
|
|
624
|
+
task_ids: list[str],
|
|
625
|
+
) -> list[dict[str, Any]]:
|
|
626
|
+
requested = {str(item).strip() for item in task_ids if str(item).strip()}
|
|
627
|
+
for task_id in sorted(requested):
|
|
628
|
+
if find_task(plan, task_id) is None:
|
|
629
|
+
raise ProtocolError("task_not_found", f"Unknown task id: {task_id}")
|
|
630
|
+
ordered = [
|
|
631
|
+
task
|
|
632
|
+
for task in (plan.get("tasks") or [])
|
|
633
|
+
if isinstance(task, dict) and str(task.get("id") or "").strip() in requested
|
|
634
|
+
]
|
|
635
|
+
return ordered
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def forge_swarm_apply_result(
|
|
639
|
+
record: ForgePlanRecord,
|
|
640
|
+
plan: dict[str, Any],
|
|
641
|
+
*,
|
|
642
|
+
task_ids: list[str],
|
|
643
|
+
base_branch: str | None = None,
|
|
644
|
+
) -> dict[str, Any]:
|
|
645
|
+
"""Apply per-task review diffs to the canonical working tree.
|
|
646
|
+
|
|
647
|
+
Applies are sequential in plan order, per-task atomic (``git apply
|
|
648
|
+
--check`` first), and never stage or commit anything. Untracked files
|
|
649
|
+
created by the worker are NOT applied (no contents in the diff) and are
|
|
650
|
+
surfaced prominently per task so nothing lands silently.
|
|
651
|
+
"""
|
|
652
|
+
paths = record.paths
|
|
653
|
+
ordered = _ordered_requested_tasks(plan, task_ids)
|
|
654
|
+
# Pre-validate everything before mutating anything.
|
|
655
|
+
for task in ordered:
|
|
656
|
+
task_id = str(task.get("id") or "").strip()
|
|
657
|
+
if _read_json_marker(_review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)):
|
|
658
|
+
continue
|
|
659
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
660
|
+
if status != "ready_for_merge":
|
|
661
|
+
raise ProtocolError(
|
|
662
|
+
"task_not_reviewable",
|
|
663
|
+
f"Task {task_id} is not awaiting review (status {status}).",
|
|
664
|
+
)
|
|
665
|
+
applied: list[dict[str, Any]] = []
|
|
666
|
+
for task in ordered:
|
|
667
|
+
task_id = str(task.get("id") or "").strip()
|
|
668
|
+
marker_path = _review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)
|
|
669
|
+
existing_marker = _read_json_marker(marker_path)
|
|
670
|
+
if existing_marker is not None:
|
|
671
|
+
untracked = [
|
|
672
|
+
str(item) for item in (existing_marker.get("untracked_files_not_applied") or [])
|
|
673
|
+
]
|
|
674
|
+
applied.append(
|
|
675
|
+
{
|
|
676
|
+
"task_id": task_id,
|
|
677
|
+
"applied": True,
|
|
678
|
+
"already_applied": True,
|
|
679
|
+
"untracked_files_not_applied": untracked,
|
|
680
|
+
"untracked_files_note": _untracked_note(untracked),
|
|
681
|
+
}
|
|
682
|
+
)
|
|
683
|
+
continue
|
|
684
|
+
entry = _classify_task(paths, task)
|
|
685
|
+
diff_path = _harvest_dir(paths) / f"{task_id}.diff"
|
|
686
|
+
if not diff_path.exists():
|
|
687
|
+
if entry["worktree_present"]:
|
|
688
|
+
_harvest_task(paths, entry, base_branch=base_branch or "")
|
|
689
|
+
if not diff_path.exists():
|
|
690
|
+
raise ProtocolError(
|
|
691
|
+
"diff_unavailable",
|
|
692
|
+
f"No review diff is available for task {task_id}; "
|
|
693
|
+
"the task worktree is gone and nothing was harvested.",
|
|
694
|
+
)
|
|
695
|
+
diff_text = diff_path.read_text(encoding="utf-8")
|
|
696
|
+
meta = _harvest_meta(paths, task_id) or {}
|
|
697
|
+
untracked = [str(item) for item in (meta.get("untracked_files") or [])]
|
|
698
|
+
nothing_to_apply = diff_text.strip() in {"", _EMPTY_DIFF_SENTINEL}
|
|
699
|
+
if not nothing_to_apply:
|
|
700
|
+
try:
|
|
701
|
+
apply_patch_file(paths.root, patch_path=diff_path)
|
|
702
|
+
except GitOpsError as exc:
|
|
703
|
+
raise ProtocolError(
|
|
704
|
+
"apply_failed",
|
|
705
|
+
str(
|
|
706
|
+
redact_secrets(
|
|
707
|
+
f"Applying task {task_id} diff to the working tree failed: {exc}. "
|
|
708
|
+
f"Already applied this call: "
|
|
709
|
+
f"{', '.join(str(item['task_id']) for item in applied) or '(none)'}."
|
|
710
|
+
)
|
|
711
|
+
),
|
|
712
|
+
) from exc
|
|
713
|
+
_write_json_marker(
|
|
714
|
+
marker_path,
|
|
715
|
+
{
|
|
716
|
+
"task_id": task_id,
|
|
717
|
+
"applied_at": now_iso(),
|
|
718
|
+
"nothing_to_apply": nothing_to_apply,
|
|
719
|
+
"diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
|
|
720
|
+
"untracked_files_not_applied": untracked,
|
|
721
|
+
},
|
|
722
|
+
)
|
|
723
|
+
set_task_status(plan, task_id, "done")
|
|
724
|
+
save_plan(paths, plan)
|
|
725
|
+
applied.append(
|
|
726
|
+
{
|
|
727
|
+
"task_id": task_id,
|
|
728
|
+
"applied": True,
|
|
729
|
+
"already_applied": False,
|
|
730
|
+
"nothing_to_apply": nothing_to_apply,
|
|
731
|
+
"diff_artifact": os.fspath(diff_path.relative_to(paths.root)),
|
|
732
|
+
"untracked_files_not_applied": untracked,
|
|
733
|
+
"untracked_files_note": _untracked_note(untracked),
|
|
734
|
+
}
|
|
735
|
+
)
|
|
736
|
+
return {
|
|
737
|
+
"session_id": record.session_id,
|
|
738
|
+
"plan_id": record.plan_id,
|
|
739
|
+
"applied": applied,
|
|
740
|
+
"working_tree_committed": False,
|
|
741
|
+
"redacted": True,
|
|
742
|
+
"secret_values_included": False,
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def forge_swarm_discard_result(
|
|
747
|
+
record: ForgePlanRecord,
|
|
748
|
+
plan: dict[str, Any],
|
|
749
|
+
*,
|
|
750
|
+
task_ids: list[str],
|
|
751
|
+
) -> dict[str, Any]:
|
|
752
|
+
"""Discard reviewed task work: drop the preserved worktree, keep artifacts."""
|
|
753
|
+
paths = record.paths
|
|
754
|
+
ordered = _ordered_requested_tasks(plan, task_ids)
|
|
755
|
+
discardable_statuses = {
|
|
756
|
+
"ready_for_merge",
|
|
757
|
+
"interrupted",
|
|
758
|
+
"failed",
|
|
759
|
+
"verify_failed",
|
|
760
|
+
"candidate_rejected",
|
|
761
|
+
}
|
|
762
|
+
for task in ordered:
|
|
763
|
+
task_id = str(task.get("id") or "").strip()
|
|
764
|
+
if _read_json_marker(_review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)):
|
|
765
|
+
continue
|
|
766
|
+
if _read_json_marker(_review_marker_path(paths, _REVIEW_APPLY_DIR_NAME, task_id)):
|
|
767
|
+
raise ProtocolError(
|
|
768
|
+
"task_already_applied",
|
|
769
|
+
f"Task {task_id} was already applied to the working tree; "
|
|
770
|
+
"revert it through source control instead of discarding.",
|
|
771
|
+
)
|
|
772
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
773
|
+
if status not in discardable_statuses:
|
|
774
|
+
raise ProtocolError(
|
|
775
|
+
"task_not_reviewable",
|
|
776
|
+
f"Task {task_id} cannot be discarded (status {status}).",
|
|
777
|
+
)
|
|
778
|
+
discarded: list[dict[str, Any]] = []
|
|
779
|
+
for task in ordered:
|
|
780
|
+
task_id = str(task.get("id") or "").strip()
|
|
781
|
+
marker_path = _review_marker_path(paths, _REVIEW_DISCARD_DIR_NAME, task_id)
|
|
782
|
+
if _read_json_marker(marker_path) is not None:
|
|
783
|
+
discarded.append({"task_id": task_id, "discarded": True, "already_discarded": True})
|
|
784
|
+
continue
|
|
785
|
+
entry = _classify_task(paths, task)
|
|
786
|
+
worktree_result: dict[str, Any] = {"discarded": False, "reason": "already_absent"}
|
|
787
|
+
if entry["worktree_present"]:
|
|
788
|
+
worktree_result = _discard_task_worktree(paths, entry)
|
|
789
|
+
_write_json_marker(
|
|
790
|
+
marker_path,
|
|
791
|
+
{
|
|
792
|
+
"task_id": task_id,
|
|
793
|
+
"discarded_at": now_iso(),
|
|
794
|
+
"worktree_removed": bool(worktree_result.get("discarded")),
|
|
795
|
+
},
|
|
796
|
+
)
|
|
797
|
+
set_task_status(plan, task_id, "candidate_rejected")
|
|
798
|
+
task_obj = find_task(plan, task_id)
|
|
799
|
+
if task_obj is not None:
|
|
800
|
+
task_obj["last_error"] = "discarded in IDE swarm review"
|
|
801
|
+
save_plan(paths, plan)
|
|
802
|
+
discarded.append(
|
|
803
|
+
{
|
|
804
|
+
"task_id": task_id,
|
|
805
|
+
"discarded": True,
|
|
806
|
+
"already_discarded": False,
|
|
807
|
+
"worktree_removed": bool(worktree_result.get("discarded")),
|
|
808
|
+
}
|
|
809
|
+
)
|
|
810
|
+
return {
|
|
811
|
+
"session_id": record.session_id,
|
|
812
|
+
"plan_id": record.plan_id,
|
|
813
|
+
"discarded": discarded,
|
|
814
|
+
"redacted": True,
|
|
815
|
+
"secret_values_included": False,
|
|
816
|
+
}
|