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,3112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..approval_scope import exact_file_set_scope, exact_verify_command_set_scope
|
|
14
|
+
from ..assets import AssetAlreadyExistsError, AssetError, asset_reference_check
|
|
15
|
+
from ..assets.plan_binding import bind_asset_to_matching_tasks
|
|
16
|
+
from ..assets.surface import (
|
|
17
|
+
AssetSurfaceDetail,
|
|
18
|
+
AssetSurfaceEntry,
|
|
19
|
+
build_asset_surface,
|
|
20
|
+
)
|
|
21
|
+
from ..diff_paths import parse_patch_changed_files
|
|
22
|
+
from ..execution_shared import (
|
|
23
|
+
build_task_execution_instruction_bundle,
|
|
24
|
+
build_task_local_workspace_reporting_diff,
|
|
25
|
+
capture_task_local_workspace_baseline,
|
|
26
|
+
cleanup_task_local_workspace_baseline,
|
|
27
|
+
resolve_managed_task_step_budget,
|
|
28
|
+
safe_task_file_component,
|
|
29
|
+
write_execution_budget_artifact,
|
|
30
|
+
write_execution_context_artifact,
|
|
31
|
+
)
|
|
32
|
+
from ..forge import (
|
|
33
|
+
ForgeError,
|
|
34
|
+
RunPaths,
|
|
35
|
+
append_planner_chat,
|
|
36
|
+
append_planner_summary,
|
|
37
|
+
append_transcript_note,
|
|
38
|
+
create_plan_run,
|
|
39
|
+
ensure_execution_dirs,
|
|
40
|
+
ensure_workspace_context_artifacts,
|
|
41
|
+
finalize_plan,
|
|
42
|
+
find_task,
|
|
43
|
+
load_plan,
|
|
44
|
+
make_run_paths,
|
|
45
|
+
now_iso,
|
|
46
|
+
save_plan,
|
|
47
|
+
set_task_status,
|
|
48
|
+
write_task_report,
|
|
49
|
+
)
|
|
50
|
+
from ..git_ops import head_commit
|
|
51
|
+
from ..knowledge_librarian import prepare_planner_knowledge, resolve_knowledge_workspace_root
|
|
52
|
+
from ..plan_assistant import apply_guarded_planner_plan_update, run_planner_turn
|
|
53
|
+
from ..plan_reconciliation import reconcile_plan_with_workspace
|
|
54
|
+
from ..plan_repair import PlannerRepairReport, apply_plan_status, record_plan_repair
|
|
55
|
+
from ..plan_validation import validate_plan
|
|
56
|
+
from ..review_gate import ReviewError, review_task
|
|
57
|
+
from ..runtime_kind import RuntimeKind
|
|
58
|
+
from ..sandbox_doctor import diagnose_sandbox
|
|
59
|
+
from ..surface.types import ApprovalRequest
|
|
60
|
+
from ..task_scope import check_scope
|
|
61
|
+
from ..verify_gate import (
|
|
62
|
+
resolve_verify_commands,
|
|
63
|
+
run_task_verification,
|
|
64
|
+
verify_run_result_to_payload,
|
|
65
|
+
)
|
|
66
|
+
from .protocol import ProtocolError, redact_secrets
|
|
67
|
+
|
|
68
|
+
DEFAULT_MAX_DIFF_BYTES = 64 * 1024
|
|
69
|
+
MAX_DIFF_BYTES = 1024 * 1024
|
|
70
|
+
MAX_LISTED_PLANS = 100
|
|
71
|
+
MAX_FORGE_ASSET_BYTES = 25 * 1024 * 1024
|
|
72
|
+
PLAN_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
73
|
+
ASSET_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
74
|
+
REAL_EXECUTE_AUTO_UNSUPPORTED_REASON = (
|
|
75
|
+
"Forge Execute v1 is review-mode only. Auto and fullaccess execution remain disabled "
|
|
76
|
+
"until they have separate approval, sandbox, and cancellation coverage."
|
|
77
|
+
)
|
|
78
|
+
SUPPORTED_SANDBOX_PROFILES = frozenset({"default", "strict", "warn", "off"})
|
|
79
|
+
REAL_EXECUTE_SUPPORTED_MODES = frozenset({"review"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def append_planner_router_event(paths: RunPaths, payload: dict[str, Any]) -> None:
|
|
83
|
+
"""Persist legacy planner-router telemetry when an older planner supplies it.
|
|
84
|
+
|
|
85
|
+
The deterministic planner router was removed from the current runtime, so
|
|
86
|
+
production planner results no longer include this payload. Keeping the
|
|
87
|
+
writer local to the compatibility bridge lets older IDE requests and test
|
|
88
|
+
doubles remain readable without restoring the removed router to Forge.
|
|
89
|
+
"""
|
|
90
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
event_type = (
|
|
92
|
+
"planner_router_failure"
|
|
93
|
+
if str(payload.get("fallback_reason") or "").strip()
|
|
94
|
+
else "planner_router_decision"
|
|
95
|
+
)
|
|
96
|
+
event = {"type": event_type, "ts": now_iso(), "payload": payload}
|
|
97
|
+
event_path = paths.notes_dir / "planner_router_events.jsonl"
|
|
98
|
+
with event_path.open("a", encoding="utf-8") as fh:
|
|
99
|
+
fh.write(
|
|
100
|
+
json.dumps(event, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _throw_if_cancelled(cancellation_token: Any | None) -> None:
|
|
105
|
+
if cancellation_token is None:
|
|
106
|
+
return
|
|
107
|
+
throw_if_cancelled = getattr(cancellation_token, "throw_if_cancelled", None)
|
|
108
|
+
if callable(throw_if_cancelled):
|
|
109
|
+
throw_if_cancelled("cancelled_by_user")
|
|
110
|
+
return
|
|
111
|
+
if bool(getattr(cancellation_token, "is_cancelled", False)):
|
|
112
|
+
raise RuntimeError("cancelled_by_user")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _call_accepts_keyword(callable_obj: Callable[..., Any], keyword: str) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
sig = inspect.signature(callable_obj)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
return False
|
|
120
|
+
for parameter in sig.parameters.values():
|
|
121
|
+
if parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
122
|
+
return True
|
|
123
|
+
if (
|
|
124
|
+
parameter.kind
|
|
125
|
+
in {
|
|
126
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
127
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
128
|
+
}
|
|
129
|
+
and parameter.name == keyword
|
|
130
|
+
):
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
FORGE_REGISTRY_REJECTED_MESSAGE = "Forge persisted plan registry path was rejected."
|
|
136
|
+
SAFE_IDE_TASK_STATUSES = frozenset(
|
|
137
|
+
{
|
|
138
|
+
"planned",
|
|
139
|
+
"in_progress",
|
|
140
|
+
"blocked",
|
|
141
|
+
"done",
|
|
142
|
+
"failed",
|
|
143
|
+
"changes_requested",
|
|
144
|
+
"superseded",
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
MAX_PLAN_EDIT_TEXT_CHARS = 8_000
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class ForgePlanRecord:
|
|
152
|
+
session_id: str
|
|
153
|
+
plan_id: str
|
|
154
|
+
paths: RunPaths
|
|
155
|
+
status: str = "planned"
|
|
156
|
+
job_id: str | None = None
|
|
157
|
+
source: str = "active_memory"
|
|
158
|
+
warnings: tuple[str, ...] = ()
|
|
159
|
+
verification_commands: tuple[str, ...] = ()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True, slots=True)
|
|
163
|
+
class DiffRecord:
|
|
164
|
+
diff_id: str
|
|
165
|
+
session_id: str
|
|
166
|
+
plan_id: str
|
|
167
|
+
job_id: str | None
|
|
168
|
+
path: Path
|
|
169
|
+
rel_path: str
|
|
170
|
+
file_path: str
|
|
171
|
+
status: str
|
|
172
|
+
old_label: str
|
|
173
|
+
new_label: str
|
|
174
|
+
size_bytes: int
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def create_ide_forge_plan(
|
|
178
|
+
*,
|
|
179
|
+
session_id: str,
|
|
180
|
+
workspace_root: Path,
|
|
181
|
+
instruction: str,
|
|
182
|
+
cfg: Any,
|
|
183
|
+
planner_runner: Callable[..., Any] | None = None,
|
|
184
|
+
cancellation_token: Any | None = None,
|
|
185
|
+
) -> tuple[ForgePlanRecord, dict[str, Any]]:
|
|
186
|
+
clean_instruction = instruction.strip()
|
|
187
|
+
if not clean_instruction:
|
|
188
|
+
raise ProtocolError("missing_field", "instruction is required.")
|
|
189
|
+
_throw_if_cancelled(cancellation_token)
|
|
190
|
+
quality: dict[str, Any]
|
|
191
|
+
try:
|
|
192
|
+
paths = create_plan_run(workspace_root)
|
|
193
|
+
plan = load_plan(paths)
|
|
194
|
+
append_transcript_note(paths, role="user", message=clean_instruction)
|
|
195
|
+
append_planner_chat(paths, role="user", message=clean_instruction)
|
|
196
|
+
workspace_context = _workspace_context_payload(paths)
|
|
197
|
+
relevant_knowledge_section = _planner_knowledge_section(
|
|
198
|
+
paths=paths,
|
|
199
|
+
plan=plan,
|
|
200
|
+
instruction=clean_instruction,
|
|
201
|
+
)
|
|
202
|
+
planner = planner_runner or run_planner_turn
|
|
203
|
+
planner_kwargs = {
|
|
204
|
+
"cfg": cfg,
|
|
205
|
+
"api_key_override": None,
|
|
206
|
+
"plan": plan,
|
|
207
|
+
"transcript_tail": [{"role": "user", "content": clean_instruction}],
|
|
208
|
+
"workspace_context": workspace_context,
|
|
209
|
+
"user_text": clean_instruction,
|
|
210
|
+
"stream": False,
|
|
211
|
+
"relevant_knowledge_section": relevant_knowledge_section,
|
|
212
|
+
"prefer_context": "forge",
|
|
213
|
+
"awaiting_clarification": False,
|
|
214
|
+
"pending_questions": [],
|
|
215
|
+
"run_paths": paths,
|
|
216
|
+
}
|
|
217
|
+
if _call_accepts_keyword(planner, "cancellation_token"):
|
|
218
|
+
planner_kwargs["cancellation_token"] = cancellation_token
|
|
219
|
+
planner_result = planner(**planner_kwargs)
|
|
220
|
+
_throw_if_cancelled(cancellation_token)
|
|
221
|
+
assistant_message = str(getattr(planner_result, "assistant_message", "") or "")
|
|
222
|
+
if assistant_message:
|
|
223
|
+
append_transcript_note(paths, role="assistant", message=assistant_message)
|
|
224
|
+
append_planner_chat(paths, role="assistant", message=assistant_message)
|
|
225
|
+
router_event = getattr(planner_result, "planner_router_event", None)
|
|
226
|
+
if isinstance(router_event, dict):
|
|
227
|
+
append_planner_router_event(paths, router_event)
|
|
228
|
+
|
|
229
|
+
planner_error = str(getattr(planner_result, "error", "") or "").strip()
|
|
230
|
+
if planner_error:
|
|
231
|
+
append_planner_summary(paths, f"planner error: {planner_error}")
|
|
232
|
+
save_plan(paths, plan)
|
|
233
|
+
raise ProtocolError(
|
|
234
|
+
"forge_plan_failed",
|
|
235
|
+
str(redact_secrets(f"Forge planner failed: {planner_error}")),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
raw_plan_update = getattr(planner_result, "plan_update", None)
|
|
239
|
+
if not isinstance(raw_plan_update, dict) or not raw_plan_update:
|
|
240
|
+
questions = [
|
|
241
|
+
str(question).strip()
|
|
242
|
+
for question in getattr(planner_result, "questions", []) or []
|
|
243
|
+
if str(question).strip()
|
|
244
|
+
]
|
|
245
|
+
if questions:
|
|
246
|
+
append_transcript_note(
|
|
247
|
+
paths,
|
|
248
|
+
role="system",
|
|
249
|
+
message=f"Planner questions: {'; '.join(questions)}",
|
|
250
|
+
)
|
|
251
|
+
append_planner_summary(paths, "planner produced no structured task update")
|
|
252
|
+
save_plan(paths, plan)
|
|
253
|
+
raise ProtocolError(
|
|
254
|
+
"forge_plan_incomplete",
|
|
255
|
+
"Forge planner did not produce a structured execution plan. No shallow fallback plan was created.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
apply_result = apply_guarded_planner_plan_update(
|
|
259
|
+
plan,
|
|
260
|
+
raw_plan_update,
|
|
261
|
+
latest_user_text=clean_instruction,
|
|
262
|
+
workspace_context=workspace_context,
|
|
263
|
+
)
|
|
264
|
+
reconciliation_warnings: list[str] = []
|
|
265
|
+
if apply_result.changed:
|
|
266
|
+
target_ids = set(apply_result.added_task_ids + apply_result.updated_task_ids) or None
|
|
267
|
+
reconciliation = reconcile_plan_with_workspace(
|
|
268
|
+
plan,
|
|
269
|
+
workspace_root=paths.root,
|
|
270
|
+
workspace_context=workspace_context,
|
|
271
|
+
user_text=clean_instruction,
|
|
272
|
+
transcript_tail=[
|
|
273
|
+
{"role": "user", "content": clean_instruction},
|
|
274
|
+
{"role": "assistant", "content": assistant_message},
|
|
275
|
+
],
|
|
276
|
+
target_task_ids=target_ids,
|
|
277
|
+
)
|
|
278
|
+
reconciliation_warnings = list(getattr(reconciliation, "warnings", []) or [])
|
|
279
|
+
resolved_commands = _resolved_verification_commands(cfg, paths.root)
|
|
280
|
+
if resolved_commands:
|
|
281
|
+
plan["ide_verification_commands"] = list(resolved_commands)
|
|
282
|
+
if _plan_tasks(plan):
|
|
283
|
+
finalize_plan(plan)
|
|
284
|
+
record_plan_repair(plan, getattr(planner_result, "repair", None) or PlannerRepairReport())
|
|
285
|
+
apply_plan_status(plan, validation_warnings=validate_plan(plan))
|
|
286
|
+
save_plan(paths, plan)
|
|
287
|
+
quality = validate_ide_plan_quality(
|
|
288
|
+
plan,
|
|
289
|
+
verification_commands=resolved_commands,
|
|
290
|
+
extra_warnings=[
|
|
291
|
+
*list(getattr(apply_result, "warnings", []) or []),
|
|
292
|
+
*reconciliation_warnings,
|
|
293
|
+
],
|
|
294
|
+
)
|
|
295
|
+
_write_plan_validation_artifact(paths=paths, warnings=quality["warnings"])
|
|
296
|
+
except ProtocolError:
|
|
297
|
+
raise
|
|
298
|
+
except ForgeError as e:
|
|
299
|
+
raise ProtocolError("forge_plan_failed", str(e)) from e
|
|
300
|
+
except Exception as e:
|
|
301
|
+
_throw_if_cancelled(cancellation_token)
|
|
302
|
+
raise ProtocolError(
|
|
303
|
+
"forge_plan_failed",
|
|
304
|
+
str(redact_secrets(f"Forge planner adapter failed: {e}")),
|
|
305
|
+
) from e
|
|
306
|
+
|
|
307
|
+
if not _plan_tasks(plan):
|
|
308
|
+
raise ProtocolError(
|
|
309
|
+
"forge_plan_incomplete",
|
|
310
|
+
"Forge planner did not produce executable tasks. No shallow fallback plan was returned.",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
record = ForgePlanRecord(
|
|
314
|
+
session_id=session_id,
|
|
315
|
+
plan_id=paths.run_id,
|
|
316
|
+
paths=paths,
|
|
317
|
+
source="active_memory",
|
|
318
|
+
warnings=tuple(quality["warnings"]),
|
|
319
|
+
verification_commands=tuple(resolved_commands),
|
|
320
|
+
)
|
|
321
|
+
return record, plan
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_recorded_plan(record: ForgePlanRecord, *, migrate_legacy: bool = True) -> dict[str, Any]:
|
|
325
|
+
try:
|
|
326
|
+
validate_forge_run_artifact_root(record)
|
|
327
|
+
return load_plan(record.paths, migrate_legacy=migrate_legacy)
|
|
328
|
+
except ForgeError as e:
|
|
329
|
+
raise ProtocolError("forge_plan_not_found", str(e)) from e
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def forge_plan_result(
|
|
333
|
+
record: ForgePlanRecord,
|
|
334
|
+
plan: dict[str, Any],
|
|
335
|
+
*,
|
|
336
|
+
created_session: bool,
|
|
337
|
+
verification_commands: list[str] | None = None,
|
|
338
|
+
) -> dict[str, Any]:
|
|
339
|
+
root_name = forge_artifact_root_name(record.plan_id)
|
|
340
|
+
commands = (
|
|
341
|
+
verification_commands
|
|
342
|
+
if verification_commands is not None
|
|
343
|
+
else list(record.verification_commands)
|
|
344
|
+
or _string_list(plan.get("ide_verification_commands"))
|
|
345
|
+
)
|
|
346
|
+
quality = validate_ide_plan_quality(
|
|
347
|
+
plan,
|
|
348
|
+
verification_commands=commands,
|
|
349
|
+
extra_warnings=list(record.warnings),
|
|
350
|
+
)
|
|
351
|
+
return {
|
|
352
|
+
"plan_id": record.plan_id,
|
|
353
|
+
"session_id": record.session_id,
|
|
354
|
+
"job_id": record.job_id,
|
|
355
|
+
"status": record.status,
|
|
356
|
+
"source": record.source,
|
|
357
|
+
"created_session": created_session,
|
|
358
|
+
"project_goal": str(redact_secrets(str(plan.get("project_goal") or ""))),
|
|
359
|
+
"summary": str(redact_secrets(str(plan.get("summary") or ""))),
|
|
360
|
+
"warnings": quality["warnings"],
|
|
361
|
+
"incomplete": quality["incomplete"],
|
|
362
|
+
"tasks": [
|
|
363
|
+
_task_to_ide_schema(
|
|
364
|
+
task,
|
|
365
|
+
verification_commands=commands,
|
|
366
|
+
warnings=quality["task_warnings"].get(str(task.get("id") or ""), []),
|
|
367
|
+
)
|
|
368
|
+
for task in _plan_tasks(plan)
|
|
369
|
+
],
|
|
370
|
+
"artifacts": [
|
|
371
|
+
{
|
|
372
|
+
"kind": "forge_plan_json",
|
|
373
|
+
"artifact_id": f"{root_name}:plan/plan.json",
|
|
374
|
+
"path": "plan/plan.json",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"kind": "forge_plan_markdown",
|
|
378
|
+
"artifact_id": f"{root_name}:plan/PLAN.md",
|
|
379
|
+
"path": "plan/PLAN.md",
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
"plan_artifact_id": f"{root_name}:plan/plan.json",
|
|
383
|
+
"plan_markdown_artifact_id": f"{root_name}:plan/PLAN.md",
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def forge_status_result(record: ForgePlanRecord, plan: dict[str, Any]) -> dict[str, Any]:
|
|
388
|
+
payload = forge_plan_result(record, plan, created_session=False)
|
|
389
|
+
payload["diff_count"] = len(list_diff_records([record]))
|
|
390
|
+
return payload
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def forge_show_result(record: ForgePlanRecord, plan: dict[str, Any], *, cfg: Any) -> dict[str, Any]:
|
|
394
|
+
payload = forge_status_result(record, plan)
|
|
395
|
+
payload["assets"] = _asset_entries_payload(record, cfg=cfg, include_deleted=True)
|
|
396
|
+
payload["legacy_assets"] = _legacy_plan_assets_payload(plan)
|
|
397
|
+
payload["artifact_count"] = len(payload.get("artifacts") or [])
|
|
398
|
+
return payload
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def forge_plan_state_result(
|
|
402
|
+
record: ForgePlanRecord,
|
|
403
|
+
plan: dict[str, Any],
|
|
404
|
+
*,
|
|
405
|
+
cfg: Any,
|
|
406
|
+
) -> dict[str, Any]:
|
|
407
|
+
payload = forge_show_result(record, plan, cfg=cfg)
|
|
408
|
+
payload["ide_revision"] = _plan_ide_revision(plan)
|
|
409
|
+
payload["assistant"] = _plan_assistant_payload(plan)
|
|
410
|
+
payload["goal"] = str(redact_secrets(str(plan.get("project_goal") or "")))
|
|
411
|
+
payload["validation"] = forge_plan_validate_result(record, plan)
|
|
412
|
+
return payload
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass(frozen=True, slots=True)
|
|
416
|
+
class ForgePlanEditOutcome:
|
|
417
|
+
"""A committed plan edit, separated from the response payload rendering.
|
|
418
|
+
|
|
419
|
+
Apply functions mutate and persist the plan under the caller's lock;
|
|
420
|
+
forge_plan_edit_render builds the (filesystem-heavy) response payload and
|
|
421
|
+
must run outside bridge-wide locks.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
changed: bool
|
|
425
|
+
previous_revision: int
|
|
426
|
+
changed_fields: tuple[str, ...]
|
|
427
|
+
task: dict[str, Any] | None = None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def forge_plan_set_assistant_apply(
|
|
431
|
+
record: ForgePlanRecord,
|
|
432
|
+
plan: dict[str, Any],
|
|
433
|
+
*,
|
|
434
|
+
instruction: str,
|
|
435
|
+
expected_revision: Any = None,
|
|
436
|
+
) -> ForgePlanEditOutcome:
|
|
437
|
+
previous_revision = _check_expected_revision(plan, expected_revision)
|
|
438
|
+
clean = _safe_plan_edit_text(instruction, field="instruction")
|
|
439
|
+
previous = _plan_assistant_payload(plan).get("instruction", "")
|
|
440
|
+
changed = previous != clean
|
|
441
|
+
if changed:
|
|
442
|
+
plan["ide_assistant"] = {
|
|
443
|
+
"instruction": clean,
|
|
444
|
+
"updated_at": now_iso(),
|
|
445
|
+
"source": "ide_protocol",
|
|
446
|
+
}
|
|
447
|
+
plan["ide_revision"] = previous_revision + 1
|
|
448
|
+
save_plan(record.paths, plan)
|
|
449
|
+
return ForgePlanEditOutcome(
|
|
450
|
+
changed=changed,
|
|
451
|
+
previous_revision=previous_revision,
|
|
452
|
+
changed_fields=("assistant.instruction",) if changed else (),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def forge_plan_set_goal_apply(
|
|
457
|
+
record: ForgePlanRecord,
|
|
458
|
+
plan: dict[str, Any],
|
|
459
|
+
*,
|
|
460
|
+
goal: str,
|
|
461
|
+
expected_revision: Any = None,
|
|
462
|
+
) -> ForgePlanEditOutcome:
|
|
463
|
+
previous_revision = _check_expected_revision(plan, expected_revision)
|
|
464
|
+
clean = _safe_plan_edit_text(goal, field="goal")
|
|
465
|
+
if not clean:
|
|
466
|
+
raise ProtocolError("missing_field", "goal must be a non-empty string.")
|
|
467
|
+
previous = str(plan.get("project_goal") or "")
|
|
468
|
+
changed = previous != clean
|
|
469
|
+
if changed:
|
|
470
|
+
plan["project_goal"] = clean
|
|
471
|
+
plan["ide_revision"] = previous_revision + 1
|
|
472
|
+
plan["ide_stale_reason"] = "goal_changed"
|
|
473
|
+
save_plan(record.paths, plan)
|
|
474
|
+
return ForgePlanEditOutcome(
|
|
475
|
+
changed=changed,
|
|
476
|
+
previous_revision=previous_revision,
|
|
477
|
+
changed_fields=("project_goal",) if changed else (),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def forge_plan_update_task_apply(
|
|
482
|
+
record: ForgePlanRecord,
|
|
483
|
+
plan: dict[str, Any],
|
|
484
|
+
*,
|
|
485
|
+
task_id: str,
|
|
486
|
+
title: str | None = None,
|
|
487
|
+
body: str | None = None,
|
|
488
|
+
status: str | None = None,
|
|
489
|
+
expected_revision: Any = None,
|
|
490
|
+
) -> ForgePlanEditOutcome:
|
|
491
|
+
clean_task_id = _required_task_id(task_id)
|
|
492
|
+
previous_revision = _check_expected_revision(plan, expected_revision)
|
|
493
|
+
try:
|
|
494
|
+
task = find_task(plan, clean_task_id)
|
|
495
|
+
except ForgeError as e:
|
|
496
|
+
raise ProtocolError("task_not_found", str(redact_secrets(str(e)))) from e
|
|
497
|
+
|
|
498
|
+
changed_fields: list[str] = []
|
|
499
|
+
if title is not None:
|
|
500
|
+
clean_title = _safe_plan_edit_text(title, field="title")
|
|
501
|
+
if not clean_title:
|
|
502
|
+
raise ProtocolError("missing_field", "task title must be a non-empty string.")
|
|
503
|
+
if str(task.get("title") or "") != clean_title:
|
|
504
|
+
task["title"] = clean_title
|
|
505
|
+
changed_fields.append("title")
|
|
506
|
+
if body is not None:
|
|
507
|
+
clean_body = _safe_plan_edit_text(body, field="body")
|
|
508
|
+
if str(task.get("description") or "") != clean_body:
|
|
509
|
+
task["description"] = clean_body
|
|
510
|
+
changed_fields.append("description")
|
|
511
|
+
if status is not None:
|
|
512
|
+
clean_status = str(status or "").strip().lower()
|
|
513
|
+
if clean_status not in SAFE_IDE_TASK_STATUSES:
|
|
514
|
+
raise ProtocolError(
|
|
515
|
+
"invalid_task_status",
|
|
516
|
+
"task status must be one of: " + ", ".join(sorted(SAFE_IDE_TASK_STATUSES)),
|
|
517
|
+
)
|
|
518
|
+
if str(task.get("status") or "") != clean_status:
|
|
519
|
+
set_task_status(plan, clean_task_id, clean_status)
|
|
520
|
+
changed_fields.append("status")
|
|
521
|
+
|
|
522
|
+
if changed_fields:
|
|
523
|
+
plan["ide_revision"] = previous_revision + 1
|
|
524
|
+
plan["ide_stale_reason"] = "task_updated"
|
|
525
|
+
save_plan(record.paths, plan)
|
|
526
|
+
task_payload = _task_to_ide_schema(
|
|
527
|
+
task,
|
|
528
|
+
verification_commands=_string_list(plan.get("ide_verification_commands")),
|
|
529
|
+
warnings=[],
|
|
530
|
+
)
|
|
531
|
+
return ForgePlanEditOutcome(
|
|
532
|
+
changed=bool(changed_fields),
|
|
533
|
+
previous_revision=previous_revision,
|
|
534
|
+
changed_fields=tuple(changed_fields),
|
|
535
|
+
task=task_payload,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def forge_plan_edit_render(
|
|
540
|
+
record: ForgePlanRecord,
|
|
541
|
+
plan: dict[str, Any],
|
|
542
|
+
*,
|
|
543
|
+
cfg: Any,
|
|
544
|
+
outcome: ForgePlanEditOutcome,
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
result = _plan_edit_result(
|
|
547
|
+
record,
|
|
548
|
+
plan,
|
|
549
|
+
cfg=cfg,
|
|
550
|
+
changed=outcome.changed,
|
|
551
|
+
previous_revision=outcome.previous_revision,
|
|
552
|
+
changed_fields=list(outcome.changed_fields),
|
|
553
|
+
)
|
|
554
|
+
if outcome.task is not None:
|
|
555
|
+
result["task"] = outcome.task
|
|
556
|
+
return result
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def forge_plan_set_assistant_result(
|
|
560
|
+
record: ForgePlanRecord,
|
|
561
|
+
plan: dict[str, Any],
|
|
562
|
+
*,
|
|
563
|
+
instruction: str,
|
|
564
|
+
expected_revision: Any = None,
|
|
565
|
+
cfg: Any,
|
|
566
|
+
) -> dict[str, Any]:
|
|
567
|
+
outcome = forge_plan_set_assistant_apply(
|
|
568
|
+
record, plan, instruction=instruction, expected_revision=expected_revision
|
|
569
|
+
)
|
|
570
|
+
return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def forge_plan_set_goal_result(
|
|
574
|
+
record: ForgePlanRecord,
|
|
575
|
+
plan: dict[str, Any],
|
|
576
|
+
*,
|
|
577
|
+
goal: str,
|
|
578
|
+
expected_revision: Any = None,
|
|
579
|
+
cfg: Any,
|
|
580
|
+
) -> dict[str, Any]:
|
|
581
|
+
outcome = forge_plan_set_goal_apply(
|
|
582
|
+
record, plan, goal=goal, expected_revision=expected_revision
|
|
583
|
+
)
|
|
584
|
+
return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def forge_plan_update_task_result(
|
|
588
|
+
record: ForgePlanRecord,
|
|
589
|
+
plan: dict[str, Any],
|
|
590
|
+
*,
|
|
591
|
+
task_id: str,
|
|
592
|
+
title: str | None = None,
|
|
593
|
+
body: str | None = None,
|
|
594
|
+
status: str | None = None,
|
|
595
|
+
expected_revision: Any = None,
|
|
596
|
+
cfg: Any,
|
|
597
|
+
) -> dict[str, Any]:
|
|
598
|
+
outcome = forge_plan_update_task_apply(
|
|
599
|
+
record,
|
|
600
|
+
plan,
|
|
601
|
+
task_id=task_id,
|
|
602
|
+
title=title,
|
|
603
|
+
body=body,
|
|
604
|
+
status=status,
|
|
605
|
+
expected_revision=expected_revision,
|
|
606
|
+
)
|
|
607
|
+
return forge_plan_edit_render(record, plan, cfg=cfg, outcome=outcome)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def forge_plan_validate_result(record: ForgePlanRecord, plan: dict[str, Any]) -> dict[str, Any]:
|
|
611
|
+
commands = list(record.verification_commands) or _string_list(
|
|
612
|
+
plan.get("ide_verification_commands")
|
|
613
|
+
)
|
|
614
|
+
quality = validate_ide_plan_quality(plan, verification_commands=commands)
|
|
615
|
+
return {
|
|
616
|
+
"ok": not bool(quality["warnings"]) and not bool(quality["incomplete"]),
|
|
617
|
+
"warnings": quality["warnings"],
|
|
618
|
+
"incomplete": quality["incomplete"],
|
|
619
|
+
"task_warnings": quality["task_warnings"],
|
|
620
|
+
"ide_revision": _plan_ide_revision(plan),
|
|
621
|
+
"stale_reason": str(redact_secrets(str(plan.get("ide_stale_reason") or ""))) or None,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@dataclass(slots=True)
|
|
626
|
+
class ForgePlanRegenerateComputation:
|
|
627
|
+
"""Planner output computed from a plan snapshot, awaiting a revision-checked commit."""
|
|
628
|
+
|
|
629
|
+
plan: dict[str, Any]
|
|
630
|
+
base_revision: int
|
|
631
|
+
changed: bool
|
|
632
|
+
quality: dict[str, Any]
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def ensure_expected_plan_revision(plan: dict[str, Any], expected_revision: Any) -> int:
|
|
636
|
+
return _check_expected_revision(plan, expected_revision)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def forge_plan_regenerate_compute(
|
|
640
|
+
record: ForgePlanRecord,
|
|
641
|
+
plan: dict[str, Any],
|
|
642
|
+
*,
|
|
643
|
+
instruction: str | None = None,
|
|
644
|
+
focus: str | None = None,
|
|
645
|
+
expected_revision: Any = None,
|
|
646
|
+
cfg: Any,
|
|
647
|
+
planner_runner: Callable[..., Any] | None = None,
|
|
648
|
+
cancellation_token: Any | None = None,
|
|
649
|
+
) -> ForgePlanRegenerateComputation:
|
|
650
|
+
"""Run the planner against a plan snapshot without persisting anything.
|
|
651
|
+
|
|
652
|
+
Callers must not hold bridge-wide locks across this call: the planner is a
|
|
653
|
+
provider round-trip. The snapshot is mutated in place and only becomes
|
|
654
|
+
durable through forge_plan_regenerate_commit, which re-checks the revision.
|
|
655
|
+
"""
|
|
656
|
+
old_revision = _check_expected_revision(plan, expected_revision)
|
|
657
|
+
clean_instruction = (
|
|
658
|
+
_safe_plan_edit_text(instruction, field="instruction") if instruction is not None else ""
|
|
659
|
+
)
|
|
660
|
+
clean_focus = _safe_plan_edit_text(focus, field="focus") if focus is not None else ""
|
|
661
|
+
user_text = _regeneration_instruction(plan, instruction=clean_instruction, focus=clean_focus)
|
|
662
|
+
before_fingerprint = _plan_regeneration_fingerprint(plan)
|
|
663
|
+
_throw_if_cancelled(cancellation_token)
|
|
664
|
+
try:
|
|
665
|
+
append_transcript_note(record.paths, role="user", message=user_text)
|
|
666
|
+
append_planner_chat(record.paths, role="user", message=user_text)
|
|
667
|
+
workspace_context = _workspace_context_payload(record.paths)
|
|
668
|
+
relevant_knowledge_section = _planner_knowledge_section(
|
|
669
|
+
paths=record.paths,
|
|
670
|
+
plan=plan,
|
|
671
|
+
instruction=user_text,
|
|
672
|
+
)
|
|
673
|
+
planner = planner_runner or run_planner_turn
|
|
674
|
+
planner_kwargs: dict[str, Any] = {
|
|
675
|
+
"cfg": cfg,
|
|
676
|
+
"api_key_override": None,
|
|
677
|
+
"plan": plan,
|
|
678
|
+
"transcript_tail": [{"role": "user", "content": user_text}],
|
|
679
|
+
"workspace_context": workspace_context,
|
|
680
|
+
"user_text": user_text,
|
|
681
|
+
"stream": False,
|
|
682
|
+
"relevant_knowledge_section": relevant_knowledge_section,
|
|
683
|
+
"prefer_context": "forge",
|
|
684
|
+
"awaiting_clarification": False,
|
|
685
|
+
"pending_questions": [],
|
|
686
|
+
"run_paths": record.paths,
|
|
687
|
+
}
|
|
688
|
+
if _call_accepts_keyword(planner, "cancellation_token"):
|
|
689
|
+
planner_kwargs["cancellation_token"] = cancellation_token
|
|
690
|
+
planner_result = planner(**planner_kwargs)
|
|
691
|
+
_throw_if_cancelled(cancellation_token)
|
|
692
|
+
assistant_message = str(getattr(planner_result, "assistant_message", "") or "")
|
|
693
|
+
if assistant_message:
|
|
694
|
+
append_transcript_note(record.paths, role="assistant", message=assistant_message)
|
|
695
|
+
append_planner_chat(record.paths, role="assistant", message=assistant_message)
|
|
696
|
+
router_event = getattr(planner_result, "planner_router_event", None)
|
|
697
|
+
if isinstance(router_event, dict):
|
|
698
|
+
append_planner_router_event(record.paths, router_event)
|
|
699
|
+
|
|
700
|
+
planner_error = str(getattr(planner_result, "error", "") or "").strip()
|
|
701
|
+
if planner_error:
|
|
702
|
+
append_planner_summary(record.paths, f"planner regenerate error: {planner_error}")
|
|
703
|
+
raise ProtocolError(
|
|
704
|
+
"forge_plan_failed",
|
|
705
|
+
str(redact_secrets(f"Forge planner regeneration failed: {planner_error}")),
|
|
706
|
+
)
|
|
707
|
+
raw_plan_update = getattr(planner_result, "plan_update", None)
|
|
708
|
+
if not isinstance(raw_plan_update, dict) or not raw_plan_update:
|
|
709
|
+
append_planner_summary(
|
|
710
|
+
record.paths, "planner regeneration produced no structured update"
|
|
711
|
+
)
|
|
712
|
+
raise ProtocolError(
|
|
713
|
+
"forge_plan_incomplete",
|
|
714
|
+
"Forge planner regeneration did not produce a structured plan update.",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
apply_result = apply_guarded_planner_plan_update(
|
|
718
|
+
plan,
|
|
719
|
+
raw_plan_update,
|
|
720
|
+
latest_user_text=user_text,
|
|
721
|
+
workspace_context=workspace_context,
|
|
722
|
+
)
|
|
723
|
+
target_ids = set(apply_result.added_task_ids + apply_result.updated_task_ids) or None
|
|
724
|
+
reconciliation = reconcile_plan_with_workspace(
|
|
725
|
+
plan,
|
|
726
|
+
workspace_root=record.paths.root,
|
|
727
|
+
workspace_context=workspace_context,
|
|
728
|
+
user_text=user_text,
|
|
729
|
+
transcript_tail=[
|
|
730
|
+
{"role": "user", "content": user_text},
|
|
731
|
+
{"role": "assistant", "content": assistant_message},
|
|
732
|
+
],
|
|
733
|
+
target_task_ids=target_ids,
|
|
734
|
+
)
|
|
735
|
+
_throw_if_cancelled(cancellation_token)
|
|
736
|
+
resolved_commands = _resolved_verification_commands(cfg, record.paths.root)
|
|
737
|
+
if resolved_commands:
|
|
738
|
+
plan["ide_verification_commands"] = list(resolved_commands)
|
|
739
|
+
if _plan_tasks(plan):
|
|
740
|
+
finalize_plan(plan)
|
|
741
|
+
after_fingerprint = _plan_regeneration_fingerprint(plan)
|
|
742
|
+
changed = before_fingerprint != after_fingerprint
|
|
743
|
+
quality = validate_ide_plan_quality(
|
|
744
|
+
plan,
|
|
745
|
+
verification_commands=resolved_commands
|
|
746
|
+
or list(record.verification_commands)
|
|
747
|
+
or _string_list(plan.get("ide_verification_commands")),
|
|
748
|
+
extra_warnings=[
|
|
749
|
+
*list(getattr(apply_result, "warnings", []) or []),
|
|
750
|
+
*list(getattr(reconciliation, "warnings", []) or []),
|
|
751
|
+
],
|
|
752
|
+
)
|
|
753
|
+
except ProtocolError:
|
|
754
|
+
raise
|
|
755
|
+
except ForgeError as e:
|
|
756
|
+
raise ProtocolError("forge_plan_failed", str(redact_secrets(str(e)))) from e
|
|
757
|
+
except Exception as e:
|
|
758
|
+
_throw_if_cancelled(cancellation_token)
|
|
759
|
+
raise ProtocolError(
|
|
760
|
+
"forge_plan_failed",
|
|
761
|
+
str(redact_secrets(f"Forge planner regeneration adapter failed: {e}")),
|
|
762
|
+
) from e
|
|
763
|
+
return ForgePlanRegenerateComputation(
|
|
764
|
+
plan=plan,
|
|
765
|
+
base_revision=old_revision,
|
|
766
|
+
changed=changed,
|
|
767
|
+
quality=quality,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def forge_plan_regenerate_commit(
|
|
772
|
+
record: ForgePlanRecord,
|
|
773
|
+
computation: ForgePlanRegenerateComputation,
|
|
774
|
+
) -> None:
|
|
775
|
+
"""Persist a computed regeneration after re-checking the plan revision.
|
|
776
|
+
|
|
777
|
+
Callers must serialize this against other plan writers (the bridge holds
|
|
778
|
+
its state lock). A revision moved by a concurrent writer during compute
|
|
779
|
+
fails closed with stale_plan_revision and persists nothing.
|
|
780
|
+
"""
|
|
781
|
+
current = load_recorded_plan(record, migrate_legacy=False)
|
|
782
|
+
current_revision = _plan_ide_revision(current)
|
|
783
|
+
if current_revision != computation.base_revision:
|
|
784
|
+
raise ProtocolError(
|
|
785
|
+
"stale_plan_revision",
|
|
786
|
+
f"Plan revision is {current_revision}; regeneration was computed from "
|
|
787
|
+
f"revision {computation.base_revision}. Refresh plan state and retry.",
|
|
788
|
+
)
|
|
789
|
+
plan = computation.plan
|
|
790
|
+
if computation.changed:
|
|
791
|
+
plan["ide_revision"] = computation.base_revision + 1
|
|
792
|
+
plan["ide_stale_reason"] = "regenerated"
|
|
793
|
+
plan["ide_regenerated_at"] = now_iso()
|
|
794
|
+
apply_plan_status(plan, validation_warnings=computation.quality["warnings"])
|
|
795
|
+
try:
|
|
796
|
+
save_plan(record.paths, plan)
|
|
797
|
+
_write_plan_validation_artifact(
|
|
798
|
+
paths=record.paths, warnings=computation.quality["warnings"]
|
|
799
|
+
)
|
|
800
|
+
except ForgeError as e:
|
|
801
|
+
raise ProtocolError("forge_plan_failed", str(redact_secrets(str(e)))) from e
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def forge_plan_regenerate_render(
|
|
805
|
+
record: ForgePlanRecord,
|
|
806
|
+
computation: ForgePlanRegenerateComputation,
|
|
807
|
+
*,
|
|
808
|
+
cfg: Any,
|
|
809
|
+
) -> dict[str, Any]:
|
|
810
|
+
plan = computation.plan
|
|
811
|
+
result = forge_plan_state_result(record, plan, cfg=cfg)
|
|
812
|
+
result.update(
|
|
813
|
+
{
|
|
814
|
+
"old_revision": computation.base_revision,
|
|
815
|
+
"new_revision": _plan_ide_revision(plan),
|
|
816
|
+
"changed": computation.changed,
|
|
817
|
+
"summary": str(redact_secrets(str(plan.get("summary") or ""))),
|
|
818
|
+
"warnings": computation.quality["warnings"],
|
|
819
|
+
"validation": forge_plan_validate_result(record, plan),
|
|
820
|
+
"redacted": True,
|
|
821
|
+
"secret_values_included": False,
|
|
822
|
+
"audit": {
|
|
823
|
+
"changed_fields": ["plan"] if computation.changed else [],
|
|
824
|
+
"previous_revision": computation.base_revision,
|
|
825
|
+
"revision": _plan_ide_revision(plan),
|
|
826
|
+
"source": "ide_protocol",
|
|
827
|
+
"operation": "forge.plan.regenerate",
|
|
828
|
+
"secret_values_included": False,
|
|
829
|
+
},
|
|
830
|
+
}
|
|
831
|
+
)
|
|
832
|
+
return result
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def forge_plan_regenerate_result(
|
|
836
|
+
record: ForgePlanRecord,
|
|
837
|
+
plan: dict[str, Any],
|
|
838
|
+
*,
|
|
839
|
+
instruction: str | None = None,
|
|
840
|
+
focus: str | None = None,
|
|
841
|
+
expected_revision: Any = None,
|
|
842
|
+
cfg: Any,
|
|
843
|
+
planner_runner: Callable[..., Any] | None = None,
|
|
844
|
+
cancellation_token: Any | None = None,
|
|
845
|
+
) -> dict[str, Any]:
|
|
846
|
+
computation = forge_plan_regenerate_compute(
|
|
847
|
+
record,
|
|
848
|
+
plan,
|
|
849
|
+
instruction=instruction,
|
|
850
|
+
focus=focus,
|
|
851
|
+
expected_revision=expected_revision,
|
|
852
|
+
cfg=cfg,
|
|
853
|
+
planner_runner=planner_runner,
|
|
854
|
+
cancellation_token=cancellation_token,
|
|
855
|
+
)
|
|
856
|
+
forge_plan_regenerate_commit(record, computation)
|
|
857
|
+
return forge_plan_regenerate_render(record, computation, cfg=cfg)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _plan_edit_result(
|
|
861
|
+
record: ForgePlanRecord,
|
|
862
|
+
plan: dict[str, Any],
|
|
863
|
+
*,
|
|
864
|
+
cfg: Any,
|
|
865
|
+
changed: bool,
|
|
866
|
+
previous_revision: int,
|
|
867
|
+
changed_fields: list[str],
|
|
868
|
+
) -> dict[str, Any]:
|
|
869
|
+
payload = forge_plan_state_result(record, plan, cfg=cfg)
|
|
870
|
+
payload["changed"] = bool(changed)
|
|
871
|
+
payload["audit"] = {
|
|
872
|
+
"changed_fields": _redacted_strings(changed_fields),
|
|
873
|
+
"previous_revision": previous_revision,
|
|
874
|
+
"revision": _plan_ide_revision(plan),
|
|
875
|
+
"source": "ide_protocol",
|
|
876
|
+
"secret_values_included": False,
|
|
877
|
+
}
|
|
878
|
+
return payload
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def forge_review_result(
|
|
882
|
+
record: ForgePlanRecord,
|
|
883
|
+
plan: dict[str, Any],
|
|
884
|
+
*,
|
|
885
|
+
task_id: str,
|
|
886
|
+
cfg: Any,
|
|
887
|
+
reviewer: Callable[..., Any] | None = None,
|
|
888
|
+
) -> dict[str, Any]:
|
|
889
|
+
clean_task_id = _required_task_id(task_id)
|
|
890
|
+
validate_forge_run_artifact_root(record)
|
|
891
|
+
try:
|
|
892
|
+
task = find_task(plan, clean_task_id)
|
|
893
|
+
except ForgeError as e:
|
|
894
|
+
raise ProtocolError("task_not_found", str(redact_secrets(str(e)))) from e
|
|
895
|
+
try:
|
|
896
|
+
outcome = (reviewer or review_task)(
|
|
897
|
+
paths=record.paths,
|
|
898
|
+
plan=plan,
|
|
899
|
+
task=task,
|
|
900
|
+
cfg=cfg,
|
|
901
|
+
api_key_override=None,
|
|
902
|
+
)
|
|
903
|
+
except ReviewError as e:
|
|
904
|
+
raise ProtocolError("forge_review_failed", str(redact_secrets(str(e)))) from e
|
|
905
|
+
except Exception as e: # noqa: BLE001
|
|
906
|
+
raise ProtocolError(
|
|
907
|
+
"forge_review_failed",
|
|
908
|
+
str(redact_secrets(f"Forge review failed: {e}")),
|
|
909
|
+
) from e
|
|
910
|
+
review_json = _read_json_artifact(outcome.json_path, root=record.paths.root)
|
|
911
|
+
review_markdown = _read_text_artifact(outcome.markdown_path, root=record.paths.root)
|
|
912
|
+
return {
|
|
913
|
+
"session_id": record.session_id,
|
|
914
|
+
"plan_id": record.plan_id,
|
|
915
|
+
"task_id": str(redact_secrets(outcome.task_id)),
|
|
916
|
+
"approved": bool(outcome.approved),
|
|
917
|
+
"confidence": str(redact_secrets(outcome.confidence)),
|
|
918
|
+
"summary": str(redact_secrets(outcome.summary)),
|
|
919
|
+
"blocking_issues_count": int(outcome.blocking_issues_count),
|
|
920
|
+
"non_blocking_issues_count": int(outcome.non_blocking_issues_count),
|
|
921
|
+
"review_json": redact_secrets(review_json),
|
|
922
|
+
"review_markdown": str(redact_secrets(review_markdown)),
|
|
923
|
+
"json_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(record.paths.root, outcome.json_path)}",
|
|
924
|
+
"markdown_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(record.paths.root, outcome.markdown_path)}",
|
|
925
|
+
"requires_human_approval": not bool(outcome.approved),
|
|
926
|
+
"action": (
|
|
927
|
+
None
|
|
928
|
+
if outcome.approved
|
|
929
|
+
else {
|
|
930
|
+
"kind": "review_needed",
|
|
931
|
+
"message": "Forge review returned blocking issues; inspect the structured review before continuing.",
|
|
932
|
+
}
|
|
933
|
+
),
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def forge_attach_result(
|
|
938
|
+
record: ForgePlanRecord,
|
|
939
|
+
*,
|
|
940
|
+
source_path: Path,
|
|
941
|
+
cfg: Any,
|
|
942
|
+
title: str | None = None,
|
|
943
|
+
description: str = "",
|
|
944
|
+
) -> dict[str, Any]:
|
|
945
|
+
return forge_assets_add_result(
|
|
946
|
+
record,
|
|
947
|
+
source_path=source_path,
|
|
948
|
+
title=title,
|
|
949
|
+
description=description,
|
|
950
|
+
pinned=False,
|
|
951
|
+
wait=False,
|
|
952
|
+
link=True,
|
|
953
|
+
cfg=cfg,
|
|
954
|
+
command="forge.attach",
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def forge_assets_list_result(
|
|
959
|
+
record: ForgePlanRecord,
|
|
960
|
+
*,
|
|
961
|
+
cfg: Any,
|
|
962
|
+
include_deleted: bool = False,
|
|
963
|
+
) -> dict[str, Any]:
|
|
964
|
+
assets = _asset_entries_payload(record, cfg=cfg, include_deleted=include_deleted)
|
|
965
|
+
return {
|
|
966
|
+
"session_id": record.session_id,
|
|
967
|
+
"plan_id": record.plan_id,
|
|
968
|
+
"run_id": record.paths.run_id,
|
|
969
|
+
"assets": assets,
|
|
970
|
+
"count": len(assets),
|
|
971
|
+
"include_deleted": bool(include_deleted),
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def forge_assets_show_result(record: ForgePlanRecord, *, asset_id: str, cfg: Any) -> dict[str, Any]:
|
|
976
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
977
|
+
try:
|
|
978
|
+
detail = surface.show_asset(_required_asset_id(asset_id))
|
|
979
|
+
except AssetError as e:
|
|
980
|
+
raise ProtocolError("asset_not_found", str(redact_secrets(str(e)))) from e
|
|
981
|
+
return {
|
|
982
|
+
"session_id": record.session_id,
|
|
983
|
+
"plan_id": record.plan_id,
|
|
984
|
+
"asset": _asset_detail_payload(detail),
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def forge_assets_add_result(
|
|
989
|
+
record: ForgePlanRecord,
|
|
990
|
+
*,
|
|
991
|
+
source_path: Path,
|
|
992
|
+
title: str | None,
|
|
993
|
+
description: str = "",
|
|
994
|
+
pinned: bool = False,
|
|
995
|
+
wait: bool = False,
|
|
996
|
+
link: bool = False,
|
|
997
|
+
cfg: Any,
|
|
998
|
+
command: str = "forge.assets.add",
|
|
999
|
+
) -> dict[str, Any]:
|
|
1000
|
+
source = _resolve_workspace_asset_source(record, source_path)
|
|
1001
|
+
clean_title = str(title or "").strip() or source.name
|
|
1002
|
+
if not clean_title:
|
|
1003
|
+
raise ProtocolError("missing_field", "title is required.")
|
|
1004
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1005
|
+
try:
|
|
1006
|
+
result = surface.add_asset(
|
|
1007
|
+
source,
|
|
1008
|
+
title=clean_title,
|
|
1009
|
+
description=str(description or ""),
|
|
1010
|
+
pinned=bool(pinned),
|
|
1011
|
+
added_by={"phase": "ide_protocol", "command": command},
|
|
1012
|
+
comprehend="sync" if wait else "skip",
|
|
1013
|
+
dedupe_policy="link" if link else "reject",
|
|
1014
|
+
)
|
|
1015
|
+
bound_task_ids = _bind_asset_to_plan(record, surface=surface, asset_record=result.record)
|
|
1016
|
+
detail = surface.show_asset(result.record.id)
|
|
1017
|
+
except AssetAlreadyExistsError as e:
|
|
1018
|
+
raise ProtocolError(
|
|
1019
|
+
"asset_already_exists",
|
|
1020
|
+
str(redact_secrets(f"{e} existing_id={e.existing_id}")),
|
|
1021
|
+
) from e
|
|
1022
|
+
except AssetError as e:
|
|
1023
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1024
|
+
return {
|
|
1025
|
+
"session_id": record.session_id,
|
|
1026
|
+
"plan_id": record.plan_id,
|
|
1027
|
+
"asset": _asset_detail_payload(detail),
|
|
1028
|
+
"bound_task_ids": _redacted_strings(bound_task_ids),
|
|
1029
|
+
"comprehension_record": (
|
|
1030
|
+
redact_secrets(result.comprehension_record.to_dict())
|
|
1031
|
+
if result.comprehension_record is not None
|
|
1032
|
+
else None
|
|
1033
|
+
),
|
|
1034
|
+
"status": "added",
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def forge_assets_delete_result(
|
|
1039
|
+
record: ForgePlanRecord, *, asset_id: str, cfg: Any
|
|
1040
|
+
) -> dict[str, Any]:
|
|
1041
|
+
clean_asset_id = _required_asset_id(asset_id)
|
|
1042
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1043
|
+
try:
|
|
1044
|
+
deleted = surface.delete_asset(clean_asset_id)
|
|
1045
|
+
except AssetError as e:
|
|
1046
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1047
|
+
return {
|
|
1048
|
+
"session_id": record.session_id,
|
|
1049
|
+
"plan_id": record.plan_id,
|
|
1050
|
+
"asset": redact_secrets(deleted.to_dict()),
|
|
1051
|
+
"status": "deleted",
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def forge_assets_edit_result(
|
|
1056
|
+
record: ForgePlanRecord,
|
|
1057
|
+
*,
|
|
1058
|
+
asset_id: str,
|
|
1059
|
+
cfg: Any,
|
|
1060
|
+
title: str | None = None,
|
|
1061
|
+
description: str | None = None,
|
|
1062
|
+
pinned: bool | None = None,
|
|
1063
|
+
refresh: bool = False,
|
|
1064
|
+
) -> dict[str, Any]:
|
|
1065
|
+
clean_asset_id = _required_asset_id(asset_id)
|
|
1066
|
+
if title is None and description is None and pinned is None and not refresh:
|
|
1067
|
+
raise ProtocolError(
|
|
1068
|
+
"missing_field",
|
|
1069
|
+
"forge.assets.edit requires title, description, pinned, or refresh=true.",
|
|
1070
|
+
)
|
|
1071
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1072
|
+
try:
|
|
1073
|
+
detail = surface.edit_asset(
|
|
1074
|
+
clean_asset_id,
|
|
1075
|
+
title=title,
|
|
1076
|
+
description=description,
|
|
1077
|
+
pinned=pinned,
|
|
1078
|
+
retrigger_comprehension=False,
|
|
1079
|
+
)
|
|
1080
|
+
comprehension = (
|
|
1081
|
+
surface.refresh_comprehension(clean_asset_id, mode="sync").join() if refresh else None
|
|
1082
|
+
)
|
|
1083
|
+
if refresh:
|
|
1084
|
+
detail = surface.show_asset(clean_asset_id)
|
|
1085
|
+
except AssetError as e:
|
|
1086
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1087
|
+
return {
|
|
1088
|
+
"session_id": record.session_id,
|
|
1089
|
+
"plan_id": record.plan_id,
|
|
1090
|
+
"asset": _asset_detail_payload(detail),
|
|
1091
|
+
"comprehension_record": (
|
|
1092
|
+
redact_secrets(comprehension.to_dict()) if comprehension is not None else None
|
|
1093
|
+
),
|
|
1094
|
+
"status": "updated",
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def forge_assets_refresh_result(
|
|
1099
|
+
record: ForgePlanRecord,
|
|
1100
|
+
*,
|
|
1101
|
+
asset_id: str,
|
|
1102
|
+
cfg: Any,
|
|
1103
|
+
) -> dict[str, Any]:
|
|
1104
|
+
clean_asset_id = _required_asset_id(asset_id)
|
|
1105
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1106
|
+
try:
|
|
1107
|
+
handle = surface.refresh_comprehension(clean_asset_id, mode="sync")
|
|
1108
|
+
comprehension = handle.join()
|
|
1109
|
+
detail = surface.show_asset(clean_asset_id)
|
|
1110
|
+
except AssetError as e:
|
|
1111
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1112
|
+
return {
|
|
1113
|
+
"session_id": record.session_id,
|
|
1114
|
+
"plan_id": record.plan_id,
|
|
1115
|
+
"asset": _asset_detail_payload(detail),
|
|
1116
|
+
"comprehension_record": (
|
|
1117
|
+
redact_secrets(comprehension.to_dict()) if comprehension is not None else None
|
|
1118
|
+
),
|
|
1119
|
+
"status": detail.comprehension_status,
|
|
1120
|
+
"async_background": False,
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def forge_assets_cancel_pending_result(record: ForgePlanRecord, *, cfg: Any) -> dict[str, Any]:
|
|
1125
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1126
|
+
cancelled = surface.cancel_pending_comprehensions()
|
|
1127
|
+
return {
|
|
1128
|
+
"session_id": record.session_id,
|
|
1129
|
+
"plan_id": record.plan_id,
|
|
1130
|
+
"cancelled_count": int(cancelled),
|
|
1131
|
+
"status": "cancelled" if cancelled else "idle",
|
|
1132
|
+
"message": "No persistent IDE background comprehensions are running."
|
|
1133
|
+
if not cancelled
|
|
1134
|
+
else "Pending in-process comprehensions were asked to stop.",
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def forge_assets_check_plan_result(
|
|
1139
|
+
record: ForgePlanRecord,
|
|
1140
|
+
plan: dict[str, Any],
|
|
1141
|
+
*,
|
|
1142
|
+
cfg: Any,
|
|
1143
|
+
) -> dict[str, Any]:
|
|
1144
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1145
|
+
try:
|
|
1146
|
+
report = asset_reference_check(plan, surface)
|
|
1147
|
+
except AssetError as e:
|
|
1148
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1149
|
+
return {
|
|
1150
|
+
"session_id": record.session_id,
|
|
1151
|
+
"plan_id": record.plan_id,
|
|
1152
|
+
"deleted_referenced": [
|
|
1153
|
+
{"task_id": str(redact_secrets(task_id)), "asset_id": str(redact_secrets(asset_id))}
|
|
1154
|
+
for task_id, asset_id in report.deleted_referenced
|
|
1155
|
+
],
|
|
1156
|
+
"missing_referenced": [
|
|
1157
|
+
{"task_id": str(redact_secrets(task_id)), "asset_id": str(redact_secrets(asset_id))}
|
|
1158
|
+
for task_id, asset_id in report.missing_referenced
|
|
1159
|
+
],
|
|
1160
|
+
"pinned_added": _redacted_strings(list(report.pinned_added)),
|
|
1161
|
+
"ok": not report.deleted_referenced and not report.missing_referenced,
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def forge_assets_prune_legacy_result(
|
|
1166
|
+
record: ForgePlanRecord,
|
|
1167
|
+
plan: dict[str, Any],
|
|
1168
|
+
*,
|
|
1169
|
+
cfg: Any,
|
|
1170
|
+
yes: bool,
|
|
1171
|
+
) -> dict[str, Any]:
|
|
1172
|
+
if _plan_schema_version(plan) < 2:
|
|
1173
|
+
raise ProtocolError(
|
|
1174
|
+
"legacy_prune_unavailable",
|
|
1175
|
+
"Legacy asset pruning requires plan schema_version=2 after migration.",
|
|
1176
|
+
)
|
|
1177
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1178
|
+
legacy_files = _legacy_asset_files(record.paths)
|
|
1179
|
+
known_hashes = {
|
|
1180
|
+
str(asset.sha256)
|
|
1181
|
+
for asset in surface.index.records(include_deleted=True)
|
|
1182
|
+
if str(asset.sha256 or "").strip()
|
|
1183
|
+
}
|
|
1184
|
+
verified: list[str] = []
|
|
1185
|
+
unverified: list[str] = []
|
|
1186
|
+
for file_path in legacy_files:
|
|
1187
|
+
resolved = _resolve_existing_file(
|
|
1188
|
+
file_path,
|
|
1189
|
+
workspace_root=record.paths.root.resolve(),
|
|
1190
|
+
run_root=record.paths.run_dir.resolve(),
|
|
1191
|
+
not_found_code="legacy_asset_not_found",
|
|
1192
|
+
)
|
|
1193
|
+
digest = _sha256_file(resolved)
|
|
1194
|
+
rel = _repo_rel(record.paths.root, resolved)
|
|
1195
|
+
if digest in known_hashes:
|
|
1196
|
+
verified.append(rel)
|
|
1197
|
+
else:
|
|
1198
|
+
unverified.append(rel)
|
|
1199
|
+
payload: dict[str, Any] = {
|
|
1200
|
+
"session_id": record.session_id,
|
|
1201
|
+
"plan_id": record.plan_id,
|
|
1202
|
+
"verified": _redacted_strings(verified),
|
|
1203
|
+
"unverified": _redacted_strings(unverified),
|
|
1204
|
+
"deleted": [],
|
|
1205
|
+
"requires_confirmation": bool(verified) and not yes,
|
|
1206
|
+
"blocked": bool(unverified),
|
|
1207
|
+
}
|
|
1208
|
+
if unverified or not verified or not yes:
|
|
1209
|
+
return payload
|
|
1210
|
+
deleted: list[str] = []
|
|
1211
|
+
verified_set = set(verified)
|
|
1212
|
+
for file_path in legacy_files:
|
|
1213
|
+
rel = _repo_rel(record.paths.root, file_path)
|
|
1214
|
+
if rel not in verified_set:
|
|
1215
|
+
continue
|
|
1216
|
+
try:
|
|
1217
|
+
file_path.unlink()
|
|
1218
|
+
except OSError as e:
|
|
1219
|
+
raise ProtocolError(
|
|
1220
|
+
"legacy_prune_failed",
|
|
1221
|
+
str(redact_secrets(f"Failed to delete {rel}: {e}")),
|
|
1222
|
+
) from e
|
|
1223
|
+
deleted.append(rel)
|
|
1224
|
+
_remove_empty_legacy_dirs(record.paths)
|
|
1225
|
+
payload["deleted"] = _redacted_strings(deleted)
|
|
1226
|
+
payload["requires_confirmation"] = False
|
|
1227
|
+
payload["blocked"] = False
|
|
1228
|
+
return payload
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def forge_execute_preview_result(
|
|
1232
|
+
record: ForgePlanRecord,
|
|
1233
|
+
plan: dict[str, Any],
|
|
1234
|
+
*,
|
|
1235
|
+
task_ids: list[str],
|
|
1236
|
+
execution_mode: str,
|
|
1237
|
+
workspace_trusted: bool | None,
|
|
1238
|
+
sandbox_profile: str,
|
|
1239
|
+
max_steps: Any = None,
|
|
1240
|
+
no_log: bool = False,
|
|
1241
|
+
cfg: Any | None = None,
|
|
1242
|
+
) -> dict[str, Any]:
|
|
1243
|
+
selected_tasks = _select_preview_tasks(plan, task_ids)
|
|
1244
|
+
commands = list(record.verification_commands) or _string_list(
|
|
1245
|
+
plan.get("ide_verification_commands")
|
|
1246
|
+
)
|
|
1247
|
+
mode = execution_mode.strip().lower() or "review"
|
|
1248
|
+
if mode not in {"readonly", "review", "auto"}:
|
|
1249
|
+
raise ProtocolError(
|
|
1250
|
+
"invalid_mode", "Forge execute preview mode must be readonly, review, or auto."
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
sandbox = _sandbox_preview(sandbox_profile, cfg=cfg)
|
|
1254
|
+
workspace_trust_required = mode in {"review", "auto"}
|
|
1255
|
+
file_scopes = [_preview_file_scope(task) for task in selected_tasks]
|
|
1256
|
+
verification = [_preview_verification(task, commands) for task in selected_tasks]
|
|
1257
|
+
real_supported = mode in REAL_EXECUTE_SUPPORTED_MODES
|
|
1258
|
+
known_risks = _preview_known_risks(selected_tasks, real_execution_supported=real_supported)
|
|
1259
|
+
missing = _preview_missing_prerequisites(
|
|
1260
|
+
selected_tasks=selected_tasks,
|
|
1261
|
+
verification=verification,
|
|
1262
|
+
workspace_trust_required=workspace_trust_required,
|
|
1263
|
+
workspace_trusted=workspace_trusted,
|
|
1264
|
+
sandbox=sandbox,
|
|
1265
|
+
sandbox_required=mode in {"review", "auto"},
|
|
1266
|
+
)
|
|
1267
|
+
required_approvals = _preview_required_approvals(
|
|
1268
|
+
mode=mode,
|
|
1269
|
+
selected_tasks=selected_tasks,
|
|
1270
|
+
verification=verification,
|
|
1271
|
+
)
|
|
1272
|
+
preview_ready = not missing
|
|
1273
|
+
unsupported_reason = "" if real_supported else REAL_EXECUTE_AUTO_UNSUPPORTED_REASON
|
|
1274
|
+
return {
|
|
1275
|
+
"session_id": record.session_id,
|
|
1276
|
+
"plan_id": record.plan_id,
|
|
1277
|
+
"selected_task_ids": [str(task.get("id") or "") for task in selected_tasks],
|
|
1278
|
+
"execution_mode_requested": mode,
|
|
1279
|
+
"workspace_trust_required": workspace_trust_required,
|
|
1280
|
+
"workspace_trusted": workspace_trusted,
|
|
1281
|
+
"estimated_file_scopes": file_scopes,
|
|
1282
|
+
"verification_commands": verification,
|
|
1283
|
+
"required_approvals": required_approvals,
|
|
1284
|
+
"runtime_approval_requirements": _preview_runtime_approval_requirements(mode),
|
|
1285
|
+
"approval_scopes_safe": _approval_scopes_are_safe(required_approvals),
|
|
1286
|
+
"sandbox_profile": sandbox,
|
|
1287
|
+
"known_risks": _redacted_strings(known_risks),
|
|
1288
|
+
"missing_prerequisites": _redacted_strings(missing),
|
|
1289
|
+
"preview_ready": preview_ready,
|
|
1290
|
+
"real_execution_supported": real_supported,
|
|
1291
|
+
"unsupported_reason": str(redact_secrets(unsupported_reason)),
|
|
1292
|
+
"active_cancellation_supported": True,
|
|
1293
|
+
"cancellation": {
|
|
1294
|
+
"supported": True,
|
|
1295
|
+
"kind": "cooperative_checkpoint",
|
|
1296
|
+
"hard_interrupt": False,
|
|
1297
|
+
},
|
|
1298
|
+
"max_steps": _optional_positive_int(max_steps),
|
|
1299
|
+
"no_log": bool(no_log),
|
|
1300
|
+
"subagents_supported": False,
|
|
1301
|
+
"subagents_enabled": False,
|
|
1302
|
+
"subagents_policy": "disabled_for_ide_forge_execute_v1",
|
|
1303
|
+
"next_recommended_action": _preview_next_action(
|
|
1304
|
+
preview_ready=preview_ready,
|
|
1305
|
+
missing_prerequisites=missing,
|
|
1306
|
+
real_execution_supported=real_supported,
|
|
1307
|
+
),
|
|
1308
|
+
"status": "preview",
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def list_persisted_forge_plans(
|
|
1313
|
+
*,
|
|
1314
|
+
workspace_root: Path,
|
|
1315
|
+
session_id: str | None = None,
|
|
1316
|
+
active_records: list[ForgePlanRecord] | None = None,
|
|
1317
|
+
max_items: Any = MAX_LISTED_PLANS,
|
|
1318
|
+
) -> dict[str, Any]:
|
|
1319
|
+
limit = _bounded_plan_count(max_items)
|
|
1320
|
+
active_by_plan = {record.plan_id: record for record in active_records or []}
|
|
1321
|
+
plans: list[dict[str, Any]] = []
|
|
1322
|
+
for paths, plan in _iter_persisted_plans(workspace_root, limit=limit * 2):
|
|
1323
|
+
active = active_by_plan.get(paths.run_id)
|
|
1324
|
+
source = "active_memory" if active is not None else "persisted"
|
|
1325
|
+
plans.append(_plan_summary(paths=paths, plan=plan, session_id=session_id, source=source))
|
|
1326
|
+
plans.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
|
1327
|
+
return {
|
|
1328
|
+
"workspace_root": str(workspace_root),
|
|
1329
|
+
"plans": plans[:limit],
|
|
1330
|
+
"truncated": len(plans) > limit,
|
|
1331
|
+
"max_items": limit,
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def open_persisted_forge_plan(
|
|
1336
|
+
*,
|
|
1337
|
+
workspace_root: Path,
|
|
1338
|
+
session_id: str,
|
|
1339
|
+
plan_id: str,
|
|
1340
|
+
source: str = "loaded_persisted",
|
|
1341
|
+
) -> tuple[ForgePlanRecord, dict[str, Any]]:
|
|
1342
|
+
paths = _paths_for_persisted_plan(workspace_root=workspace_root, plan_id=plan_id)
|
|
1343
|
+
try:
|
|
1344
|
+
_validate_forge_run_paths(paths, require_plan_json=True)
|
|
1345
|
+
plan = load_plan(paths)
|
|
1346
|
+
except ForgeError as e:
|
|
1347
|
+
raise ProtocolError("forge_plan_not_found", str(e)) from e
|
|
1348
|
+
return (
|
|
1349
|
+
ForgePlanRecord(
|
|
1350
|
+
session_id=session_id,
|
|
1351
|
+
plan_id=paths.run_id,
|
|
1352
|
+
paths=paths,
|
|
1353
|
+
source=source,
|
|
1354
|
+
),
|
|
1355
|
+
plan,
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def forge_artifact_root_name(plan_id: str) -> str:
|
|
1360
|
+
safe = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in plan_id)
|
|
1361
|
+
return f"forge_{safe}"
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def validate_forge_run_artifact_root(record: ForgePlanRecord) -> Path:
|
|
1365
|
+
_validate_forge_run_paths(record.paths, require_plan_json=True)
|
|
1366
|
+
return record.paths.run_dir
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def list_diff_records(records: list[ForgePlanRecord]) -> list[DiffRecord]:
|
|
1370
|
+
diffs: list[DiffRecord] = []
|
|
1371
|
+
for record in records:
|
|
1372
|
+
patches_dir = _validated_forge_patches_dir(record)
|
|
1373
|
+
if patches_dir is None:
|
|
1374
|
+
continue
|
|
1375
|
+
run_root = _resolve_existing_directory(record.paths.run_dir, code="forge_registry_rejected")
|
|
1376
|
+
workspace_root = record.paths.root.expanduser().resolve()
|
|
1377
|
+
for path in sorted(patches_dir.glob("*.diff"), key=lambda item: item.name):
|
|
1378
|
+
if path.is_symlink() or not path.is_file():
|
|
1379
|
+
continue
|
|
1380
|
+
try:
|
|
1381
|
+
resolved = path.resolve(strict=True)
|
|
1382
|
+
resolved.relative_to(workspace_root)
|
|
1383
|
+
resolved.relative_to(run_root)
|
|
1384
|
+
resolved.relative_to(patches_dir)
|
|
1385
|
+
size = path.stat().st_size
|
|
1386
|
+
except (OSError, ValueError):
|
|
1387
|
+
continue
|
|
1388
|
+
rel_path = resolved.relative_to(run_root).as_posix()
|
|
1389
|
+
try:
|
|
1390
|
+
preview = _read_limited_text(path, max_bytes=DEFAULT_MAX_DIFF_BYTES).text
|
|
1391
|
+
except ProtocolError:
|
|
1392
|
+
continue
|
|
1393
|
+
changed_files = parse_patch_changed_files(preview)
|
|
1394
|
+
file_path = _diff_file_label(changed_files)
|
|
1395
|
+
status = "truncated" if size > DEFAULT_MAX_DIFF_BYTES else "available"
|
|
1396
|
+
diffs.append(
|
|
1397
|
+
DiffRecord(
|
|
1398
|
+
diff_id=_diff_id(record, rel_path),
|
|
1399
|
+
session_id=record.session_id,
|
|
1400
|
+
plan_id=record.plan_id,
|
|
1401
|
+
job_id=record.job_id,
|
|
1402
|
+
path=path,
|
|
1403
|
+
rel_path=rel_path,
|
|
1404
|
+
file_path=str(redact_secrets(file_path)),
|
|
1405
|
+
status=status,
|
|
1406
|
+
old_label="before",
|
|
1407
|
+
new_label="after",
|
|
1408
|
+
size_bytes=size,
|
|
1409
|
+
)
|
|
1410
|
+
)
|
|
1411
|
+
return diffs
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def diff_list_result(records: list[ForgePlanRecord]) -> dict[str, Any]:
|
|
1415
|
+
diffs = list_diff_records(records)
|
|
1416
|
+
return {
|
|
1417
|
+
"diffs": [
|
|
1418
|
+
{
|
|
1419
|
+
"diff_id": record.diff_id,
|
|
1420
|
+
"session_id": record.session_id,
|
|
1421
|
+
"plan_id": record.plan_id,
|
|
1422
|
+
"job_id": record.job_id,
|
|
1423
|
+
"file_path": record.file_path,
|
|
1424
|
+
"status": record.status,
|
|
1425
|
+
"old_label": record.old_label,
|
|
1426
|
+
"new_label": record.new_label,
|
|
1427
|
+
"size_bytes": record.size_bytes,
|
|
1428
|
+
}
|
|
1429
|
+
for record in diffs
|
|
1430
|
+
],
|
|
1431
|
+
"empty_reason": (
|
|
1432
|
+
"No Forge diff artifacts are available for this scoped plan."
|
|
1433
|
+
if records and not diffs
|
|
1434
|
+
else None
|
|
1435
|
+
),
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def diff_get_result(
|
|
1440
|
+
records: list[ForgePlanRecord],
|
|
1441
|
+
*,
|
|
1442
|
+
diff_id: str,
|
|
1443
|
+
max_bytes: Any = DEFAULT_MAX_DIFF_BYTES,
|
|
1444
|
+
) -> dict[str, Any]:
|
|
1445
|
+
clean = diff_id.strip()
|
|
1446
|
+
if not clean:
|
|
1447
|
+
raise ProtocolError("missing_field", "diff_id is required.")
|
|
1448
|
+
max_bytes_int = _bounded_diff_bytes(max_bytes)
|
|
1449
|
+
matching = [record for record in list_diff_records(records) if record.diff_id == clean]
|
|
1450
|
+
if not matching:
|
|
1451
|
+
raise ProtocolError("diff_not_found", "Diff was not found.")
|
|
1452
|
+
record = matching[0]
|
|
1453
|
+
limited = _read_limited_text(record.path, max_bytes=max_bytes_int)
|
|
1454
|
+
return {
|
|
1455
|
+
"diff_id": record.diff_id,
|
|
1456
|
+
"session_id": record.session_id,
|
|
1457
|
+
"plan_id": record.plan_id,
|
|
1458
|
+
"job_id": record.job_id,
|
|
1459
|
+
"file_path": record.file_path,
|
|
1460
|
+
"old_text": None,
|
|
1461
|
+
"new_text": None,
|
|
1462
|
+
"old_artifact_id": None,
|
|
1463
|
+
"new_artifact_id": None,
|
|
1464
|
+
"unified_diff": redact_secrets(limited.text),
|
|
1465
|
+
"truncated": limited.truncated,
|
|
1466
|
+
"size_bytes": record.size_bytes,
|
|
1467
|
+
"max_bytes": max_bytes_int,
|
|
1468
|
+
"redaction": "protocol_preview",
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def forge_execute_review_job_result(
|
|
1473
|
+
record: ForgePlanRecord,
|
|
1474
|
+
plan: dict[str, Any],
|
|
1475
|
+
*,
|
|
1476
|
+
task_ids: list[str],
|
|
1477
|
+
workspace_trusted: bool | None,
|
|
1478
|
+
sandbox_profile: str,
|
|
1479
|
+
cfg: Any,
|
|
1480
|
+
surface: Any,
|
|
1481
|
+
job_id: str,
|
|
1482
|
+
max_steps: Any = None,
|
|
1483
|
+
no_log: bool = False,
|
|
1484
|
+
agent_runner: Callable[..., int] | None = None,
|
|
1485
|
+
cancellation_token: Any | None = None,
|
|
1486
|
+
) -> dict[str, Any]:
|
|
1487
|
+
_throw_if_cancelled(cancellation_token)
|
|
1488
|
+
if not task_ids:
|
|
1489
|
+
raise ProtocolError("missing_field", "forge.execute requires explicit task_ids.")
|
|
1490
|
+
if not str(getattr(cfg, "model", "") or "").strip():
|
|
1491
|
+
raise ProtocolError("config_error", "Model is not set.")
|
|
1492
|
+
preview = forge_execute_preview_result(
|
|
1493
|
+
record,
|
|
1494
|
+
plan,
|
|
1495
|
+
task_ids=task_ids,
|
|
1496
|
+
execution_mode="review",
|
|
1497
|
+
workspace_trusted=workspace_trusted,
|
|
1498
|
+
sandbox_profile=sandbox_profile,
|
|
1499
|
+
max_steps=max_steps,
|
|
1500
|
+
no_log=no_log,
|
|
1501
|
+
cfg=cfg,
|
|
1502
|
+
)
|
|
1503
|
+
if not preview["real_execution_supported"]:
|
|
1504
|
+
raise ProtocolError(
|
|
1505
|
+
"forge_execute_unsupported",
|
|
1506
|
+
preview["unsupported_reason"] or REAL_EXECUTE_AUTO_UNSUPPORTED_REASON,
|
|
1507
|
+
)
|
|
1508
|
+
if not preview["approval_scopes_safe"]:
|
|
1509
|
+
raise ProtocolError(
|
|
1510
|
+
"forge_execute_prerequisites_failed",
|
|
1511
|
+
"Forge Execute requires exact safe approval scopes.",
|
|
1512
|
+
)
|
|
1513
|
+
if not preview["preview_ready"]:
|
|
1514
|
+
blockers = "; ".join(str(item) for item in preview["missing_prerequisites"])
|
|
1515
|
+
raise ProtocolError(
|
|
1516
|
+
"forge_execute_prerequisites_failed",
|
|
1517
|
+
str(redact_secrets(blockers or "Forge Execute prerequisites are not satisfied.")),
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1520
|
+
selected_tasks = _select_preview_tasks(plan, task_ids)
|
|
1521
|
+
dependency_blockers = _selection_dependency_blockers(plan, selected_tasks)
|
|
1522
|
+
if dependency_blockers:
|
|
1523
|
+
raise ProtocolError(
|
|
1524
|
+
"forge_execute_prerequisites_failed",
|
|
1525
|
+
str(redact_secrets("; ".join(dependency_blockers))),
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
validate_forge_run_artifact_root(record)
|
|
1529
|
+
ensure_execution_dirs(record.paths)
|
|
1530
|
+
sandbox_cfg = _cfg_for_sandbox_profile(cfg, sandbox_profile.strip().lower() or "default")
|
|
1531
|
+
fallback_commands = list(record.verification_commands) or _string_list(
|
|
1532
|
+
plan.get("ide_verification_commands")
|
|
1533
|
+
)
|
|
1534
|
+
results: list[dict[str, Any]] = []
|
|
1535
|
+
all_success = True
|
|
1536
|
+
completed_task_ids = _initial_completed_task_ids(plan)
|
|
1537
|
+
surface.emit_status_update(mode="review", model=getattr(cfg, "model", None))
|
|
1538
|
+
surface.emit_info(f"forge_execute_started {record.plan_id}")
|
|
1539
|
+
for task in selected_tasks:
|
|
1540
|
+
_throw_if_cancelled(cancellation_token)
|
|
1541
|
+
dependency_blockers = _runtime_dependency_blockers(
|
|
1542
|
+
plan=plan,
|
|
1543
|
+
task=task,
|
|
1544
|
+
completed_task_ids=completed_task_ids,
|
|
1545
|
+
)
|
|
1546
|
+
if dependency_blockers:
|
|
1547
|
+
result = _block_review_task(
|
|
1548
|
+
record=record,
|
|
1549
|
+
plan=plan,
|
|
1550
|
+
task=task,
|
|
1551
|
+
summary="; ".join(dependency_blockers),
|
|
1552
|
+
surface=surface,
|
|
1553
|
+
)
|
|
1554
|
+
else:
|
|
1555
|
+
result = _run_single_review_task(
|
|
1556
|
+
record=record,
|
|
1557
|
+
plan=plan,
|
|
1558
|
+
task=task,
|
|
1559
|
+
cfg=sandbox_cfg,
|
|
1560
|
+
surface=surface,
|
|
1561
|
+
fallback_verify_commands=fallback_commands,
|
|
1562
|
+
max_steps=max_steps,
|
|
1563
|
+
no_log=no_log,
|
|
1564
|
+
agent_runner=agent_runner,
|
|
1565
|
+
cancellation_token=cancellation_token,
|
|
1566
|
+
)
|
|
1567
|
+
_throw_if_cancelled(cancellation_token)
|
|
1568
|
+
results.append(result)
|
|
1569
|
+
if result["status"] == "done":
|
|
1570
|
+
completed_task_ids.add(str(task.get("id") or "").strip())
|
|
1571
|
+
else:
|
|
1572
|
+
all_success = False
|
|
1573
|
+
save_plan(record.paths, plan)
|
|
1574
|
+
diff_count = len(list_diff_records([record]))
|
|
1575
|
+
status = "completed" if all_success else "failed"
|
|
1576
|
+
return {
|
|
1577
|
+
"session_id": record.session_id,
|
|
1578
|
+
"plan_id": record.plan_id,
|
|
1579
|
+
"job_id": job_id,
|
|
1580
|
+
"status": status,
|
|
1581
|
+
"selected_task_ids": [str(task.get("id") or "") for task in selected_tasks],
|
|
1582
|
+
"task_results": results,
|
|
1583
|
+
"diff_count": diff_count,
|
|
1584
|
+
"max_steps": _optional_positive_int(max_steps),
|
|
1585
|
+
"no_log": bool(no_log),
|
|
1586
|
+
"subagents_supported": False,
|
|
1587
|
+
"subagents_enabled": False,
|
|
1588
|
+
"subagents_policy": "disabled_for_ide_forge_execute_v1",
|
|
1589
|
+
"active_cancellation_supported": True,
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _run_single_review_task(
|
|
1594
|
+
*,
|
|
1595
|
+
record: ForgePlanRecord,
|
|
1596
|
+
plan: dict[str, Any],
|
|
1597
|
+
task: dict[str, Any],
|
|
1598
|
+
cfg: Any,
|
|
1599
|
+
surface: Any,
|
|
1600
|
+
fallback_verify_commands: list[str],
|
|
1601
|
+
max_steps: Any,
|
|
1602
|
+
no_log: bool,
|
|
1603
|
+
agent_runner: Callable[..., int] | None,
|
|
1604
|
+
cancellation_token: Any | None = None,
|
|
1605
|
+
) -> dict[str, Any]:
|
|
1606
|
+
_throw_if_cancelled(cancellation_token)
|
|
1607
|
+
task_id = str(task.get("id") or "").strip()
|
|
1608
|
+
title = str(task.get("title") or "").strip() or task_id
|
|
1609
|
+
paths = record.paths
|
|
1610
|
+
safe_task = safe_task_file_component(task_id)
|
|
1611
|
+
verify_commands = _verification_commands_for_task(task, fallback_verify_commands)
|
|
1612
|
+
allowed_scope = _task_write_scope_for_execution(task)
|
|
1613
|
+
started_at = now_iso()
|
|
1614
|
+
patch_path = paths.execution_patches_dir / f"{safe_task}.diff"
|
|
1615
|
+
verify_path = paths.execution_verify_dir / f"{safe_task}.txt"
|
|
1616
|
+
surface.emit_plan_node_updated(task_id, "in_progress", title)
|
|
1617
|
+
surface.emit_swarm_worker_state_changed(task_id, "running", role="forge_execute_review")
|
|
1618
|
+
|
|
1619
|
+
task_step_budget = resolve_managed_task_step_budget(
|
|
1620
|
+
cfg=cfg,
|
|
1621
|
+
plan=plan,
|
|
1622
|
+
task=task,
|
|
1623
|
+
kind="ide_forge_execute",
|
|
1624
|
+
mode="review",
|
|
1625
|
+
verification_enabled=bool(verify_commands),
|
|
1626
|
+
max_steps_override=_optional_positive_int(max_steps),
|
|
1627
|
+
attempt_count=_task_attempt_count(task),
|
|
1628
|
+
image_count=0,
|
|
1629
|
+
)
|
|
1630
|
+
instruction_bundle = build_task_execution_instruction_bundle(
|
|
1631
|
+
plan=plan,
|
|
1632
|
+
task=task,
|
|
1633
|
+
root=paths.root,
|
|
1634
|
+
cfg=cfg,
|
|
1635
|
+
role_model=str(getattr(cfg, "model", "") or ""),
|
|
1636
|
+
mode="review",
|
|
1637
|
+
yes=False,
|
|
1638
|
+
deny_write_prefixes=[".alysis"],
|
|
1639
|
+
allow_write_globs=allowed_scope,
|
|
1640
|
+
non_interactive=True,
|
|
1641
|
+
verification_enabled=bool(verify_commands),
|
|
1642
|
+
authoritative_verification_commands=verify_commands or None,
|
|
1643
|
+
api_key=None,
|
|
1644
|
+
subagents_enabled=False,
|
|
1645
|
+
)
|
|
1646
|
+
context_path = write_execution_context_artifact(
|
|
1647
|
+
run_paths=paths,
|
|
1648
|
+
task_id=task_id,
|
|
1649
|
+
context_text=instruction_bundle.artifact_text,
|
|
1650
|
+
)
|
|
1651
|
+
budget_payload = instruction_bundle.to_budget_artifact_payload()
|
|
1652
|
+
budget_payload["step_budget"] = task_step_budget.to_payload()
|
|
1653
|
+
budget_path = write_execution_budget_artifact(
|
|
1654
|
+
run_paths=paths,
|
|
1655
|
+
task_id=task_id,
|
|
1656
|
+
payload=budget_payload,
|
|
1657
|
+
)
|
|
1658
|
+
baseline = capture_task_local_workspace_baseline(
|
|
1659
|
+
paths.root, before_commit=head_commit(paths.root)
|
|
1660
|
+
)
|
|
1661
|
+
run_code = 1
|
|
1662
|
+
run_error: str | None = None
|
|
1663
|
+
verify_summary: str | None = None
|
|
1664
|
+
verify_payload: dict[str, Any] | None = None
|
|
1665
|
+
try:
|
|
1666
|
+
runner = agent_runner
|
|
1667
|
+
if runner is None:
|
|
1668
|
+
from ..agent_loop import run_agent as runner
|
|
1669
|
+
try:
|
|
1670
|
+
runner_kwargs = {
|
|
1671
|
+
"cfg": cfg,
|
|
1672
|
+
"root": paths.root,
|
|
1673
|
+
"instruction": instruction_bundle.instruction,
|
|
1674
|
+
"mode": "review",
|
|
1675
|
+
"runtime_kind": RuntimeKind.FORGE_EXEC,
|
|
1676
|
+
"yes": False,
|
|
1677
|
+
"max_steps": task_step_budget.resolved_max_steps,
|
|
1678
|
+
"no_log": no_log,
|
|
1679
|
+
"api_key_override": None,
|
|
1680
|
+
"console": None,
|
|
1681
|
+
"surface": surface,
|
|
1682
|
+
"image_paths": list(instruction_bundle.image_paths) or None,
|
|
1683
|
+
"deny_write_prefixes": [".alysis"],
|
|
1684
|
+
"allow_write_globs": allowed_scope,
|
|
1685
|
+
"non_interactive": True,
|
|
1686
|
+
"session_log_dir_override": paths.execution_sessions_dir,
|
|
1687
|
+
"session_id_override": safe_task,
|
|
1688
|
+
"usage_role": f"ide_forge_execute:{task_id}",
|
|
1689
|
+
"enable_compaction": False,
|
|
1690
|
+
"enable_tool_output_offload": True,
|
|
1691
|
+
"enable_conversation_summarization": True,
|
|
1692
|
+
"compaction_profile": "execution",
|
|
1693
|
+
"enable_chat_turn_step_budget": False,
|
|
1694
|
+
"one_shot_execution": True,
|
|
1695
|
+
"verification_enabled": bool(verify_commands),
|
|
1696
|
+
"authoritative_verification_commands": verify_commands or None,
|
|
1697
|
+
"subagents_enabled": False,
|
|
1698
|
+
}
|
|
1699
|
+
if _call_accepts_keyword(runner, "cancellation_token"):
|
|
1700
|
+
runner_kwargs["cancellation_token"] = cancellation_token
|
|
1701
|
+
run_code = int(runner(**runner_kwargs) or 0)
|
|
1702
|
+
_throw_if_cancelled(cancellation_token)
|
|
1703
|
+
except Exception as exc: # noqa: BLE001
|
|
1704
|
+
_throw_if_cancelled(cancellation_token)
|
|
1705
|
+
run_error = str(redact_secrets(f"agent failed: {exc}"))
|
|
1706
|
+
run_code = 1
|
|
1707
|
+
after_commit = head_commit(paths.root)
|
|
1708
|
+
diff = build_task_local_workspace_reporting_diff(
|
|
1709
|
+
paths.root,
|
|
1710
|
+
baseline=baseline,
|
|
1711
|
+
after_commit=after_commit,
|
|
1712
|
+
)
|
|
1713
|
+
finally:
|
|
1714
|
+
cleanup_task_local_workspace_baseline(baseline)
|
|
1715
|
+
|
|
1716
|
+
patch_path.write_text(diff.patch_text, encoding="utf-8")
|
|
1717
|
+
changed_files = list(diff.changed_files)
|
|
1718
|
+
success = run_code == 0
|
|
1719
|
+
status = "done"
|
|
1720
|
+
summary_parts: list[str] = []
|
|
1721
|
+
if run_error:
|
|
1722
|
+
success = False
|
|
1723
|
+
summary_parts.append(run_error)
|
|
1724
|
+
elif run_code != 0:
|
|
1725
|
+
success = False
|
|
1726
|
+
summary_parts.append(f"agent exited non-zero ({run_code})")
|
|
1727
|
+
if diff.inspection_error:
|
|
1728
|
+
success = False
|
|
1729
|
+
summary_parts.append(str(redact_secrets(diff.inspection_error)))
|
|
1730
|
+
scope_ok, scope_violations = check_scope(changed_files, allowed_scope, root=paths.root)
|
|
1731
|
+
if not scope_ok:
|
|
1732
|
+
success = False
|
|
1733
|
+
preview = ", ".join(scope_violations[:20])
|
|
1734
|
+
if len(scope_violations) > 20:
|
|
1735
|
+
preview += ", ..."
|
|
1736
|
+
summary_parts.append(
|
|
1737
|
+
f"out-of-scope file changes detected ({len(scope_violations)}): {preview}"
|
|
1738
|
+
)
|
|
1739
|
+
if success:
|
|
1740
|
+
verify_allowed = _request_verify_approval(surface, verify_commands)
|
|
1741
|
+
if not verify_allowed:
|
|
1742
|
+
success = False
|
|
1743
|
+
status = "verify_failed"
|
|
1744
|
+
verify_summary = "verification approval denied"
|
|
1745
|
+
summary_parts.append(verify_summary)
|
|
1746
|
+
surface.emit_verify_gate_result(
|
|
1747
|
+
_verification_command_label(verify_commands),
|
|
1748
|
+
False,
|
|
1749
|
+
verify_summary,
|
|
1750
|
+
worker_id=task_id,
|
|
1751
|
+
role="forge_execute_review",
|
|
1752
|
+
)
|
|
1753
|
+
else:
|
|
1754
|
+
verify_result = run_task_verification(
|
|
1755
|
+
root=paths.root,
|
|
1756
|
+
commands=verify_commands,
|
|
1757
|
+
artifact_path=verify_path,
|
|
1758
|
+
cfg=cfg,
|
|
1759
|
+
)
|
|
1760
|
+
verify_summary = str(redact_secrets(verify_result.summary))
|
|
1761
|
+
verify_payload = redact_secrets(
|
|
1762
|
+
verify_run_result_to_payload(root=paths.root, result=verify_result)
|
|
1763
|
+
)
|
|
1764
|
+
surface.emit_verify_gate_result(
|
|
1765
|
+
_verification_command_label(verify_commands),
|
|
1766
|
+
bool(verify_result.all_passed),
|
|
1767
|
+
verify_summary,
|
|
1768
|
+
worker_id=task_id,
|
|
1769
|
+
role="forge_execute_review",
|
|
1770
|
+
)
|
|
1771
|
+
if not verify_result.all_passed:
|
|
1772
|
+
success = False
|
|
1773
|
+
status = "verify_failed"
|
|
1774
|
+
summary_parts.append(f"verification failed: {verify_summary}")
|
|
1775
|
+
if not success and status == "done":
|
|
1776
|
+
status = "failed"
|
|
1777
|
+
if success:
|
|
1778
|
+
summary = "IDE Forge Execute review task completed with scoped artifacts and passing verification."
|
|
1779
|
+
surface.emit_review_gate_decision(
|
|
1780
|
+
"accepted",
|
|
1781
|
+
summary,
|
|
1782
|
+
worker_id=task_id,
|
|
1783
|
+
role="forge_execute_review",
|
|
1784
|
+
)
|
|
1785
|
+
else:
|
|
1786
|
+
summary = "; ".join(summary_parts) or "IDE Forge Execute review task failed."
|
|
1787
|
+
surface.emit_review_gate_decision(
|
|
1788
|
+
"blocked",
|
|
1789
|
+
summary,
|
|
1790
|
+
worker_id=task_id,
|
|
1791
|
+
role="forge_execute_review",
|
|
1792
|
+
)
|
|
1793
|
+
finished_at = now_iso()
|
|
1794
|
+
report_path = write_task_report(
|
|
1795
|
+
paths=paths,
|
|
1796
|
+
task=task,
|
|
1797
|
+
result="success" if success else "failure",
|
|
1798
|
+
result_kind="ide_review_execute",
|
|
1799
|
+
summary=str(redact_secrets(summary)),
|
|
1800
|
+
started_at=started_at,
|
|
1801
|
+
finished_at=finished_at,
|
|
1802
|
+
changed_files=changed_files,
|
|
1803
|
+
verify_commands=verify_commands,
|
|
1804
|
+
patch_path=patch_path,
|
|
1805
|
+
budget_artifact_path=budget_path,
|
|
1806
|
+
execution_log_artifacts=None,
|
|
1807
|
+
verify_artifact_path=verify_path if verify_path.exists() else None,
|
|
1808
|
+
verify_summary=verify_summary,
|
|
1809
|
+
verify_payload=verify_payload,
|
|
1810
|
+
verify_command_source="ide_forge_execute",
|
|
1811
|
+
merge_result="not merged: IDE review-mode execution leaves changes in the workspace",
|
|
1812
|
+
)
|
|
1813
|
+
set_task_status(plan, task_id, status)
|
|
1814
|
+
save_plan(paths, plan)
|
|
1815
|
+
surface.emit_plan_node_updated(task_id, status, title)
|
|
1816
|
+
surface.emit_swarm_worker_state_changed(
|
|
1817
|
+
task_id, "completed" if success else "failed", role="forge_execute_review"
|
|
1818
|
+
)
|
|
1819
|
+
if not success:
|
|
1820
|
+
surface.emit_error(
|
|
1821
|
+
"forge_execute_task_failed",
|
|
1822
|
+
summary,
|
|
1823
|
+
False,
|
|
1824
|
+
worker_id=task_id,
|
|
1825
|
+
role="forge_execute_review",
|
|
1826
|
+
)
|
|
1827
|
+
return {
|
|
1828
|
+
"task_id": str(redact_secrets(task_id)),
|
|
1829
|
+
"status": status,
|
|
1830
|
+
"success": success,
|
|
1831
|
+
"summary": str(redact_secrets(summary)),
|
|
1832
|
+
"changed_files": _redacted_strings(changed_files),
|
|
1833
|
+
"report_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, report_path)}",
|
|
1834
|
+
"patch_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, patch_path)}",
|
|
1835
|
+
"context_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, context_path)}",
|
|
1836
|
+
"verify_summary": verify_summary,
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _block_review_task(
|
|
1841
|
+
*,
|
|
1842
|
+
record: ForgePlanRecord,
|
|
1843
|
+
plan: dict[str, Any],
|
|
1844
|
+
task: dict[str, Any],
|
|
1845
|
+
summary: str,
|
|
1846
|
+
surface: Any,
|
|
1847
|
+
) -> dict[str, Any]:
|
|
1848
|
+
task_id = str(task.get("id") or "").strip()
|
|
1849
|
+
title = str(task.get("title") or "").strip() or task_id
|
|
1850
|
+
clean_summary = str(redact_secrets(summary or "Task dependencies are not satisfied."))
|
|
1851
|
+
started_at = now_iso()
|
|
1852
|
+
finished_at = now_iso()
|
|
1853
|
+
paths = record.paths
|
|
1854
|
+
ensure_execution_dirs(paths)
|
|
1855
|
+
report_path = paths.execution_reports_dir / f"{safe_task_file_component(task_id)}.md"
|
|
1856
|
+
report_path.write_text(
|
|
1857
|
+
"\n".join(
|
|
1858
|
+
[
|
|
1859
|
+
f"# Task Execution Report: {task_id}",
|
|
1860
|
+
"",
|
|
1861
|
+
f"- Task Title: {str(redact_secrets(title))}",
|
|
1862
|
+
f"- Started At: {started_at}",
|
|
1863
|
+
f"- Finished At: {finished_at}",
|
|
1864
|
+
"- Result: blocked",
|
|
1865
|
+
"- Result Kind: ide_review_execute",
|
|
1866
|
+
"- Patch: (none)",
|
|
1867
|
+
"- Verify Artifact: (none)",
|
|
1868
|
+
"- Merge Result: blocked before execution",
|
|
1869
|
+
"",
|
|
1870
|
+
"## Summary",
|
|
1871
|
+
"",
|
|
1872
|
+
clean_summary,
|
|
1873
|
+
"",
|
|
1874
|
+
]
|
|
1875
|
+
),
|
|
1876
|
+
encoding="utf-8",
|
|
1877
|
+
)
|
|
1878
|
+
set_task_status(plan, task_id, "blocked")
|
|
1879
|
+
save_plan(paths, plan)
|
|
1880
|
+
surface.emit_plan_node_updated(task_id, "blocked", title)
|
|
1881
|
+
surface.emit_swarm_worker_state_changed(task_id, "blocked", role="forge_execute_review")
|
|
1882
|
+
surface.emit_warning(clean_summary, worker_id=task_id, role="forge_execute_review")
|
|
1883
|
+
surface.emit_review_gate_decision(
|
|
1884
|
+
"blocked",
|
|
1885
|
+
clean_summary,
|
|
1886
|
+
worker_id=task_id,
|
|
1887
|
+
role="forge_execute_review",
|
|
1888
|
+
)
|
|
1889
|
+
return {
|
|
1890
|
+
"task_id": str(redact_secrets(task_id)),
|
|
1891
|
+
"status": "blocked",
|
|
1892
|
+
"success": False,
|
|
1893
|
+
"summary": clean_summary,
|
|
1894
|
+
"changed_files": [],
|
|
1895
|
+
"report_artifact_id": f"{forge_artifact_root_name(record.plan_id)}:{_repo_rel(paths.root, report_path)}",
|
|
1896
|
+
"patch_artifact_id": None,
|
|
1897
|
+
"context_artifact_id": None,
|
|
1898
|
+
"verify_summary": None,
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _repo_rel(root: Path, path: Path) -> str:
|
|
1903
|
+
try:
|
|
1904
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
1905
|
+
except (OSError, ValueError):
|
|
1906
|
+
return path.name
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def _asset_surface(record: ForgePlanRecord, *, cfg: Any) -> Any:
|
|
1910
|
+
validate_forge_run_artifact_root(record)
|
|
1911
|
+
try:
|
|
1912
|
+
return build_asset_surface(cfg=cfg, run_paths=record.paths)
|
|
1913
|
+
except AssetError as e:
|
|
1914
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _asset_entries_payload(
|
|
1918
|
+
record: ForgePlanRecord,
|
|
1919
|
+
*,
|
|
1920
|
+
cfg: Any,
|
|
1921
|
+
include_deleted: bool,
|
|
1922
|
+
) -> list[dict[str, Any]]:
|
|
1923
|
+
surface = _asset_surface(record, cfg=cfg)
|
|
1924
|
+
try:
|
|
1925
|
+
entries = surface.list_assets(include_deleted=include_deleted)
|
|
1926
|
+
except AssetError as e:
|
|
1927
|
+
raise ProtocolError("asset_error", str(redact_secrets(str(e)))) from e
|
|
1928
|
+
return [_asset_entry_payload(entry) for entry in entries]
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _asset_entry_payload(entry: AssetSurfaceEntry) -> dict[str, Any]:
|
|
1932
|
+
return redact_secrets(
|
|
1933
|
+
{
|
|
1934
|
+
"record": entry.record.to_dict(),
|
|
1935
|
+
"comprehension_status": entry.comprehension_status,
|
|
1936
|
+
"comprehension_source": entry.comprehension_source,
|
|
1937
|
+
"comprehension_summary_preview": entry.comprehension_summary_preview,
|
|
1938
|
+
"detected_language": entry.detected_language,
|
|
1939
|
+
}
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def _asset_detail_payload(detail: AssetSurfaceDetail) -> dict[str, Any]:
|
|
1944
|
+
return redact_secrets(
|
|
1945
|
+
{
|
|
1946
|
+
"record": detail.record.to_dict(),
|
|
1947
|
+
"comprehension_status": detail.comprehension_status,
|
|
1948
|
+
"comprehension": (
|
|
1949
|
+
detail.comprehension.to_dict() if detail.comprehension is not None else None
|
|
1950
|
+
),
|
|
1951
|
+
"versions": detail.versions,
|
|
1952
|
+
"extracted_text_preview": detail.extracted_text_preview,
|
|
1953
|
+
}
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
def _legacy_plan_assets_payload(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1958
|
+
assets = plan.get("assets")
|
|
1959
|
+
if not isinstance(assets, list):
|
|
1960
|
+
return []
|
|
1961
|
+
return [redact_secrets(dict(asset)) for asset in assets if isinstance(asset, dict)]
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _bind_asset_to_plan(record: ForgePlanRecord, *, surface: Any, asset_record: Any) -> list[str]:
|
|
1965
|
+
try:
|
|
1966
|
+
plan = load_plan(record.paths)
|
|
1967
|
+
active_records = surface.index.records(include_deleted=False)
|
|
1968
|
+
bound_task_ids = bind_asset_to_matching_tasks(
|
|
1969
|
+
plan=plan,
|
|
1970
|
+
record=asset_record,
|
|
1971
|
+
active_records=active_records,
|
|
1972
|
+
)
|
|
1973
|
+
if bound_task_ids:
|
|
1974
|
+
save_plan(record.paths, plan)
|
|
1975
|
+
return [str(task_id) for task_id in bound_task_ids]
|
|
1976
|
+
except (ForgeError, AssetError):
|
|
1977
|
+
return []
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def _required_task_id(value: str) -> str:
|
|
1981
|
+
clean = str(value or "").strip()
|
|
1982
|
+
if not clean:
|
|
1983
|
+
raise ProtocolError("missing_field", "task_id is required.")
|
|
1984
|
+
if len(clean) > 128 or "/" in clean or "\\" in clean:
|
|
1985
|
+
raise ProtocolError("invalid_task_id", "task_id must be an opaque Forge task id.")
|
|
1986
|
+
return clean
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def _plan_ide_revision(plan: dict[str, Any]) -> int:
|
|
1990
|
+
try:
|
|
1991
|
+
revision = int(plan.get("ide_revision", 0) or 0)
|
|
1992
|
+
except (TypeError, ValueError):
|
|
1993
|
+
revision = 0
|
|
1994
|
+
return max(0, revision)
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _check_expected_revision(plan: dict[str, Any], expected_revision: Any) -> int:
|
|
1998
|
+
current = _plan_ide_revision(plan)
|
|
1999
|
+
if expected_revision is None:
|
|
2000
|
+
return current
|
|
2001
|
+
try:
|
|
2002
|
+
expected = int(expected_revision)
|
|
2003
|
+
except (TypeError, ValueError) as e:
|
|
2004
|
+
raise ProtocolError(
|
|
2005
|
+
"invalid_revision",
|
|
2006
|
+
"expected_revision must be an integer when provided.",
|
|
2007
|
+
) from e
|
|
2008
|
+
if expected != current:
|
|
2009
|
+
raise ProtocolError(
|
|
2010
|
+
"stale_plan_revision",
|
|
2011
|
+
f"Plan revision is {current}; caller expected {expected}. Refresh plan state and retry.",
|
|
2012
|
+
)
|
|
2013
|
+
return current
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def _safe_plan_edit_text(value: str, *, field: str) -> str:
|
|
2017
|
+
text = str(value or "").strip()
|
|
2018
|
+
if "\x00" in text:
|
|
2019
|
+
raise ProtocolError("invalid_field", f"{field} must not contain NUL bytes.")
|
|
2020
|
+
if len(text) > MAX_PLAN_EDIT_TEXT_CHARS:
|
|
2021
|
+
raise ProtocolError(
|
|
2022
|
+
"invalid_field",
|
|
2023
|
+
f"{field} is too large for an IDE plan edit.",
|
|
2024
|
+
)
|
|
2025
|
+
return str(redact_secrets(text))
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def _regeneration_instruction(
|
|
2029
|
+
plan: dict[str, Any],
|
|
2030
|
+
*,
|
|
2031
|
+
instruction: str,
|
|
2032
|
+
focus: str,
|
|
2033
|
+
) -> str:
|
|
2034
|
+
if instruction:
|
|
2035
|
+
base = instruction
|
|
2036
|
+
else:
|
|
2037
|
+
goal = str(redact_secrets(str(plan.get("project_goal") or ""))).strip()
|
|
2038
|
+
summary = str(redact_secrets(str(plan.get("summary") or ""))).strip()
|
|
2039
|
+
base = "Regenerate the active Forge plan using the current workspace context."
|
|
2040
|
+
if goal:
|
|
2041
|
+
base += f" Goal: {goal}."
|
|
2042
|
+
elif summary:
|
|
2043
|
+
base += f" Current summary: {summary}."
|
|
2044
|
+
if focus:
|
|
2045
|
+
return f"{base}\n\nFocus: {focus}"
|
|
2046
|
+
return base
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
def _plan_regeneration_fingerprint(plan: dict[str, Any]) -> str:
|
|
2050
|
+
comparable = dict(plan)
|
|
2051
|
+
for key in ("updated_at", "ide_revision", "ide_regenerated_at"):
|
|
2052
|
+
comparable.pop(key, None)
|
|
2053
|
+
return json.dumps(
|
|
2054
|
+
comparable,
|
|
2055
|
+
sort_keys=True,
|
|
2056
|
+
ensure_ascii=False,
|
|
2057
|
+
separators=(",", ":"),
|
|
2058
|
+
default=str,
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _plan_assistant_payload(plan: dict[str, Any]) -> dict[str, Any]:
|
|
2063
|
+
raw = plan.get("ide_assistant")
|
|
2064
|
+
if not isinstance(raw, dict):
|
|
2065
|
+
return {"instruction": "", "updated_at": None, "source": "default"}
|
|
2066
|
+
return {
|
|
2067
|
+
"instruction": str(redact_secrets(str(raw.get("instruction") or ""))),
|
|
2068
|
+
"updated_at": str(raw.get("updated_at") or "") or None,
|
|
2069
|
+
"source": str(raw.get("source") or "ide_protocol"),
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def _required_asset_id(value: str) -> str:
|
|
2074
|
+
clean = str(value or "").strip()
|
|
2075
|
+
if not clean:
|
|
2076
|
+
raise ProtocolError("missing_field", "asset_id is required.")
|
|
2077
|
+
if ASSET_ID_PATTERN.fullmatch(clean) is None or "/" in clean or "\\" in clean:
|
|
2078
|
+
raise ProtocolError("invalid_asset_id", "asset_id must be an opaque Forge asset id.")
|
|
2079
|
+
return clean
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
def _resolve_workspace_asset_source(record: ForgePlanRecord, source_path: Path) -> Path:
|
|
2083
|
+
validate_forge_run_artifact_root(record)
|
|
2084
|
+
root = record.paths.root.expanduser().resolve()
|
|
2085
|
+
raw = Path(source_path).expanduser()
|
|
2086
|
+
candidate = raw if raw.is_absolute() else root / raw
|
|
2087
|
+
try:
|
|
2088
|
+
candidate.relative_to(root)
|
|
2089
|
+
except ValueError as e:
|
|
2090
|
+
raise ProtocolError(
|
|
2091
|
+
"asset_path_outside_workspace",
|
|
2092
|
+
"Asset source paths must stay inside the resolved workspace.",
|
|
2093
|
+
) from e
|
|
2094
|
+
if _path_or_parent_is_symlink(candidate, root=root):
|
|
2095
|
+
raise ProtocolError("asset_symlink_rejected", "Asset source paths may not be symlinks.")
|
|
2096
|
+
try:
|
|
2097
|
+
resolved = candidate.resolve(strict=False)
|
|
2098
|
+
resolved.relative_to(root)
|
|
2099
|
+
except (OSError, ValueError) as e:
|
|
2100
|
+
raise ProtocolError(
|
|
2101
|
+
"asset_path_outside_workspace",
|
|
2102
|
+
"Asset source paths must stay inside the resolved workspace.",
|
|
2103
|
+
) from e
|
|
2104
|
+
if not resolved.exists():
|
|
2105
|
+
raise ProtocolError("asset_not_found", "Asset source path does not exist.")
|
|
2106
|
+
if not resolved.is_file():
|
|
2107
|
+
raise ProtocolError("asset_not_file", "Asset source path must be a regular file.")
|
|
2108
|
+
try:
|
|
2109
|
+
size = resolved.stat().st_size
|
|
2110
|
+
except OSError as e:
|
|
2111
|
+
raise ProtocolError("asset_not_found", "Asset source path could not be inspected.") from e
|
|
2112
|
+
if size > MAX_FORGE_ASSET_BYTES:
|
|
2113
|
+
raise ProtocolError(
|
|
2114
|
+
"asset_too_large",
|
|
2115
|
+
f"Asset source exceeds the maximum supported size ({MAX_FORGE_ASSET_BYTES} bytes).",
|
|
2116
|
+
)
|
|
2117
|
+
return resolved
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _path_or_parent_is_symlink(path: Path, *, root: Path) -> bool:
|
|
2121
|
+
try:
|
|
2122
|
+
relative = path.relative_to(root)
|
|
2123
|
+
except ValueError:
|
|
2124
|
+
return True
|
|
2125
|
+
probe = root
|
|
2126
|
+
for part in relative.parts:
|
|
2127
|
+
probe = probe / part
|
|
2128
|
+
if _path_is_symlink(probe):
|
|
2129
|
+
return True
|
|
2130
|
+
if probe == path:
|
|
2131
|
+
break
|
|
2132
|
+
return False
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def _read_json_artifact(path: Path, *, root: Path) -> dict[str, Any] | None:
|
|
2136
|
+
resolved = _resolve_artifact_file_for_read(path, root=root)
|
|
2137
|
+
if resolved is None:
|
|
2138
|
+
return None
|
|
2139
|
+
try:
|
|
2140
|
+
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
2141
|
+
except (OSError, json.JSONDecodeError):
|
|
2142
|
+
return None
|
|
2143
|
+
return payload if isinstance(payload, dict) else None
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
def _read_text_artifact(path: Path, *, root: Path, max_bytes: int = DEFAULT_MAX_DIFF_BYTES) -> str:
|
|
2147
|
+
resolved = _resolve_artifact_file_for_read(path, root=root)
|
|
2148
|
+
if resolved is None:
|
|
2149
|
+
return ""
|
|
2150
|
+
return _read_limited_text(resolved, max_bytes=max_bytes).text
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def _resolve_artifact_file_for_read(path: Path, *, root: Path) -> Path | None:
|
|
2154
|
+
if _path_is_symlink(path):
|
|
2155
|
+
return None
|
|
2156
|
+
try:
|
|
2157
|
+
resolved = path.resolve(strict=True)
|
|
2158
|
+
resolved.relative_to(root.resolve())
|
|
2159
|
+
except (OSError, ValueError):
|
|
2160
|
+
return None
|
|
2161
|
+
return resolved if resolved.is_file() else None
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
def _plan_schema_version(plan: dict[str, Any]) -> int:
|
|
2165
|
+
try:
|
|
2166
|
+
return int(plan.get("schema_version", 1) or 1)
|
|
2167
|
+
except (TypeError, ValueError):
|
|
2168
|
+
return 1
|
|
2169
|
+
|
|
2170
|
+
|
|
2171
|
+
def _legacy_asset_files(run_paths: RunPaths) -> list[Path]:
|
|
2172
|
+
files: list[Path] = []
|
|
2173
|
+
for root in (run_paths.assets_dir, run_paths.assets_text_dir):
|
|
2174
|
+
if _path_is_symlink(root):
|
|
2175
|
+
_raise_forge_registry_rejected()
|
|
2176
|
+
if not root.exists():
|
|
2177
|
+
continue
|
|
2178
|
+
root_resolved = _resolve_existing_directory(root, code="forge_registry_rejected")
|
|
2179
|
+
_ensure_resolved_under(root_resolved, run_paths.root.resolve())
|
|
2180
|
+
for path in sorted(root.rglob("*")):
|
|
2181
|
+
if _path_is_symlink(path):
|
|
2182
|
+
_raise_forge_registry_rejected()
|
|
2183
|
+
if path.is_file():
|
|
2184
|
+
files.append(path)
|
|
2185
|
+
return files
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
def _sha256_file(path: Path) -> str:
|
|
2189
|
+
digest = hashlib.sha256()
|
|
2190
|
+
with path.open("rb") as handle:
|
|
2191
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
2192
|
+
digest.update(chunk)
|
|
2193
|
+
return digest.hexdigest()
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _remove_empty_legacy_dirs(run_paths: RunPaths) -> None:
|
|
2197
|
+
for root in (run_paths.assets_text_dir, run_paths.assets_dir):
|
|
2198
|
+
if not root.exists() or _path_is_symlink(root):
|
|
2199
|
+
continue
|
|
2200
|
+
for directory in sorted(
|
|
2201
|
+
[path for path in root.rglob("*") if path.is_dir() and not path.is_symlink()],
|
|
2202
|
+
key=lambda item: len(item.parts),
|
|
2203
|
+
reverse=True,
|
|
2204
|
+
):
|
|
2205
|
+
with suppress(OSError):
|
|
2206
|
+
directory.rmdir()
|
|
2207
|
+
with suppress(OSError):
|
|
2208
|
+
root.rmdir()
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
def _verification_commands_for_task(task: dict[str, Any], fallback: list[str]) -> list[str]:
|
|
2212
|
+
explicit = _string_list(task.get("verification_commands") or task.get("verify_commands"))
|
|
2213
|
+
return explicit or list(fallback)
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def _task_write_scope_for_execution(task: dict[str, Any]) -> list[str]:
|
|
2217
|
+
return _string_list(task.get("write_scope")) or _string_list(task.get("estimated_files"))
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def _request_verify_approval(surface: Any, commands: list[str]) -> bool:
|
|
2221
|
+
decision = surface.request_approval(
|
|
2222
|
+
ApprovalRequest(
|
|
2223
|
+
kind="verify_run",
|
|
2224
|
+
reason="review mode requires confirmation for exact verification commands",
|
|
2225
|
+
preview="\n".join(f"$ {command}" for command in commands),
|
|
2226
|
+
command=_verification_command_label(commands),
|
|
2227
|
+
allow_for_session_scope=exact_verify_command_set_scope(commands),
|
|
2228
|
+
)
|
|
2229
|
+
)
|
|
2230
|
+
return bool(getattr(decision, "allow", False))
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
def _verification_command_label(commands: list[str]) -> str:
|
|
2234
|
+
return commands[0] if len(commands) == 1 else f"{len(commands)} verification commands"
|
|
2235
|
+
|
|
2236
|
+
|
|
2237
|
+
def _task_attempt_count(task: dict[str, Any]) -> int:
|
|
2238
|
+
try:
|
|
2239
|
+
return max(1, int(task.get("attempts") or 0) + 1)
|
|
2240
|
+
except (TypeError, ValueError):
|
|
2241
|
+
return 1
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _optional_positive_int(value: Any) -> int | None:
|
|
2245
|
+
if value is None or value == "":
|
|
2246
|
+
return None
|
|
2247
|
+
try:
|
|
2248
|
+
parsed = int(value)
|
|
2249
|
+
except (TypeError, ValueError):
|
|
2250
|
+
return None
|
|
2251
|
+
return parsed if parsed > 0 else None
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _selection_dependency_blockers(
|
|
2255
|
+
plan: dict[str, Any],
|
|
2256
|
+
selected_tasks: list[dict[str, Any]],
|
|
2257
|
+
) -> list[str]:
|
|
2258
|
+
completed = {
|
|
2259
|
+
str(task.get("id") or "").strip()
|
|
2260
|
+
for task in _plan_tasks(plan)
|
|
2261
|
+
if str(task.get("status") or "").strip().lower() in {"done", "completed"}
|
|
2262
|
+
}
|
|
2263
|
+
blockers: list[str] = []
|
|
2264
|
+
for task in selected_tasks:
|
|
2265
|
+
task_id = str(task.get("id") or "").strip() or "task"
|
|
2266
|
+
for dep_id in _string_list(task.get("dependencies")):
|
|
2267
|
+
if dep_id not in completed:
|
|
2268
|
+
dep_task = find_task(plan, dep_id)
|
|
2269
|
+
dep_status = (
|
|
2270
|
+
str(dep_task.get("status") or "unknown").strip()
|
|
2271
|
+
if isinstance(dep_task, dict)
|
|
2272
|
+
else "missing"
|
|
2273
|
+
)
|
|
2274
|
+
blockers.append(f"{task_id}: dependency {dep_id} is not done ({dep_status}).")
|
|
2275
|
+
completed.add(task_id)
|
|
2276
|
+
return blockers
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _initial_completed_task_ids(plan: dict[str, Any]) -> set[str]:
|
|
2280
|
+
return {
|
|
2281
|
+
str(task.get("id") or "").strip()
|
|
2282
|
+
for task in _plan_tasks(plan)
|
|
2283
|
+
if str(task.get("id") or "").strip()
|
|
2284
|
+
and str(task.get("status") or "").strip().lower() in {"done", "completed"}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _runtime_dependency_blockers(
|
|
2289
|
+
*,
|
|
2290
|
+
plan: dict[str, Any],
|
|
2291
|
+
task: dict[str, Any],
|
|
2292
|
+
completed_task_ids: set[str],
|
|
2293
|
+
) -> list[str]:
|
|
2294
|
+
task_id = str(task.get("id") or "").strip() or "task"
|
|
2295
|
+
blockers: list[str] = []
|
|
2296
|
+
for dep_id in _string_list(task.get("dependencies")):
|
|
2297
|
+
if dep_id in completed_task_ids:
|
|
2298
|
+
continue
|
|
2299
|
+
dep_task = find_task(plan, dep_id)
|
|
2300
|
+
dep_status = (
|
|
2301
|
+
str(dep_task.get("status") or "unknown").strip()
|
|
2302
|
+
if isinstance(dep_task, dict)
|
|
2303
|
+
else "missing"
|
|
2304
|
+
)
|
|
2305
|
+
blockers.append(
|
|
2306
|
+
f"{task_id}: dependency {dep_id} is not completed; current status is {dep_status}."
|
|
2307
|
+
)
|
|
2308
|
+
return blockers
|
|
2309
|
+
|
|
2310
|
+
|
|
2311
|
+
def _plan_tasks(plan: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2312
|
+
tasks = plan.get("tasks")
|
|
2313
|
+
if not isinstance(tasks, list):
|
|
2314
|
+
return []
|
|
2315
|
+
return [task for task in tasks if isinstance(task, dict)]
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
def _task_to_ide_schema(
|
|
2319
|
+
task: dict[str, Any],
|
|
2320
|
+
*,
|
|
2321
|
+
verification_commands: list[str],
|
|
2322
|
+
warnings: list[str],
|
|
2323
|
+
) -> dict[str, Any]:
|
|
2324
|
+
task_id = str(task.get("id") or "")
|
|
2325
|
+
title = str(task.get("title") or "").strip() or task_id or "Untitled task"
|
|
2326
|
+
objective = str(task.get("description") or "").strip() or title
|
|
2327
|
+
explicit_verify = _string_list(task.get("verification_commands") or task.get("verify_commands"))
|
|
2328
|
+
return {
|
|
2329
|
+
"task_id": str(redact_secrets(task_id)),
|
|
2330
|
+
"title": str(redact_secrets(title)),
|
|
2331
|
+
"objective": str(redact_secrets(objective)),
|
|
2332
|
+
"file_scope": {
|
|
2333
|
+
"estimated_files": _redacted_string_list(task.get("estimated_files")),
|
|
2334
|
+
"write_scope": _redacted_string_list(task.get("write_scope")),
|
|
2335
|
+
},
|
|
2336
|
+
"acceptance_criteria": _redacted_string_list(task.get("acceptance_criteria")),
|
|
2337
|
+
"verification_commands": _redacted_strings(explicit_verify or verification_commands),
|
|
2338
|
+
"risk_notes": _redacted_string_list(
|
|
2339
|
+
task.get("risk_notes") or task.get("risks") or task.get("risk")
|
|
2340
|
+
),
|
|
2341
|
+
"dependencies": _redacted_string_list(task.get("dependencies")),
|
|
2342
|
+
"order": _optional_int(task.get("order") or task.get("sequence")),
|
|
2343
|
+
"scope_unknown_reason": str(
|
|
2344
|
+
redact_secrets(str(task.get("scope_unknown_reason") or "").strip())
|
|
2345
|
+
),
|
|
2346
|
+
"warnings": _redacted_strings(warnings),
|
|
2347
|
+
"status": str(redact_secrets(str(task.get("status") or "planned"))),
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
|
|
2351
|
+
def _string_list(value: Any) -> list[str]:
|
|
2352
|
+
if not isinstance(value, list | tuple):
|
|
2353
|
+
return []
|
|
2354
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
def _redacted_string_list(value: Any) -> list[str]:
|
|
2358
|
+
if isinstance(value, str):
|
|
2359
|
+
return _redacted_strings([value])
|
|
2360
|
+
return _redacted_strings(_string_list(value))
|
|
2361
|
+
|
|
2362
|
+
|
|
2363
|
+
def _redacted_strings(values: list[str]) -> list[str]:
|
|
2364
|
+
return [str(redact_secrets(value)) for value in values if str(value).strip()]
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
def _optional_int(value: Any) -> int | None:
|
|
2368
|
+
if isinstance(value, bool):
|
|
2369
|
+
return None
|
|
2370
|
+
try:
|
|
2371
|
+
return int(value)
|
|
2372
|
+
except (TypeError, ValueError):
|
|
2373
|
+
return None
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
def _workspace_context_payload(paths: RunPaths) -> dict[str, Any] | None:
|
|
2377
|
+
try:
|
|
2378
|
+
return ensure_workspace_context_artifacts(paths).to_dict()
|
|
2379
|
+
except Exception:
|
|
2380
|
+
return None
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
def _planner_knowledge_section(
|
|
2384
|
+
*,
|
|
2385
|
+
paths: RunPaths,
|
|
2386
|
+
plan: dict[str, Any],
|
|
2387
|
+
instruction: str,
|
|
2388
|
+
) -> str | None:
|
|
2389
|
+
try:
|
|
2390
|
+
selected = prepare_planner_knowledge(
|
|
2391
|
+
paths=paths,
|
|
2392
|
+
plan=plan,
|
|
2393
|
+
user_text=instruction,
|
|
2394
|
+
selection_label="ide_forge_plan",
|
|
2395
|
+
)
|
|
2396
|
+
return selected.render_prompt_section(
|
|
2397
|
+
workspace_root=resolve_knowledge_workspace_root(paths)
|
|
2398
|
+
)
|
|
2399
|
+
except Exception:
|
|
2400
|
+
return None
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
def _resolved_verification_commands(cfg: Any, workspace_root: Path) -> list[str]:
|
|
2404
|
+
try:
|
|
2405
|
+
return _redacted_strings(
|
|
2406
|
+
resolve_verify_commands(cfg=cfg, verify_cmd=None, root=workspace_root)
|
|
2407
|
+
)
|
|
2408
|
+
except Exception:
|
|
2409
|
+
return []
|
|
2410
|
+
|
|
2411
|
+
|
|
2412
|
+
def validate_ide_plan_quality(
|
|
2413
|
+
plan: dict[str, Any],
|
|
2414
|
+
*,
|
|
2415
|
+
verification_commands: list[str],
|
|
2416
|
+
extra_warnings: list[str] | tuple[str, ...] = (),
|
|
2417
|
+
) -> dict[str, Any]:
|
|
2418
|
+
warnings = _sanitize_warnings([*list(extra_warnings), *validate_plan(plan)])
|
|
2419
|
+
task_warnings: dict[str, list[str]] = {}
|
|
2420
|
+
tasks = _plan_tasks(plan)
|
|
2421
|
+
if not tasks:
|
|
2422
|
+
warnings.append("Forge plan has no structured tasks.")
|
|
2423
|
+
for task in tasks:
|
|
2424
|
+
task_id = str(task.get("id") or "").strip() or "task"
|
|
2425
|
+
current: list[str] = []
|
|
2426
|
+
acceptance = _string_list(task.get("acceptance_criteria"))
|
|
2427
|
+
explicit_verify = _string_list(
|
|
2428
|
+
task.get("verification_commands") or task.get("verify_commands")
|
|
2429
|
+
)
|
|
2430
|
+
estimated = _string_list(task.get("estimated_files"))
|
|
2431
|
+
write_scope = _string_list(task.get("write_scope"))
|
|
2432
|
+
scope_unknown_reason = str(task.get("scope_unknown_reason") or "").strip()
|
|
2433
|
+
if _task_is_execution_ready(task):
|
|
2434
|
+
if not acceptance:
|
|
2435
|
+
current.append(f"{task_id}: missing acceptance criteria.")
|
|
2436
|
+
if not explicit_verify and not verification_commands:
|
|
2437
|
+
current.append(f"{task_id}: missing verification commands.")
|
|
2438
|
+
if not estimated and not write_scope and not scope_unknown_reason:
|
|
2439
|
+
current.append(
|
|
2440
|
+
f"{task_id}: missing file scope and no scope_unknown_reason was recorded."
|
|
2441
|
+
)
|
|
2442
|
+
if current:
|
|
2443
|
+
task_warnings[task_id] = _sanitize_warnings(current)
|
|
2444
|
+
warnings.extend(task_warnings[task_id])
|
|
2445
|
+
sanitized = _sanitize_warnings(warnings)
|
|
2446
|
+
return {
|
|
2447
|
+
"warnings": sanitized,
|
|
2448
|
+
"task_warnings": task_warnings,
|
|
2449
|
+
"incomplete": bool(sanitized),
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
|
|
2453
|
+
def _task_is_execution_ready(task: dict[str, Any]) -> bool:
|
|
2454
|
+
if bool(task.get("analysis_only")):
|
|
2455
|
+
return False
|
|
2456
|
+
status = str(task.get("status") or "planned").strip().lower()
|
|
2457
|
+
return status not in {"blocked", "superseded", "invalidated", "done", "completed"}
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def _select_preview_tasks(plan: dict[str, Any], task_ids: list[str]) -> list[dict[str, Any]]:
|
|
2461
|
+
tasks = _plan_tasks(plan)
|
|
2462
|
+
by_id = {
|
|
2463
|
+
str(task.get("id") or "").strip(): task
|
|
2464
|
+
for task in tasks
|
|
2465
|
+
if str(task.get("id") or "").strip()
|
|
2466
|
+
}
|
|
2467
|
+
if task_ids:
|
|
2468
|
+
selected: list[dict[str, Any]] = []
|
|
2469
|
+
for task_id in task_ids:
|
|
2470
|
+
task = by_id.get(task_id)
|
|
2471
|
+
if task is None:
|
|
2472
|
+
raise ProtocolError("forge_task_not_found", f"Forge task was not found: {task_id}")
|
|
2473
|
+
selected.append(task)
|
|
2474
|
+
return selected
|
|
2475
|
+
return [task for task in tasks if _task_is_execution_ready(task)]
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
def _preview_file_scope(task: dict[str, Any]) -> dict[str, Any]:
|
|
2479
|
+
return {
|
|
2480
|
+
"task_id": str(redact_secrets(str(task.get("id") or ""))),
|
|
2481
|
+
"title": str(redact_secrets(str(task.get("title") or ""))),
|
|
2482
|
+
"estimated_files": _redacted_string_list(task.get("estimated_files")),
|
|
2483
|
+
"write_scope": _redacted_string_list(task.get("write_scope")),
|
|
2484
|
+
"scope_unknown_reason": str(
|
|
2485
|
+
redact_secrets(str(task.get("scope_unknown_reason") or "").strip())
|
|
2486
|
+
),
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
|
|
2490
|
+
def _preview_verification(task: dict[str, Any], fallback_commands: list[str]) -> dict[str, Any]:
|
|
2491
|
+
explicit = _string_list(task.get("verification_commands") or task.get("verify_commands"))
|
|
2492
|
+
commands = explicit or fallback_commands
|
|
2493
|
+
return {
|
|
2494
|
+
"task_id": str(redact_secrets(str(task.get("id") or ""))),
|
|
2495
|
+
"commands": _redacted_strings(commands),
|
|
2496
|
+
"source": "task" if explicit else ("plan_or_config" if commands else "missing"),
|
|
2497
|
+
"missing_reason": (
|
|
2498
|
+
""
|
|
2499
|
+
if commands
|
|
2500
|
+
else "No task verification commands or plan-level verification commands are available."
|
|
2501
|
+
),
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def _preview_known_risks(
|
|
2506
|
+
selected_tasks: list[dict[str, Any]],
|
|
2507
|
+
*,
|
|
2508
|
+
real_execution_supported: bool,
|
|
2509
|
+
) -> list[str]:
|
|
2510
|
+
risks = [
|
|
2511
|
+
"Active Forge runtime cancellation is cooperative and may stop at the next backend checkpoint.",
|
|
2512
|
+
]
|
|
2513
|
+
if not real_execution_supported:
|
|
2514
|
+
risks.insert(0, REAL_EXECUTE_AUTO_UNSUPPORTED_REASON)
|
|
2515
|
+
for task in selected_tasks:
|
|
2516
|
+
task_id = str(task.get("id") or "task").strip() or "task"
|
|
2517
|
+
for risk in _string_list(task.get("risk_notes") or task.get("risks") or task.get("risk")):
|
|
2518
|
+
risks.append(f"{task_id}: {risk}")
|
|
2519
|
+
for warning in _string_list(task.get("warnings")):
|
|
2520
|
+
risks.append(f"{task_id}: {warning}")
|
|
2521
|
+
return list(dict.fromkeys(risks))
|
|
2522
|
+
|
|
2523
|
+
|
|
2524
|
+
def _preview_missing_prerequisites(
|
|
2525
|
+
*,
|
|
2526
|
+
selected_tasks: list[dict[str, Any]],
|
|
2527
|
+
verification: list[dict[str, Any]],
|
|
2528
|
+
workspace_trust_required: bool,
|
|
2529
|
+
workspace_trusted: bool | None,
|
|
2530
|
+
sandbox: dict[str, Any],
|
|
2531
|
+
sandbox_required: bool,
|
|
2532
|
+
) -> list[str]:
|
|
2533
|
+
missing: list[str] = []
|
|
2534
|
+
if not selected_tasks:
|
|
2535
|
+
missing.append("No executable Forge tasks were selected.")
|
|
2536
|
+
if workspace_trust_required:
|
|
2537
|
+
if workspace_trusted is False:
|
|
2538
|
+
missing.append("Workspace Trust is required before mutating Forge execution can run.")
|
|
2539
|
+
elif workspace_trusted is None:
|
|
2540
|
+
missing.append(
|
|
2541
|
+
"Workspace Trust status is required before mutating Forge execution can run."
|
|
2542
|
+
)
|
|
2543
|
+
if sandbox.get("supported") is False:
|
|
2544
|
+
missing.append(str(sandbox.get("diagnostic") or "Sandbox profile is unsupported."))
|
|
2545
|
+
elif sandbox_required and sandbox.get("available") is not True:
|
|
2546
|
+
missing.append(str(sandbox.get("diagnostic") or "Sandbox profile is not available."))
|
|
2547
|
+
verification_by_id = {
|
|
2548
|
+
str(item.get("task_id") or ""): item for item in verification if isinstance(item, dict)
|
|
2549
|
+
}
|
|
2550
|
+
for task in selected_tasks:
|
|
2551
|
+
task_id = str(task.get("id") or "task").strip() or "task"
|
|
2552
|
+
if not _task_is_execution_ready(task):
|
|
2553
|
+
status = str(task.get("status") or "unknown").strip().lower() or "unknown"
|
|
2554
|
+
missing.append(f"{task_id}: task status '{status}' is not executable.")
|
|
2555
|
+
continue
|
|
2556
|
+
if not _string_list(task.get("acceptance_criteria")):
|
|
2557
|
+
missing.append(f"{task_id}: acceptance criteria are required before execution.")
|
|
2558
|
+
verify_item = verification_by_id.get(task_id)
|
|
2559
|
+
if not verify_item or not _string_list(verify_item.get("commands")):
|
|
2560
|
+
missing.append(f"{task_id}: verification commands are required before execution.")
|
|
2561
|
+
if _task_is_execution_ready(task):
|
|
2562
|
+
estimated = _string_list(task.get("estimated_files"))
|
|
2563
|
+
write_scope = _string_list(task.get("write_scope"))
|
|
2564
|
+
scope_unknown_reason = str(task.get("scope_unknown_reason") or "").strip()
|
|
2565
|
+
if not estimated and not write_scope and not scope_unknown_reason:
|
|
2566
|
+
missing.append(
|
|
2567
|
+
f"{task_id}: file scope is required, or scope_unknown_reason must explain why it is unknown."
|
|
2568
|
+
)
|
|
2569
|
+
return list(dict.fromkeys(missing))
|
|
2570
|
+
|
|
2571
|
+
|
|
2572
|
+
def _preview_required_approvals(
|
|
2573
|
+
*,
|
|
2574
|
+
mode: str,
|
|
2575
|
+
selected_tasks: list[dict[str, Any]],
|
|
2576
|
+
verification: list[dict[str, Any]],
|
|
2577
|
+
) -> list[dict[str, Any]]:
|
|
2578
|
+
if mode == "readonly":
|
|
2579
|
+
return []
|
|
2580
|
+
approvals: list[dict[str, Any]] = []
|
|
2581
|
+
executable_task_ids = {
|
|
2582
|
+
str(task.get("id") or "").strip()
|
|
2583
|
+
for task in selected_tasks
|
|
2584
|
+
if _task_is_execution_ready(task)
|
|
2585
|
+
}
|
|
2586
|
+
for task in selected_tasks:
|
|
2587
|
+
if not _task_is_execution_ready(task):
|
|
2588
|
+
continue
|
|
2589
|
+
task_id = str(task.get("id") or "").strip()
|
|
2590
|
+
files = _string_list(task.get("write_scope")) or _string_list(task.get("estimated_files"))
|
|
2591
|
+
if files:
|
|
2592
|
+
scope = exact_file_set_scope(files, operation="fs_write")
|
|
2593
|
+
approvals.append(
|
|
2594
|
+
{
|
|
2595
|
+
"kind": "fs_write",
|
|
2596
|
+
"task_id": str(redact_secrets(task_id)),
|
|
2597
|
+
"reason": "Forge execution may write the scoped task files.",
|
|
2598
|
+
"scope": scope,
|
|
2599
|
+
"allow_for_session_scope": scope,
|
|
2600
|
+
"allow_for_session_supported": True,
|
|
2601
|
+
}
|
|
2602
|
+
)
|
|
2603
|
+
for item in verification:
|
|
2604
|
+
task_id = str(item.get("task_id") or "").strip() if isinstance(item, dict) else ""
|
|
2605
|
+
if task_id not in executable_task_ids:
|
|
2606
|
+
continue
|
|
2607
|
+
commands = _string_list(item.get("commands")) if isinstance(item, dict) else []
|
|
2608
|
+
if commands:
|
|
2609
|
+
scope = exact_verify_command_set_scope(commands)
|
|
2610
|
+
approvals.append(
|
|
2611
|
+
{
|
|
2612
|
+
"kind": "verify_run",
|
|
2613
|
+
"task_id": str(redact_secrets(task_id)),
|
|
2614
|
+
"reason": "Forge execution may run the exact verification command set.",
|
|
2615
|
+
"scope": scope,
|
|
2616
|
+
"allow_for_session_scope": scope,
|
|
2617
|
+
"allow_for_session_supported": True,
|
|
2618
|
+
}
|
|
2619
|
+
)
|
|
2620
|
+
return redact_secrets(approvals)
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def _preview_runtime_approval_requirements(mode: str) -> list[dict[str, Any]]:
|
|
2624
|
+
if mode == "readonly":
|
|
2625
|
+
return []
|
|
2626
|
+
return redact_secrets(
|
|
2627
|
+
[
|
|
2628
|
+
{
|
|
2629
|
+
"kind": "shell_run",
|
|
2630
|
+
"reason": (
|
|
2631
|
+
"If review execution runs shell commands, each command is evaluated before "
|
|
2632
|
+
"execution and requires a host-managed exact command-hash approval."
|
|
2633
|
+
),
|
|
2634
|
+
"scope_requirement": {
|
|
2635
|
+
"type": "exact_command_hash",
|
|
2636
|
+
"deferred_until_command_known": True,
|
|
2637
|
+
},
|
|
2638
|
+
"allow_for_session_supported": True,
|
|
2639
|
+
"warning": None,
|
|
2640
|
+
},
|
|
2641
|
+
{
|
|
2642
|
+
"kind": "custom_tool_run",
|
|
2643
|
+
"reason": (
|
|
2644
|
+
"If review execution invokes a custom tool, the tool invocation is host-managed. "
|
|
2645
|
+
"Unscoped custom-tool approvals cannot be approved broadly for the session."
|
|
2646
|
+
),
|
|
2647
|
+
"scope_requirement": None,
|
|
2648
|
+
"allow_for_session_supported": False,
|
|
2649
|
+
"warning": "Allow for session is disabled unless the runtime provides an explicit safe scope.",
|
|
2650
|
+
},
|
|
2651
|
+
{
|
|
2652
|
+
"kind": "mcp_tool_run",
|
|
2653
|
+
"reason": (
|
|
2654
|
+
"If review execution invokes MCP-like tools, the invocation remains host-managed "
|
|
2655
|
+
"and must not receive broad session approval by tool kind."
|
|
2656
|
+
),
|
|
2657
|
+
"scope_requirement": None,
|
|
2658
|
+
"allow_for_session_supported": False,
|
|
2659
|
+
"warning": "Allow for session is disabled unless the runtime provides an explicit safe scope.",
|
|
2660
|
+
},
|
|
2661
|
+
]
|
|
2662
|
+
)
|
|
2663
|
+
|
|
2664
|
+
|
|
2665
|
+
def _approval_scopes_are_safe(approvals: list[dict[str, Any]]) -> bool:
|
|
2666
|
+
allowed = {"exact_file_set", "exact_verify_command_set"}
|
|
2667
|
+
for approval in approvals:
|
|
2668
|
+
scope = approval.get("allow_for_session_scope") or approval.get("scope")
|
|
2669
|
+
if not isinstance(scope, dict) or str(scope.get("type") or "") not in allowed:
|
|
2670
|
+
return False
|
|
2671
|
+
return True
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
def _sandbox_preview(raw_profile: str, *, cfg: Any | None = None) -> dict[str, Any]:
|
|
2675
|
+
profile = raw_profile.strip().lower() or "default"
|
|
2676
|
+
supported = profile in SUPPORTED_SANDBOX_PROFILES
|
|
2677
|
+
if not supported:
|
|
2678
|
+
diagnostic = f"Unsupported sandbox profile for IDE preview: {profile}"
|
|
2679
|
+
return {
|
|
2680
|
+
"requested": str(redact_secrets(profile)),
|
|
2681
|
+
"supported": False,
|
|
2682
|
+
"available": False,
|
|
2683
|
+
"diagnostic": diagnostic,
|
|
2684
|
+
}
|
|
2685
|
+
if profile == "off":
|
|
2686
|
+
diagnostic = "Sandbox profile 'off' disables sandboxing; mutating Forge execution requires an available sandbox profile."
|
|
2687
|
+
return {
|
|
2688
|
+
"requested": str(redact_secrets(profile)),
|
|
2689
|
+
"supported": True,
|
|
2690
|
+
"available": False,
|
|
2691
|
+
"diagnostic": diagnostic,
|
|
2692
|
+
}
|
|
2693
|
+
if profile == "warn":
|
|
2694
|
+
diagnostic = (
|
|
2695
|
+
"Sandbox profile 'warn' can fall back to host execution; mutating IDE Forge Execute "
|
|
2696
|
+
"requires strict sandboxing."
|
|
2697
|
+
)
|
|
2698
|
+
return {
|
|
2699
|
+
"requested": str(redact_secrets(profile)),
|
|
2700
|
+
"supported": True,
|
|
2701
|
+
"available": False,
|
|
2702
|
+
"diagnostic": diagnostic,
|
|
2703
|
+
}
|
|
2704
|
+
if cfg is None:
|
|
2705
|
+
diagnostic = (
|
|
2706
|
+
"Sandbox availability is not verified by this bridge call. Run the sandbox doctor "
|
|
2707
|
+
"or start the IDE bridge with a model-backed execution request before mutating execution."
|
|
2708
|
+
)
|
|
2709
|
+
return {
|
|
2710
|
+
"requested": str(redact_secrets(profile)),
|
|
2711
|
+
"supported": True,
|
|
2712
|
+
"available": False,
|
|
2713
|
+
"diagnostic": diagnostic,
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
sandbox_cfg = _cfg_for_sandbox_profile(cfg, profile)
|
|
2717
|
+
try:
|
|
2718
|
+
diagnostic_result = diagnose_sandbox(
|
|
2719
|
+
sandbox_cfg,
|
|
2720
|
+
include_smoke=False,
|
|
2721
|
+
include_server_image=False,
|
|
2722
|
+
)
|
|
2723
|
+
except Exception as exc: # noqa: BLE001 - diagnostics are environment-dependent
|
|
2724
|
+
return {
|
|
2725
|
+
"requested": str(redact_secrets(profile)),
|
|
2726
|
+
"supported": True,
|
|
2727
|
+
"available": False,
|
|
2728
|
+
"diagnostic": str(redact_secrets(f"Sandbox diagnostic failed: {exc}")),
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
configured_mode = str(getattr(diagnostic_result, "configured_mode", "") or "").strip().lower()
|
|
2732
|
+
available = bool(getattr(diagnostic_result, "ready", False)) and configured_mode == "strict"
|
|
2733
|
+
if available:
|
|
2734
|
+
diagnostic = (
|
|
2735
|
+
f"Sandbox ready using {diagnostic_result.selected_backend or configured_mode} "
|
|
2736
|
+
f"({configured_mode})."
|
|
2737
|
+
)
|
|
2738
|
+
elif configured_mode != "strict":
|
|
2739
|
+
diagnostic = (
|
|
2740
|
+
f"Sandbox mode is {configured_mode or 'unknown'}; mutating IDE Forge Execute "
|
|
2741
|
+
"requires strict sandboxing."
|
|
2742
|
+
)
|
|
2743
|
+
else:
|
|
2744
|
+
next_steps = "; ".join(
|
|
2745
|
+
str(item) for item in getattr(diagnostic_result, "next_steps", ()) if str(item)
|
|
2746
|
+
)
|
|
2747
|
+
status = str(getattr(diagnostic_result, "status", "") or "unavailable")
|
|
2748
|
+
diagnostic = next_steps or f"Sandbox is not available ({status})."
|
|
2749
|
+
return {
|
|
2750
|
+
"requested": str(redact_secrets(profile)),
|
|
2751
|
+
"supported": supported,
|
|
2752
|
+
"available": available,
|
|
2753
|
+
"diagnostic": str(redact_secrets(diagnostic)),
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def _cfg_for_sandbox_profile(cfg: Any, profile: str) -> Any:
|
|
2758
|
+
if profile == "default":
|
|
2759
|
+
return cfg
|
|
2760
|
+
try:
|
|
2761
|
+
effective = cfg.model_copy(deep=True)
|
|
2762
|
+
except AttributeError:
|
|
2763
|
+
effective = cfg
|
|
2764
|
+
extra_fields = dict(getattr(effective, "extra_fields", {}) or {})
|
|
2765
|
+
shell_sandbox = dict(extra_fields.get("shell_sandbox") or {})
|
|
2766
|
+
shell_sandbox["mode"] = profile
|
|
2767
|
+
extra_fields["shell_sandbox"] = shell_sandbox
|
|
2768
|
+
verify_sandbox = dict(extra_fields.get("verify_sandbox") or {})
|
|
2769
|
+
verify_sandbox["mode"] = profile
|
|
2770
|
+
extra_fields["verify_sandbox"] = verify_sandbox
|
|
2771
|
+
try:
|
|
2772
|
+
effective.extra_fields = extra_fields
|
|
2773
|
+
except Exception:
|
|
2774
|
+
pass
|
|
2775
|
+
return effective
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def _preview_next_action(
|
|
2779
|
+
*,
|
|
2780
|
+
preview_ready: bool,
|
|
2781
|
+
missing_prerequisites: list[str],
|
|
2782
|
+
real_execution_supported: bool,
|
|
2783
|
+
) -> str:
|
|
2784
|
+
if missing_prerequisites:
|
|
2785
|
+
return "Resolve the missing prerequisites, then run Forge Execute Preview again."
|
|
2786
|
+
if not real_execution_supported:
|
|
2787
|
+
return "Review the preview result. Real execution is unavailable for the requested mode."
|
|
2788
|
+
if preview_ready:
|
|
2789
|
+
return "Review the preview, then explicitly confirm real review-mode execution."
|
|
2790
|
+
return "Review the preview result before attempting execution."
|
|
2791
|
+
|
|
2792
|
+
|
|
2793
|
+
def _sanitize_warnings(values: list[str] | tuple[str, ...]) -> list[str]:
|
|
2794
|
+
return list(
|
|
2795
|
+
dict.fromkeys(str(redact_secrets(value)).strip() for value in values if str(value).strip())
|
|
2796
|
+
)
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def _write_plan_validation_artifact(*, paths: RunPaths, warnings: list[str]) -> None:
|
|
2800
|
+
lines = ["# Plan Validation", ""]
|
|
2801
|
+
if warnings:
|
|
2802
|
+
lines.extend(["## Warnings", ""])
|
|
2803
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
2804
|
+
else:
|
|
2805
|
+
lines.append("No IDE plan quality warnings.")
|
|
2806
|
+
try:
|
|
2807
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
2808
|
+
(paths.notes_dir / "plan_validation.md").write_text(
|
|
2809
|
+
"\n".join(lines).rstrip() + "\n",
|
|
2810
|
+
encoding="utf-8",
|
|
2811
|
+
)
|
|
2812
|
+
except OSError:
|
|
2813
|
+
return
|
|
2814
|
+
|
|
2815
|
+
|
|
2816
|
+
def _bounded_plan_count(value: Any) -> int:
|
|
2817
|
+
if value is None or value == "":
|
|
2818
|
+
return MAX_LISTED_PLANS
|
|
2819
|
+
try:
|
|
2820
|
+
parsed = int(value)
|
|
2821
|
+
except (TypeError, ValueError):
|
|
2822
|
+
return MAX_LISTED_PLANS
|
|
2823
|
+
return max(1, min(parsed, MAX_LISTED_PLANS))
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _iter_persisted_plans(
|
|
2827
|
+
workspace_root: Path,
|
|
2828
|
+
*,
|
|
2829
|
+
limit: int,
|
|
2830
|
+
) -> list[tuple[RunPaths, dict[str, Any]]]:
|
|
2831
|
+
registry = _validated_registry_base(workspace_root, missing_ok=True)
|
|
2832
|
+
if registry is None:
|
|
2833
|
+
return []
|
|
2834
|
+
root, _runtime_dir, runs_dir = registry
|
|
2835
|
+
out: list[tuple[RunPaths, dict[str, Any]]] = []
|
|
2836
|
+
for run_dir in sorted(runs_dir.iterdir(), key=lambda path: path.name, reverse=True):
|
|
2837
|
+
if len(out) >= limit:
|
|
2838
|
+
break
|
|
2839
|
+
if run_dir.is_symlink() or not run_dir.is_dir() or not _valid_plan_id(run_dir.name):
|
|
2840
|
+
continue
|
|
2841
|
+
try:
|
|
2842
|
+
paths = _paths_for_persisted_plan(workspace_root=root, plan_id=run_dir.name)
|
|
2843
|
+
plan = load_plan(paths)
|
|
2844
|
+
except (ForgeError, ProtocolError, OSError):
|
|
2845
|
+
continue
|
|
2846
|
+
out.append((paths, plan))
|
|
2847
|
+
return out
|
|
2848
|
+
|
|
2849
|
+
|
|
2850
|
+
def _paths_for_persisted_plan(*, workspace_root: Path, plan_id: str) -> RunPaths:
|
|
2851
|
+
clean = plan_id.strip()
|
|
2852
|
+
if not _valid_plan_id(clean):
|
|
2853
|
+
raise ProtocolError("invalid_plan_id", "plan_id must be an opaque Forge plan id.")
|
|
2854
|
+
registry = _validated_registry_base(workspace_root, missing_ok=False)
|
|
2855
|
+
if registry is None:
|
|
2856
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2857
|
+
root, _runtime_dir, runs_dir = registry
|
|
2858
|
+
paths = make_run_paths(root=root, run_id=clean)
|
|
2859
|
+
try:
|
|
2860
|
+
paths.run_dir.resolve(strict=False).relative_to(runs_dir)
|
|
2861
|
+
except (OSError, ValueError) as e:
|
|
2862
|
+
raise ProtocolError("invalid_plan_id", "plan_id escapes the Forge run registry.") from e
|
|
2863
|
+
if not paths.run_dir.exists():
|
|
2864
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2865
|
+
_validate_forge_run_paths(paths, require_plan_json=True)
|
|
2866
|
+
return paths
|
|
2867
|
+
|
|
2868
|
+
|
|
2869
|
+
def _validated_registry_base(
|
|
2870
|
+
workspace_root: Path,
|
|
2871
|
+
*,
|
|
2872
|
+
missing_ok: bool,
|
|
2873
|
+
) -> tuple[Path, Path, Path] | None:
|
|
2874
|
+
root = workspace_root.expanduser().resolve()
|
|
2875
|
+
runtime_dir = root / ".alysis"
|
|
2876
|
+
if _path_is_symlink(runtime_dir):
|
|
2877
|
+
_raise_forge_registry_rejected()
|
|
2878
|
+
if not runtime_dir.exists():
|
|
2879
|
+
if missing_ok:
|
|
2880
|
+
return None
|
|
2881
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2882
|
+
runtime_resolved = _resolve_existing_directory(runtime_dir, code="forge_registry_rejected")
|
|
2883
|
+
_ensure_resolved_under(runtime_resolved, root)
|
|
2884
|
+
runs_dir = runtime_dir / "runs"
|
|
2885
|
+
if _path_is_symlink(runs_dir):
|
|
2886
|
+
_raise_forge_registry_rejected()
|
|
2887
|
+
if not runs_dir.exists():
|
|
2888
|
+
if missing_ok:
|
|
2889
|
+
return None
|
|
2890
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2891
|
+
runs_resolved = _resolve_existing_directory(runs_dir, code="forge_registry_rejected")
|
|
2892
|
+
_ensure_resolved_under(runs_resolved, root)
|
|
2893
|
+
_ensure_resolved_under(runs_resolved, runtime_resolved)
|
|
2894
|
+
return root, runtime_resolved, runs_resolved
|
|
2895
|
+
|
|
2896
|
+
|
|
2897
|
+
def _validate_forge_run_paths(paths: RunPaths, *, require_plan_json: bool) -> None:
|
|
2898
|
+
registry = _validated_registry_base(paths.root, missing_ok=False)
|
|
2899
|
+
if registry is None:
|
|
2900
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2901
|
+
root, _runtime_dir, runs_dir = registry
|
|
2902
|
+
run_dir = paths.run_dir
|
|
2903
|
+
if _path_is_symlink(run_dir):
|
|
2904
|
+
_raise_forge_registry_rejected()
|
|
2905
|
+
if not run_dir.exists():
|
|
2906
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2907
|
+
run_resolved = _resolve_existing_directory(run_dir, code="forge_registry_rejected")
|
|
2908
|
+
_ensure_resolved_under(run_resolved, root)
|
|
2909
|
+
_ensure_resolved_under(run_resolved, runs_dir)
|
|
2910
|
+
|
|
2911
|
+
plan_dir = paths.plan_dir
|
|
2912
|
+
if _path_is_symlink(plan_dir):
|
|
2913
|
+
_raise_forge_registry_rejected()
|
|
2914
|
+
if require_plan_json and not plan_dir.exists():
|
|
2915
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2916
|
+
if plan_dir.exists():
|
|
2917
|
+
plan_resolved = _resolve_existing_directory(plan_dir, code="forge_registry_rejected")
|
|
2918
|
+
_ensure_resolved_under(plan_resolved, root)
|
|
2919
|
+
_ensure_resolved_under(plan_resolved, run_resolved)
|
|
2920
|
+
|
|
2921
|
+
if require_plan_json:
|
|
2922
|
+
_resolve_existing_file(
|
|
2923
|
+
paths.plan_json_path,
|
|
2924
|
+
workspace_root=root,
|
|
2925
|
+
run_root=run_resolved,
|
|
2926
|
+
not_found_code="forge_plan_not_found",
|
|
2927
|
+
)
|
|
2928
|
+
|
|
2929
|
+
_validate_optional_directory(paths.execution_dir, workspace_root=root, run_root=run_resolved)
|
|
2930
|
+
_validate_optional_directory(
|
|
2931
|
+
paths.execution_patches_dir,
|
|
2932
|
+
workspace_root=root,
|
|
2933
|
+
run_root=run_resolved,
|
|
2934
|
+
)
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def _validated_forge_patches_dir(record: ForgePlanRecord) -> Path | None:
|
|
2938
|
+
_validate_forge_run_paths(record.paths, require_plan_json=True)
|
|
2939
|
+
execution_dir = record.paths.execution_dir
|
|
2940
|
+
if not execution_dir.exists():
|
|
2941
|
+
return None
|
|
2942
|
+
execution_resolved = _resolve_existing_directory(
|
|
2943
|
+
execution_dir,
|
|
2944
|
+
code="forge_registry_rejected",
|
|
2945
|
+
)
|
|
2946
|
+
patches_dir = record.paths.execution_patches_dir
|
|
2947
|
+
if _path_is_symlink(patches_dir):
|
|
2948
|
+
_raise_forge_registry_rejected()
|
|
2949
|
+
if not patches_dir.exists():
|
|
2950
|
+
return None
|
|
2951
|
+
patches_resolved = _resolve_existing_directory(patches_dir, code="forge_registry_rejected")
|
|
2952
|
+
_ensure_resolved_under(patches_resolved, execution_resolved)
|
|
2953
|
+
return patches_resolved
|
|
2954
|
+
|
|
2955
|
+
|
|
2956
|
+
def _validate_optional_directory(path: Path, *, workspace_root: Path, run_root: Path) -> None:
|
|
2957
|
+
if _path_is_symlink(path):
|
|
2958
|
+
_raise_forge_registry_rejected()
|
|
2959
|
+
if not path.exists():
|
|
2960
|
+
return
|
|
2961
|
+
resolved = _resolve_existing_directory(path, code="forge_registry_rejected")
|
|
2962
|
+
_ensure_resolved_under(resolved, workspace_root)
|
|
2963
|
+
_ensure_resolved_under(resolved, run_root)
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def _resolve_existing_directory(path: Path, *, code: str) -> Path:
|
|
2967
|
+
try:
|
|
2968
|
+
if not path.is_dir():
|
|
2969
|
+
if code == "forge_plan_not_found":
|
|
2970
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.")
|
|
2971
|
+
_raise_forge_registry_rejected()
|
|
2972
|
+
return path.resolve(strict=True)
|
|
2973
|
+
except OSError as e:
|
|
2974
|
+
if code == "forge_plan_not_found":
|
|
2975
|
+
raise ProtocolError("forge_plan_not_found", "Forge plan was not found.") from e
|
|
2976
|
+
_raise_forge_registry_rejected(e)
|
|
2977
|
+
|
|
2978
|
+
|
|
2979
|
+
def _resolve_existing_file(
|
|
2980
|
+
path: Path,
|
|
2981
|
+
*,
|
|
2982
|
+
workspace_root: Path,
|
|
2983
|
+
run_root: Path,
|
|
2984
|
+
not_found_code: str,
|
|
2985
|
+
) -> Path:
|
|
2986
|
+
if _path_is_symlink(path):
|
|
2987
|
+
_raise_forge_registry_rejected()
|
|
2988
|
+
try:
|
|
2989
|
+
if not path.is_file():
|
|
2990
|
+
raise ProtocolError(not_found_code, "Forge plan was not found.")
|
|
2991
|
+
resolved = path.resolve(strict=True)
|
|
2992
|
+
except OSError as e:
|
|
2993
|
+
raise ProtocolError(not_found_code, "Forge plan was not found.") from e
|
|
2994
|
+
try:
|
|
2995
|
+
resolved.relative_to(workspace_root)
|
|
2996
|
+
resolved.relative_to(run_root)
|
|
2997
|
+
except ValueError as e:
|
|
2998
|
+
_raise_forge_registry_rejected(e)
|
|
2999
|
+
return resolved
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
def _ensure_resolved_under(path: Path, root: Path) -> None:
|
|
3003
|
+
try:
|
|
3004
|
+
path.relative_to(root)
|
|
3005
|
+
except ValueError as e:
|
|
3006
|
+
_raise_forge_registry_rejected(e)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
def _path_is_symlink(path: Path) -> bool:
|
|
3010
|
+
try:
|
|
3011
|
+
return path.is_symlink()
|
|
3012
|
+
except OSError:
|
|
3013
|
+
_raise_forge_registry_rejected()
|
|
3014
|
+
|
|
3015
|
+
|
|
3016
|
+
def _raise_forge_registry_rejected(exc: BaseException | None = None) -> None:
|
|
3017
|
+
error = ProtocolError("forge_registry_rejected", FORGE_REGISTRY_REJECTED_MESSAGE)
|
|
3018
|
+
if exc is None:
|
|
3019
|
+
raise error
|
|
3020
|
+
raise error from exc
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
def _valid_plan_id(value: str) -> bool:
|
|
3024
|
+
return bool(PLAN_ID_PATTERN.fullmatch(value)) and "/" not in value and "\\" not in value
|
|
3025
|
+
|
|
3026
|
+
|
|
3027
|
+
def _plan_summary(
|
|
3028
|
+
*,
|
|
3029
|
+
paths: RunPaths,
|
|
3030
|
+
plan: dict[str, Any],
|
|
3031
|
+
session_id: str | None,
|
|
3032
|
+
source: str,
|
|
3033
|
+
) -> dict[str, Any]:
|
|
3034
|
+
tasks = _plan_tasks(plan)
|
|
3035
|
+
root_name = forge_artifact_root_name(paths.run_id)
|
|
3036
|
+
return {
|
|
3037
|
+
"plan_id": paths.run_id,
|
|
3038
|
+
"session_id": session_id,
|
|
3039
|
+
"workspace_root": str(paths.root),
|
|
3040
|
+
"status": _plan_status(tasks),
|
|
3041
|
+
"source": source,
|
|
3042
|
+
"project_goal": str(redact_secrets(str(plan.get("project_goal") or ""))),
|
|
3043
|
+
"summary": str(redact_secrets(str(plan.get("summary") or ""))),
|
|
3044
|
+
"task_count": len(tasks),
|
|
3045
|
+
"created_at": str(plan.get("created_at") or ""),
|
|
3046
|
+
"updated_at": str(plan.get("updated_at") or ""),
|
|
3047
|
+
"plan_artifact_id": f"{root_name}:plan/plan.json",
|
|
3048
|
+
"plan_markdown_artifact_id": f"{root_name}:plan/PLAN.md",
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
|
|
3052
|
+
def _plan_status(tasks: list[dict[str, Any]]) -> str:
|
|
3053
|
+
if not tasks:
|
|
3054
|
+
return "incomplete"
|
|
3055
|
+
statuses = {str(task.get("status") or "planned").strip().lower() for task in tasks}
|
|
3056
|
+
if statuses <= {"done", "completed"}:
|
|
3057
|
+
return "completed"
|
|
3058
|
+
if "blocked" in statuses:
|
|
3059
|
+
return "blocked"
|
|
3060
|
+
return "planned"
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
def _diff_id(record: ForgePlanRecord, rel_path: str) -> str:
|
|
3064
|
+
digest = hashlib.sha256(
|
|
3065
|
+
f"{record.session_id}\0{record.plan_id}\0{rel_path}".encode("utf-8", errors="replace")
|
|
3066
|
+
).hexdigest()
|
|
3067
|
+
return f"diff_{digest[:32]}"
|
|
3068
|
+
|
|
3069
|
+
|
|
3070
|
+
def _diff_file_label(changed_files: list[str]) -> str:
|
|
3071
|
+
if not changed_files:
|
|
3072
|
+
return "(unknown)"
|
|
3073
|
+
if len(changed_files) == 1:
|
|
3074
|
+
return _safe_rel_label(changed_files[0])
|
|
3075
|
+
return f"{_safe_rel_label(changed_files[0])} (+{len(changed_files) - 1} more)"
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
|
+
def _safe_rel_label(path: str) -> str:
|
|
3079
|
+
pure = PurePosixPath(path.replace("\\", "/"))
|
|
3080
|
+
if pure.is_absolute() or any(part == ".." for part in pure.parts):
|
|
3081
|
+
return Path(path).name or "(unknown)"
|
|
3082
|
+
return pure.as_posix()
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
@dataclass(frozen=True, slots=True)
|
|
3086
|
+
class _LimitedText:
|
|
3087
|
+
text: str
|
|
3088
|
+
truncated: bool
|
|
3089
|
+
|
|
3090
|
+
|
|
3091
|
+
def _read_limited_text(path: Path, *, max_bytes: int) -> _LimitedText:
|
|
3092
|
+
try:
|
|
3093
|
+
size = path.stat().st_size
|
|
3094
|
+
with path.open("rb") as handle:
|
|
3095
|
+
payload = handle.read(max_bytes + 1)
|
|
3096
|
+
except OSError as e:
|
|
3097
|
+
raise ProtocolError("diff_not_readable", "Diff artifact was not readable.") from e
|
|
3098
|
+
truncated = len(payload) > max_bytes or size > max_bytes
|
|
3099
|
+
return _LimitedText(
|
|
3100
|
+
text=payload[:max_bytes].decode("utf-8", errors="replace"),
|
|
3101
|
+
truncated=truncated,
|
|
3102
|
+
)
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def _bounded_diff_bytes(value: Any) -> int:
|
|
3106
|
+
if value is None or value == "":
|
|
3107
|
+
return DEFAULT_MAX_DIFF_BYTES
|
|
3108
|
+
try:
|
|
3109
|
+
parsed = int(value)
|
|
3110
|
+
except (TypeError, ValueError) as e:
|
|
3111
|
+
raise ProtocolError("invalid_field", "max_bytes must be an integer.") from e
|
|
3112
|
+
return max(1, min(parsed, MAX_DIFF_BYTES))
|