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,1099 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import warnings
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from fnmatch import fnmatchcase
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from .assets.models import AssetError
|
|
11
|
+
from .assets.plan_binding import task_asset_briefing
|
|
12
|
+
from .direction_change import filter_obsolete_direction_paths
|
|
13
|
+
from .failure_category import FailureCategory
|
|
14
|
+
from .file_classification import (
|
|
15
|
+
CONTENT_SURFACE_EXTENSIONS,
|
|
16
|
+
FRONTEND_SURFACE_EXTENSIONS,
|
|
17
|
+
describe_path_kinds,
|
|
18
|
+
is_code_implementation_path,
|
|
19
|
+
is_test_path,
|
|
20
|
+
)
|
|
21
|
+
from .mcp.forge_scope import normalize_task_mcp_scope
|
|
22
|
+
from .planning_constraints import (
|
|
23
|
+
planning_constraints_from_plan,
|
|
24
|
+
task_scope_constraint_violations,
|
|
25
|
+
)
|
|
26
|
+
from .swarm_scheduler import canonical_task_status
|
|
27
|
+
from .task_readiness import (
|
|
28
|
+
EXECUTION_UNREADY_SCOPE_WARNING,
|
|
29
|
+
TASK_KIND_ANALYSIS_ONLY,
|
|
30
|
+
classify_task_lifecycle,
|
|
31
|
+
normalize_existing_task_scope_fields,
|
|
32
|
+
normalized_text_list,
|
|
33
|
+
status_is_execution_candidate,
|
|
34
|
+
task_is_missing_runnable_scope,
|
|
35
|
+
task_readiness_warning,
|
|
36
|
+
task_requires_runnable_file_scope,
|
|
37
|
+
)
|
|
38
|
+
from .task_scope import (
|
|
39
|
+
extract_forbidden_repo_path_hints,
|
|
40
|
+
extract_repo_path_hints,
|
|
41
|
+
is_agent_internal_scope_path,
|
|
42
|
+
split_normalized_repo_path_list,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_NON_EXECUTABLE_OBSOLETE_STATUSES = frozenset({"superseded", "invalidated"})
|
|
46
|
+
PlanAcceptanceRuleId = Literal["R1", "R2", "R3", "R4", "R5"]
|
|
47
|
+
|
|
48
|
+
# Attached to every R3/R4 scope issue. Execution triages out-of-scope changes rather than
|
|
49
|
+
# rejecting them outright -- adjacent changes are amended into write_scope automatically --
|
|
50
|
+
# so the planner's job is to name the directories the work lives in, not to guess the exact
|
|
51
|
+
# filenames. An exact file list that misses one file costs a round trip; a directory glob
|
|
52
|
+
# does not.
|
|
53
|
+
SCOPE_GLOB_PREFERENCE_GUIDANCE = (
|
|
54
|
+
"Prefer directory-level globs over exact file lists in write_scope: write "
|
|
55
|
+
"`src/pkg/**` or `tests/**` rather than enumerating every file you expect to touch. "
|
|
56
|
+
"Execution auto-amends adjacent changes (a new file in a declared directory, a sibling "
|
|
57
|
+
"test file, a generated artifact of a declared file) into write_scope, but an unrelated "
|
|
58
|
+
"path still blocks the task, so declare the directories the work belongs to. Keep the "
|
|
59
|
+
"globs as narrow as the work: scope a package directory, not the repository root."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _with_scope_guidance(detail: str) -> str:
|
|
64
|
+
text = str(detail or "").strip()
|
|
65
|
+
return f"{text} {SCOPE_GLOB_PREFERENCE_GUIDANCE}" if text else SCOPE_GLOB_PREFERENCE_GUIDANCE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PlannerFailedError(RuntimeError):
|
|
69
|
+
failure_category = FailureCategory.PLANNER_FAILED
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PlanAcceptanceIssue:
|
|
74
|
+
rule_id: PlanAcceptanceRuleId
|
|
75
|
+
observed: str
|
|
76
|
+
task_id: str | None = None
|
|
77
|
+
detail: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _task_ids(tasks: list[dict[str, Any]]) -> list[str]:
|
|
81
|
+
ids: list[str] = []
|
|
82
|
+
seen: set[str] = set()
|
|
83
|
+
for task in tasks:
|
|
84
|
+
task_id = str(task.get("id") or "").strip()
|
|
85
|
+
if not task_id or task_id in seen:
|
|
86
|
+
continue
|
|
87
|
+
seen.add(task_id)
|
|
88
|
+
ids.append(task_id)
|
|
89
|
+
return ids
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _string_list(value: Any) -> list[str]:
|
|
93
|
+
if not isinstance(value, list):
|
|
94
|
+
return []
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
for item in value:
|
|
97
|
+
text = str(item).strip()
|
|
98
|
+
if text:
|
|
99
|
+
out.append(text)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _raw_text_list(value: Any) -> list[str]:
|
|
104
|
+
if not isinstance(value, list):
|
|
105
|
+
return []
|
|
106
|
+
out: list[str] = []
|
|
107
|
+
for item in value:
|
|
108
|
+
text = str(item).strip()
|
|
109
|
+
if text:
|
|
110
|
+
out.append(text)
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_only_ids(only: str | None) -> set[str] | None:
|
|
115
|
+
if only is None:
|
|
116
|
+
return None
|
|
117
|
+
ids = {part.strip() for part in only.split(",") if part.strip()}
|
|
118
|
+
return ids or None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _task_id_for_issue(task: dict[str, Any], index: int) -> str:
|
|
122
|
+
return str(task.get("id") or "").strip() or f"task[{index}]"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _candidate_execution_tasks(
|
|
126
|
+
plan: dict[str, Any],
|
|
127
|
+
*,
|
|
128
|
+
retry_failed: bool,
|
|
129
|
+
retry_changes_requested: bool,
|
|
130
|
+
retry_merge_conflicts: bool,
|
|
131
|
+
only: str | None,
|
|
132
|
+
) -> list[tuple[int, dict[str, Any], str, str]]:
|
|
133
|
+
only_ids = _parse_only_ids(only)
|
|
134
|
+
tasks_raw = plan.get("tasks")
|
|
135
|
+
if not isinstance(tasks_raw, list):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
candidates: list[tuple[int, dict[str, Any], str, str]] = []
|
|
139
|
+
for index, task in enumerate(tasks_raw):
|
|
140
|
+
if not isinstance(task, dict):
|
|
141
|
+
continue
|
|
142
|
+
task_id = _task_id_for_issue(task, index)
|
|
143
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
144
|
+
if (
|
|
145
|
+
only_ids is not None
|
|
146
|
+
and task_id not in only_ids
|
|
147
|
+
and status != "ready_for_merge"
|
|
148
|
+
and not (retry_merge_conflicts and status == "merge_conflict")
|
|
149
|
+
):
|
|
150
|
+
continue
|
|
151
|
+
if not status_is_execution_candidate(
|
|
152
|
+
status,
|
|
153
|
+
retry_failed=retry_failed,
|
|
154
|
+
retry_changes_requested=retry_changes_requested,
|
|
155
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
156
|
+
):
|
|
157
|
+
continue
|
|
158
|
+
candidates.append((index, task, task_id, status))
|
|
159
|
+
return candidates
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _mutating_task_scope(task: dict[str, Any]) -> tuple[bool, list[str], list[str]]:
|
|
163
|
+
estimated_files, write_scope = normalize_existing_task_scope_fields(
|
|
164
|
+
estimated_files=task.get("estimated_files"),
|
|
165
|
+
write_scope=task.get("write_scope"),
|
|
166
|
+
)
|
|
167
|
+
requires_runnable_scope = task_requires_runnable_file_scope(
|
|
168
|
+
title=str(task.get("title") or "").strip(),
|
|
169
|
+
description=str(task.get("description") or "").strip(),
|
|
170
|
+
acceptance_criteria=normalized_text_list(task.get("acceptance_criteria")),
|
|
171
|
+
estimated_files=estimated_files,
|
|
172
|
+
write_scope=write_scope,
|
|
173
|
+
)
|
|
174
|
+
return requires_runnable_scope, estimated_files, write_scope
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _missing_field_issue(
|
|
178
|
+
*,
|
|
179
|
+
task_id: str,
|
|
180
|
+
field_name: str,
|
|
181
|
+
) -> PlanAcceptanceIssue:
|
|
182
|
+
detail = (
|
|
183
|
+
_with_scope_guidance(EXECUTION_UNREADY_SCOPE_WARNING) if field_name == "write_scope" else ""
|
|
184
|
+
)
|
|
185
|
+
return PlanAcceptanceIssue(
|
|
186
|
+
rule_id="R4",
|
|
187
|
+
task_id=task_id,
|
|
188
|
+
observed=f"missing field: {field_name}",
|
|
189
|
+
detail=detail,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _required_task_field_errors(
|
|
194
|
+
task: dict[str, Any],
|
|
195
|
+
*,
|
|
196
|
+
task_id: str,
|
|
197
|
+
) -> list[PlanAcceptanceIssue]:
|
|
198
|
+
issues: list[PlanAcceptanceIssue] = []
|
|
199
|
+
if not str(task.get("id") or "").strip():
|
|
200
|
+
issues.append(_missing_field_issue(task_id=task_id, field_name="id"))
|
|
201
|
+
if not str(task.get("title") or "").strip():
|
|
202
|
+
issues.append(_missing_field_issue(task_id=task_id, field_name="title"))
|
|
203
|
+
raw_write_scope = task.get("write_scope")
|
|
204
|
+
if not isinstance(raw_write_scope, list) or not _raw_text_list(raw_write_scope):
|
|
205
|
+
issues.append(_missing_field_issue(task_id=task_id, field_name="write_scope"))
|
|
206
|
+
return issues
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _empty_write_scope_observed(raw_write_scope: list[str]) -> str:
|
|
210
|
+
normalized, _dropped = split_normalized_repo_path_list(raw_write_scope)
|
|
211
|
+
if normalized and all(is_agent_internal_scope_path(path) for path in normalized):
|
|
212
|
+
prefixes = sorted({path.split("/", 1)[0] for path in normalized})
|
|
213
|
+
if prefixes == [".alysis"]:
|
|
214
|
+
return "all write_scope paths under .alysis/"
|
|
215
|
+
if prefixes:
|
|
216
|
+
return "all write_scope paths under agent-internal dirs: " + ", ".join(prefixes)
|
|
217
|
+
return "write_scope has no runnable user-code paths after filtering"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _metadata_only_observed(write_scope: list[str]) -> str:
|
|
221
|
+
lowered = [path.casefold() for path in write_scope]
|
|
222
|
+
if lowered and all(
|
|
223
|
+
path in {"readme", "readme.md"} or path.startswith("docs/") or path.endswith(".md")
|
|
224
|
+
for path in lowered
|
|
225
|
+
):
|
|
226
|
+
return "write_scope is README/docs only"
|
|
227
|
+
return f"write_scope has no code implementation paths (detected: {describe_path_kinds(write_scope)})"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_PRIMARY_IMPLEMENTATION_FILENAMES = frozenset(
|
|
231
|
+
{
|
|
232
|
+
"cargo.toml",
|
|
233
|
+
"go.mod",
|
|
234
|
+
"go.sum",
|
|
235
|
+
"mix.exs",
|
|
236
|
+
"package-lock.json",
|
|
237
|
+
"package.json",
|
|
238
|
+
"pnpm-lock.yaml",
|
|
239
|
+
"poetry.lock",
|
|
240
|
+
"pom.xml",
|
|
241
|
+
"pyproject.toml",
|
|
242
|
+
"requirements-dev.txt",
|
|
243
|
+
"requirements.txt",
|
|
244
|
+
"setup.cfg",
|
|
245
|
+
"setup.py",
|
|
246
|
+
"tox.ini",
|
|
247
|
+
"yarn.lock",
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
_PRIMARY_IMPLEMENTATION_EXTENSIONS = frozenset(
|
|
251
|
+
{
|
|
252
|
+
".cfg",
|
|
253
|
+
".conf",
|
|
254
|
+
".ini",
|
|
255
|
+
".toml",
|
|
256
|
+
".yaml",
|
|
257
|
+
".yml",
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
_FRONTEND_SURFACE_EXTENSIONS = FRONTEND_SURFACE_EXTENSIONS
|
|
261
|
+
_CONTENT_SURFACE_EXTENSIONS = CONTENT_SURFACE_EXTENSIONS
|
|
262
|
+
_PRIMARY_IMPLEMENTATION_SCOPE_DIRS = frozenset(
|
|
263
|
+
{
|
|
264
|
+
"app",
|
|
265
|
+
"apps",
|
|
266
|
+
"bin",
|
|
267
|
+
"cmd",
|
|
268
|
+
"lib",
|
|
269
|
+
"libs",
|
|
270
|
+
"package",
|
|
271
|
+
"packages",
|
|
272
|
+
"server",
|
|
273
|
+
"src",
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
_BROAD_SUPPORT_SCOPE_DIRS = frozenset(
|
|
277
|
+
{
|
|
278
|
+
".github",
|
|
279
|
+
".alysis",
|
|
280
|
+
"__tests__",
|
|
281
|
+
"build",
|
|
282
|
+
"coverage",
|
|
283
|
+
"dist",
|
|
284
|
+
"doc",
|
|
285
|
+
"docs",
|
|
286
|
+
"example",
|
|
287
|
+
"examples",
|
|
288
|
+
"fixture",
|
|
289
|
+
"fixtures",
|
|
290
|
+
"note",
|
|
291
|
+
"notes",
|
|
292
|
+
"spec",
|
|
293
|
+
"specs",
|
|
294
|
+
"test",
|
|
295
|
+
"tests",
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
_SUPPORT_FIXTURE_SCOPE_DIRS = frozenset(
|
|
299
|
+
{
|
|
300
|
+
"__snapshots__",
|
|
301
|
+
"fixture",
|
|
302
|
+
"fixtures",
|
|
303
|
+
"golden",
|
|
304
|
+
"goldens",
|
|
305
|
+
"sample",
|
|
306
|
+
"samples",
|
|
307
|
+
"snapshot",
|
|
308
|
+
"snapshots",
|
|
309
|
+
"test_data",
|
|
310
|
+
"test-data",
|
|
311
|
+
"testdata",
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
_SUPPORT_FIXTURE_EXTENSIONS = frozenset(
|
|
315
|
+
{
|
|
316
|
+
".csv",
|
|
317
|
+
".golden",
|
|
318
|
+
".ini",
|
|
319
|
+
".json",
|
|
320
|
+
".jsonl",
|
|
321
|
+
".out",
|
|
322
|
+
".snap",
|
|
323
|
+
".toml",
|
|
324
|
+
".txt",
|
|
325
|
+
".xml",
|
|
326
|
+
".yaml",
|
|
327
|
+
".yml",
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
_SUPPORT_SCOPE_TEXT_RE = re.compile(
|
|
331
|
+
r"(?<![A-Za-z0-9_])"
|
|
332
|
+
r"(?:changelog|coverage|docs?|doctest|documentation|examples?|jest|markdown|"
|
|
333
|
+
r"pytest|readme|regression|specs?|tests?|unittest|verification|verify|vitest)"
|
|
334
|
+
r"(?![A-Za-z0-9_])",
|
|
335
|
+
re.IGNORECASE,
|
|
336
|
+
)
|
|
337
|
+
_PRIMARY_SUPPORT_SCOPE_TEXT_RE = re.compile(
|
|
338
|
+
r"^\s*(?:(?:add|create|write|update)\s+)?"
|
|
339
|
+
r"(?:(?:[\w./-]+)\s+){0,8}"
|
|
340
|
+
r"(?:tests?|test\s+case|coverage)\b"
|
|
341
|
+
r"|^\s*(?:verify|validate|run|check)\b"
|
|
342
|
+
r"|^\s*(?:(?:add|create|write|update|sync)\s+)?"
|
|
343
|
+
r"(?:readme|docs?|documentation|changelog|manual)\b"
|
|
344
|
+
r"|^\s*document\b",
|
|
345
|
+
re.IGNORECASE,
|
|
346
|
+
)
|
|
347
|
+
_CONTENT_SCOPE_TEXT_RE = re.compile(
|
|
348
|
+
r"(?<![A-Za-z0-9_])"
|
|
349
|
+
r"(?:adoc|article|checklist|chores?|content|copy|css|docs?|documentation|"
|
|
350
|
+
r"frontend|front-end|headings?|html|landing|markdown|mdx|notes?|page|readme|"
|
|
351
|
+
r"rst|sections?|style|styles|styling|todo|todos|ui|web(?:site)?|write[- ]?up)"
|
|
352
|
+
r"(?![A-Za-z0-9_])",
|
|
353
|
+
re.IGNORECASE,
|
|
354
|
+
)
|
|
355
|
+
_IMPLEMENTATION_INTENT_TEXT_RE = re.compile(
|
|
356
|
+
r"\b(?:build|change|configure|edit|enable|fix|implement|improve|modify|patch|"
|
|
357
|
+
r"refactor|rename|repair|support|wire)\b",
|
|
358
|
+
re.IGNORECASE,
|
|
359
|
+
)
|
|
360
|
+
_SUPPORT_FIXTURE_TEXT_RE = re.compile(
|
|
361
|
+
r"\b(?:fixtures?|test\s+data|sample(?:\s+data)?|golden(?:\s+files?)?|"
|
|
362
|
+
r"snapshots?|expected\s+(?:output|data|results?))\b",
|
|
363
|
+
re.IGNORECASE,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _is_primary_implementation_path(path: str) -> bool:
|
|
368
|
+
cleaned = str(path or "").strip().replace("\\", "/").strip("/")
|
|
369
|
+
if cleaned.endswith("/**"):
|
|
370
|
+
first_part = cleaned.split("/", 1)[0].casefold()
|
|
371
|
+
if first_part in _PRIMARY_IMPLEMENTATION_SCOPE_DIRS:
|
|
372
|
+
return True
|
|
373
|
+
if is_code_implementation_path(path):
|
|
374
|
+
return True
|
|
375
|
+
pure = PurePosixPath(path)
|
|
376
|
+
name = pure.name.casefold()
|
|
377
|
+
if name in _PRIMARY_IMPLEMENTATION_FILENAMES:
|
|
378
|
+
return True
|
|
379
|
+
if pure.suffix.casefold() in _PRIMARY_IMPLEMENTATION_EXTENSIONS:
|
|
380
|
+
return True
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_task_broad_primary_implementation_scope(task: dict[str, Any], path: str) -> bool:
|
|
385
|
+
cleaned = str(path or "").strip().replace("\\", "/").strip("/")
|
|
386
|
+
if not cleaned.endswith("/**"):
|
|
387
|
+
return False
|
|
388
|
+
base = cleaned[:-3].strip("/")
|
|
389
|
+
if not base:
|
|
390
|
+
return False
|
|
391
|
+
parts = [part.casefold() for part in base.split("/") if part]
|
|
392
|
+
if not parts:
|
|
393
|
+
return False
|
|
394
|
+
if any(part.startswith(".") or part in _BROAD_SUPPORT_SCOPE_DIRS for part in parts):
|
|
395
|
+
return False
|
|
396
|
+
text = _task_text(task)
|
|
397
|
+
if not _IMPLEMENTATION_INTENT_TEXT_RE.search(text):
|
|
398
|
+
return False
|
|
399
|
+
if _task_is_explicit_support_scope(task):
|
|
400
|
+
return False
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _path_suffix(path: str) -> str:
|
|
405
|
+
return PurePosixPath(str(path or "").strip().replace("\\", "/")).suffix.casefold()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _is_content_surface_path(path: str) -> bool:
|
|
409
|
+
lowered = str(path or "").strip().replace("\\", "/").casefold()
|
|
410
|
+
if lowered in {"readme", "readme.md", "todo", "todo.md"}:
|
|
411
|
+
return True
|
|
412
|
+
if lowered.startswith(("docs/", "notes/")):
|
|
413
|
+
return True
|
|
414
|
+
return _path_suffix(path) in _CONTENT_SURFACE_EXTENSIONS
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _is_frontend_surface_path(path: str) -> bool:
|
|
418
|
+
return _path_suffix(path) in _FRONTEND_SURFACE_EXTENSIONS
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_test_surface_path(path: str) -> bool:
|
|
422
|
+
return is_test_path(path)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _is_support_surface_path(path: str) -> bool:
|
|
426
|
+
lowered = str(path or "").strip().replace("\\", "/").casefold()
|
|
427
|
+
name = PurePosixPath(lowered).name
|
|
428
|
+
if _is_content_surface_path(path) or _is_frontend_surface_path(path):
|
|
429
|
+
return True
|
|
430
|
+
if _is_test_surface_path(path):
|
|
431
|
+
return True
|
|
432
|
+
if lowered.startswith(("example/", "examples/")):
|
|
433
|
+
return True
|
|
434
|
+
return name in {
|
|
435
|
+
"changelog",
|
|
436
|
+
"changelog.md",
|
|
437
|
+
"license",
|
|
438
|
+
"license.md",
|
|
439
|
+
"licence",
|
|
440
|
+
"licence.md",
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _is_support_fixture_path(task: dict[str, Any], path: str) -> bool:
|
|
445
|
+
normalized = str(path or "").strip().replace("\\", "/").strip("/")
|
|
446
|
+
if not normalized:
|
|
447
|
+
return False
|
|
448
|
+
pure = PurePosixPath(normalized)
|
|
449
|
+
lowered_parts = tuple(part.casefold() for part in pure.parts)
|
|
450
|
+
if set(lowered_parts) & _SUPPORT_FIXTURE_SCOPE_DIRS:
|
|
451
|
+
return True
|
|
452
|
+
if len(lowered_parts) != 1:
|
|
453
|
+
if any(part in _PRIMARY_IMPLEMENTATION_SCOPE_DIRS for part in lowered_parts[:-1]):
|
|
454
|
+
return False
|
|
455
|
+
return False
|
|
456
|
+
if pure.suffix.casefold() not in _SUPPORT_FIXTURE_EXTENSIONS:
|
|
457
|
+
return False
|
|
458
|
+
return _SUPPORT_FIXTURE_TEXT_RE.search(_task_text(task)) is not None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _task_has_explicit_support_surface_scope(
|
|
462
|
+
*,
|
|
463
|
+
estimated_files: list[str],
|
|
464
|
+
write_scope: list[str],
|
|
465
|
+
) -> bool:
|
|
466
|
+
scoped_paths = [*estimated_files, *write_scope]
|
|
467
|
+
return bool(scoped_paths) and all(_is_support_surface_path(path) for path in scoped_paths)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _task_has_explicit_content_surface_scope(
|
|
471
|
+
task: dict[str, Any],
|
|
472
|
+
*,
|
|
473
|
+
estimated_files: list[str],
|
|
474
|
+
write_scope: list[str],
|
|
475
|
+
) -> bool:
|
|
476
|
+
scoped_paths = [*estimated_files, *write_scope]
|
|
477
|
+
if not scoped_paths:
|
|
478
|
+
return False
|
|
479
|
+
if not all(
|
|
480
|
+
_is_content_surface_path(path) or _is_frontend_surface_path(path) for path in scoped_paths
|
|
481
|
+
):
|
|
482
|
+
return False
|
|
483
|
+
text = _task_text(task)
|
|
484
|
+
if _CONTENT_SCOPE_TEXT_RE.search(text):
|
|
485
|
+
return True
|
|
486
|
+
path_hints = _explicit_task_path_hints(task)
|
|
487
|
+
return bool(path_hints) and all(
|
|
488
|
+
_is_content_surface_path(path) or _is_frontend_surface_path(path) for path in path_hints
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _task_text(task: dict[str, Any]) -> str:
|
|
493
|
+
parts = [
|
|
494
|
+
str(task.get("title") or ""),
|
|
495
|
+
str(task.get("description") or ""),
|
|
496
|
+
*normalized_text_list(task.get("acceptance_criteria")),
|
|
497
|
+
]
|
|
498
|
+
return " ".join(part for part in parts if part).casefold()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _task_is_explicit_support_scope(task: dict[str, Any]) -> bool:
|
|
502
|
+
text = _task_text(task)
|
|
503
|
+
if not text:
|
|
504
|
+
return False
|
|
505
|
+
title = str(task.get("title") or "").strip()
|
|
506
|
+
if _PRIMARY_SUPPORT_SCOPE_TEXT_RE.search(title):
|
|
507
|
+
return True
|
|
508
|
+
intent_text = " ".join(
|
|
509
|
+
part
|
|
510
|
+
for part in (
|
|
511
|
+
title,
|
|
512
|
+
str(task.get("description") or "").strip(),
|
|
513
|
+
)
|
|
514
|
+
if part
|
|
515
|
+
)
|
|
516
|
+
if _IMPLEMENTATION_INTENT_TEXT_RE.search(intent_text):
|
|
517
|
+
return False
|
|
518
|
+
return _SUPPORT_SCOPE_TEXT_RE.search(text) is not None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _path_identity_key(value: str) -> str:
|
|
522
|
+
normalized = str(value or "").strip().replace("\\", "/").rstrip("/")
|
|
523
|
+
if normalized.casefold() in {"readme", "readme.md"}:
|
|
524
|
+
return "__readme_alias__"
|
|
525
|
+
return normalized.casefold()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _explicit_task_path_hints(task: dict[str, Any]) -> list[str]:
|
|
529
|
+
text = "\n".join(
|
|
530
|
+
[
|
|
531
|
+
str(task.get("title") or ""),
|
|
532
|
+
str(task.get("description") or ""),
|
|
533
|
+
*normalized_text_list(task.get("acceptance_criteria")),
|
|
534
|
+
]
|
|
535
|
+
)
|
|
536
|
+
forbidden = {_path_identity_key(path) for path in extract_forbidden_repo_path_hints(text)}
|
|
537
|
+
hints, _obsolete = filter_obsolete_direction_paths(
|
|
538
|
+
extract_repo_path_hints(text),
|
|
539
|
+
latest_user_text=text,
|
|
540
|
+
task_text=text,
|
|
541
|
+
)
|
|
542
|
+
normalized, _dropped = split_normalized_repo_path_list(hints)
|
|
543
|
+
out: list[str] = []
|
|
544
|
+
for path in normalized:
|
|
545
|
+
if is_agent_internal_scope_path(path):
|
|
546
|
+
continue
|
|
547
|
+
if _path_identity_key(path) in forbidden:
|
|
548
|
+
continue
|
|
549
|
+
out.append(path)
|
|
550
|
+
return list(dict.fromkeys(out))
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _scope_entry_covers_path(scope_entry: str, path: str) -> bool:
|
|
554
|
+
normalized_scope = str(scope_entry or "").strip().replace("\\", "/").rstrip("/")
|
|
555
|
+
normalized_path = str(path or "").strip().replace("\\", "/").rstrip("/")
|
|
556
|
+
if not normalized_scope or not normalized_path:
|
|
557
|
+
return False
|
|
558
|
+
if _path_identity_key(normalized_scope) == _path_identity_key(normalized_path):
|
|
559
|
+
return True
|
|
560
|
+
if normalized_scope == normalized_path:
|
|
561
|
+
return True
|
|
562
|
+
if any(char in normalized_scope for char in "*?["):
|
|
563
|
+
return fnmatchcase(normalized_path, normalized_scope)
|
|
564
|
+
if normalized_scope.endswith("/"):
|
|
565
|
+
return normalized_path.startswith(normalized_scope)
|
|
566
|
+
return False
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _missing_explicit_scope_hints(
|
|
570
|
+
*,
|
|
571
|
+
task: dict[str, Any],
|
|
572
|
+
estimated_files: list[str],
|
|
573
|
+
write_scope: list[str],
|
|
574
|
+
) -> list[str]:
|
|
575
|
+
explicit_paths = _explicit_task_path_hints(task)
|
|
576
|
+
if not explicit_paths:
|
|
577
|
+
return []
|
|
578
|
+
scope_entries = [*estimated_files, *write_scope]
|
|
579
|
+
missing = [
|
|
580
|
+
path
|
|
581
|
+
for path in explicit_paths
|
|
582
|
+
if not any(_scope_entry_covers_path(scope, path) for scope in scope_entries)
|
|
583
|
+
]
|
|
584
|
+
return missing
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _task_needs_primary_implementation_scope(
|
|
588
|
+
task: dict[str, Any],
|
|
589
|
+
*,
|
|
590
|
+
estimated_files: list[str],
|
|
591
|
+
write_scope: list[str],
|
|
592
|
+
) -> bool:
|
|
593
|
+
if _task_is_explicit_support_scope(task) and _task_has_explicit_support_surface_scope(
|
|
594
|
+
estimated_files=estimated_files,
|
|
595
|
+
write_scope=write_scope,
|
|
596
|
+
):
|
|
597
|
+
return False
|
|
598
|
+
if _task_has_explicit_content_surface_scope(
|
|
599
|
+
task,
|
|
600
|
+
estimated_files=estimated_files,
|
|
601
|
+
write_scope=write_scope,
|
|
602
|
+
):
|
|
603
|
+
return False
|
|
604
|
+
return True
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _task_has_primary_implementation_scope(
|
|
608
|
+
task: dict[str, Any],
|
|
609
|
+
) -> bool:
|
|
610
|
+
estimated_files, write_scope = normalize_existing_task_scope_fields(
|
|
611
|
+
estimated_files=task.get("estimated_files"),
|
|
612
|
+
write_scope=task.get("write_scope"),
|
|
613
|
+
)
|
|
614
|
+
return any(
|
|
615
|
+
_is_primary_implementation_path(path)
|
|
616
|
+
or _is_task_broad_primary_implementation_scope(task, path)
|
|
617
|
+
for path in [*estimated_files, *write_scope]
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _task_has_implementation_intent(task: dict[str, Any]) -> bool:
|
|
622
|
+
if _task_is_explicit_support_scope(task):
|
|
623
|
+
return False
|
|
624
|
+
return _IMPLEMENTATION_INTENT_TEXT_RE.search(_task_text(task)) is not None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _task_has_primary_implementation_dependency(
|
|
628
|
+
task: dict[str, Any],
|
|
629
|
+
*,
|
|
630
|
+
task_by_id: dict[str, dict[str, Any]],
|
|
631
|
+
seen: set[str] | None = None,
|
|
632
|
+
) -> bool:
|
|
633
|
+
raw_deps = task.get("dependencies") or []
|
|
634
|
+
if not isinstance(raw_deps, list):
|
|
635
|
+
return False
|
|
636
|
+
seen = set(seen or ())
|
|
637
|
+
for raw_dep in raw_deps:
|
|
638
|
+
dep_id = str(raw_dep or "").strip()
|
|
639
|
+
if not dep_id or dep_id in seen:
|
|
640
|
+
continue
|
|
641
|
+
seen.add(dep_id)
|
|
642
|
+
dep_task = task_by_id.get(dep_id)
|
|
643
|
+
if dep_task is None:
|
|
644
|
+
continue
|
|
645
|
+
dep_status = canonical_task_status(str(dep_task.get("status") or ""))
|
|
646
|
+
if dep_status in _NON_EXECUTABLE_OBSOLETE_STATUSES:
|
|
647
|
+
continue
|
|
648
|
+
if _task_has_primary_implementation_scope(dep_task) or _task_has_implementation_intent(
|
|
649
|
+
dep_task
|
|
650
|
+
):
|
|
651
|
+
return True
|
|
652
|
+
if _task_has_primary_implementation_dependency(
|
|
653
|
+
dep_task,
|
|
654
|
+
task_by_id=task_by_id,
|
|
655
|
+
seen=seen,
|
|
656
|
+
):
|
|
657
|
+
return True
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _support_surface_scope_is_covered_by_implementation_dependency(
|
|
662
|
+
task: dict[str, Any],
|
|
663
|
+
*,
|
|
664
|
+
estimated_files: list[str],
|
|
665
|
+
write_scope: list[str],
|
|
666
|
+
task_by_id: dict[str, dict[str, Any]],
|
|
667
|
+
) -> bool:
|
|
668
|
+
scoped_paths = [*estimated_files, *write_scope]
|
|
669
|
+
if not scoped_paths:
|
|
670
|
+
return False
|
|
671
|
+
if not _task_is_explicit_support_scope(task):
|
|
672
|
+
return False
|
|
673
|
+
if not any(_is_support_surface_path(path) for path in scoped_paths):
|
|
674
|
+
return False
|
|
675
|
+
if any(
|
|
676
|
+
_is_primary_implementation_path(path)
|
|
677
|
+
or _is_task_broad_primary_implementation_scope(task, path)
|
|
678
|
+
for path in scoped_paths
|
|
679
|
+
):
|
|
680
|
+
return False
|
|
681
|
+
if not all(
|
|
682
|
+
_is_support_surface_path(path) or _is_support_fixture_path(task, path)
|
|
683
|
+
for path in scoped_paths
|
|
684
|
+
):
|
|
685
|
+
return False
|
|
686
|
+
return _task_has_primary_implementation_dependency(task, task_by_id=task_by_id)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _task_is_report_only_execution_candidate(task: dict[str, Any]) -> bool:
|
|
690
|
+
if task.get("analysis_only") is True:
|
|
691
|
+
return True
|
|
692
|
+
lifecycle = classify_task_lifecycle(
|
|
693
|
+
title=str(task.get("title") or "").strip(),
|
|
694
|
+
description=str(task.get("description") or "").strip(),
|
|
695
|
+
acceptance_criteria=normalized_text_list(task.get("acceptance_criteria")),
|
|
696
|
+
estimated_files=_raw_text_list(task.get("estimated_files")),
|
|
697
|
+
write_scope=_raw_text_list(task.get("write_scope")),
|
|
698
|
+
)
|
|
699
|
+
return lifecycle.kind == TASK_KIND_ANALYSIS_ONLY
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def find_plan_acceptance_issues(
|
|
703
|
+
plan: dict[str, Any],
|
|
704
|
+
*,
|
|
705
|
+
retry_failed: bool = False,
|
|
706
|
+
retry_changes_requested: bool = False,
|
|
707
|
+
retry_merge_conflicts: bool = False,
|
|
708
|
+
only: str | None = None,
|
|
709
|
+
) -> list[PlanAcceptanceIssue]:
|
|
710
|
+
tasks_raw = plan.get("tasks")
|
|
711
|
+
if not isinstance(tasks_raw, list) or not tasks_raw:
|
|
712
|
+
return [
|
|
713
|
+
PlanAcceptanceIssue(
|
|
714
|
+
rule_id="R1",
|
|
715
|
+
observed="no mutating execution candidates",
|
|
716
|
+
detail="plan.tasks is missing or empty",
|
|
717
|
+
)
|
|
718
|
+
]
|
|
719
|
+
|
|
720
|
+
candidates = _candidate_execution_tasks(
|
|
721
|
+
plan,
|
|
722
|
+
retry_failed=retry_failed,
|
|
723
|
+
retry_changes_requested=retry_changes_requested,
|
|
724
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
725
|
+
only=only,
|
|
726
|
+
)
|
|
727
|
+
task_by_id = {
|
|
728
|
+
str(task.get("id") or "").strip(): task
|
|
729
|
+
for task in tasks_raw
|
|
730
|
+
if isinstance(task, dict) and str(task.get("id") or "").strip()
|
|
731
|
+
}
|
|
732
|
+
planning_constraints = planning_constraints_from_plan(plan)
|
|
733
|
+
mutating_seen = False
|
|
734
|
+
report_only_seen = False
|
|
735
|
+
issues: list[PlanAcceptanceIssue] = []
|
|
736
|
+
for _index, task, task_id, _status in candidates:
|
|
737
|
+
requires_runnable_scope, estimated_files, write_scope = _mutating_task_scope(task)
|
|
738
|
+
if not requires_runnable_scope:
|
|
739
|
+
if _task_is_report_only_execution_candidate(task):
|
|
740
|
+
report_only_seen = True
|
|
741
|
+
if not str(task.get("id") or "").strip():
|
|
742
|
+
issues.append(_missing_field_issue(task_id=task_id, field_name="id"))
|
|
743
|
+
if not str(task.get("title") or "").strip():
|
|
744
|
+
issues.append(_missing_field_issue(task_id=task_id, field_name="title"))
|
|
745
|
+
continue
|
|
746
|
+
mutating_seen = True
|
|
747
|
+
issues.extend(_required_task_field_errors(task, task_id=task_id))
|
|
748
|
+
|
|
749
|
+
raw_write_scope = _raw_text_list(task.get("write_scope"))
|
|
750
|
+
if raw_write_scope and not write_scope:
|
|
751
|
+
issues.append(
|
|
752
|
+
PlanAcceptanceIssue(
|
|
753
|
+
rule_id="R2",
|
|
754
|
+
task_id=task_id,
|
|
755
|
+
observed=_empty_write_scope_observed(raw_write_scope),
|
|
756
|
+
detail=EXECUTION_UNREADY_SCOPE_WARNING,
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
continue
|
|
760
|
+
missing_explicit_scope = _missing_explicit_scope_hints(
|
|
761
|
+
task=task,
|
|
762
|
+
estimated_files=estimated_files,
|
|
763
|
+
write_scope=write_scope,
|
|
764
|
+
)
|
|
765
|
+
if missing_explicit_scope:
|
|
766
|
+
issues.append(
|
|
767
|
+
PlanAcceptanceIssue(
|
|
768
|
+
rule_id="R3",
|
|
769
|
+
task_id=task_id,
|
|
770
|
+
observed=(
|
|
771
|
+
"scope omits explicit task path hints: "
|
|
772
|
+
+ ", ".join(missing_explicit_scope[:8])
|
|
773
|
+
),
|
|
774
|
+
detail=_with_scope_guidance("write_scope=" + ", ".join(write_scope[:8])),
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
if (
|
|
779
|
+
write_scope
|
|
780
|
+
and not any(
|
|
781
|
+
_is_primary_implementation_path(path)
|
|
782
|
+
or _is_task_broad_primary_implementation_scope(task, path)
|
|
783
|
+
for path in [*estimated_files, *write_scope]
|
|
784
|
+
)
|
|
785
|
+
and not _support_surface_scope_is_covered_by_implementation_dependency(
|
|
786
|
+
task,
|
|
787
|
+
estimated_files=estimated_files,
|
|
788
|
+
write_scope=write_scope,
|
|
789
|
+
task_by_id=task_by_id,
|
|
790
|
+
)
|
|
791
|
+
and _task_needs_primary_implementation_scope(
|
|
792
|
+
task,
|
|
793
|
+
estimated_files=estimated_files,
|
|
794
|
+
write_scope=write_scope,
|
|
795
|
+
)
|
|
796
|
+
):
|
|
797
|
+
issues.append(
|
|
798
|
+
PlanAcceptanceIssue(
|
|
799
|
+
rule_id="R3",
|
|
800
|
+
task_id=task_id,
|
|
801
|
+
observed=_metadata_only_observed(write_scope),
|
|
802
|
+
detail=_with_scope_guidance("write_scope=" + ", ".join(write_scope[:8])),
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
constraint_violations = task_scope_constraint_violations(task, planning_constraints)
|
|
807
|
+
for violation in constraint_violations:
|
|
808
|
+
detail = (
|
|
809
|
+
f"constraint={violation.constraint_path}; evidence={violation.evidence}"
|
|
810
|
+
if violation.constraint_path
|
|
811
|
+
else f"evidence={violation.evidence}"
|
|
812
|
+
)
|
|
813
|
+
issues.append(
|
|
814
|
+
PlanAcceptanceIssue(
|
|
815
|
+
rule_id="R5",
|
|
816
|
+
task_id=task_id,
|
|
817
|
+
observed=(
|
|
818
|
+
f"scope violates planning constraints: {violation.path} "
|
|
819
|
+
f"({violation.classification}; {violation.reason_code})"
|
|
820
|
+
),
|
|
821
|
+
detail=detail,
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
if not mutating_seen and candidates and not report_only_seen:
|
|
826
|
+
issues.insert(
|
|
827
|
+
0,
|
|
828
|
+
PlanAcceptanceIssue(
|
|
829
|
+
rule_id="R1",
|
|
830
|
+
observed="no mutating execution candidates",
|
|
831
|
+
detail="execution candidates are read-only/report-only",
|
|
832
|
+
),
|
|
833
|
+
)
|
|
834
|
+
return issues
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _format_plan_acceptance_block(issues: list[PlanAcceptanceIssue]) -> str:
|
|
838
|
+
if not issues:
|
|
839
|
+
return ""
|
|
840
|
+
rendered: list[str] = []
|
|
841
|
+
for issue in issues[:5]:
|
|
842
|
+
parts = [issue.rule_id]
|
|
843
|
+
if issue.task_id:
|
|
844
|
+
parts.append(f"task={issue.task_id}")
|
|
845
|
+
parts.append(f"observed={issue.observed}")
|
|
846
|
+
if issue.detail:
|
|
847
|
+
parts.append(f"detail={issue.detail}")
|
|
848
|
+
rendered.append(" ".join(parts))
|
|
849
|
+
if len(issues) > 5:
|
|
850
|
+
rendered.append(f"+{len(issues) - 5} more")
|
|
851
|
+
return "Execution blocked: " + "; ".join(rendered)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _find_cycle(ids_in_order: list[str], deps_map: dict[str, list[str]]) -> list[str] | None:
|
|
855
|
+
white = 0
|
|
856
|
+
gray = 1
|
|
857
|
+
black = 2
|
|
858
|
+
state: dict[str, int] = {task_id: white for task_id in ids_in_order}
|
|
859
|
+
stack: list[str] = []
|
|
860
|
+
stack_index: dict[str, int] = {}
|
|
861
|
+
|
|
862
|
+
def _dfs(task_id: str) -> list[str] | None:
|
|
863
|
+
state[task_id] = gray
|
|
864
|
+
stack_index[task_id] = len(stack)
|
|
865
|
+
stack.append(task_id)
|
|
866
|
+
for dep_id in deps_map.get(task_id, []):
|
|
867
|
+
dep_state = state.get(dep_id, white)
|
|
868
|
+
if dep_state == white:
|
|
869
|
+
cycle = _dfs(dep_id)
|
|
870
|
+
if cycle is not None:
|
|
871
|
+
return cycle
|
|
872
|
+
continue
|
|
873
|
+
if dep_state == gray:
|
|
874
|
+
start = stack_index.get(dep_id, 0)
|
|
875
|
+
return stack[start:] + [dep_id]
|
|
876
|
+
|
|
877
|
+
stack.pop()
|
|
878
|
+
stack_index.pop(task_id, None)
|
|
879
|
+
state[task_id] = black
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
for task_id in ids_in_order:
|
|
883
|
+
if state.get(task_id, white) != white:
|
|
884
|
+
continue
|
|
885
|
+
cycle = _dfs(task_id)
|
|
886
|
+
if cycle is not None:
|
|
887
|
+
return cycle
|
|
888
|
+
return None
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def validate_plan(plan: dict[str, Any]) -> list[str]:
|
|
892
|
+
_warn_legacy_schema(plan)
|
|
893
|
+
tasks_raw = plan.get("tasks")
|
|
894
|
+
if not isinstance(tasks_raw, list):
|
|
895
|
+
return ["Plan validation warning: tasks field is missing or not an array."]
|
|
896
|
+
|
|
897
|
+
tasks: list[dict[str, Any]] = [task for task in tasks_raw if isinstance(task, dict)]
|
|
898
|
+
known_ids = _task_ids(tasks)
|
|
899
|
+
known_set = set(known_ids)
|
|
900
|
+
warnings: list[str] = []
|
|
901
|
+
planning_constraints = planning_constraints_from_plan(plan)
|
|
902
|
+
|
|
903
|
+
deps_map: dict[str, list[str]] = {}
|
|
904
|
+
for task in tasks:
|
|
905
|
+
task_id = str(task.get("id") or "").strip()
|
|
906
|
+
if not task_id or task_id not in known_set:
|
|
907
|
+
continue
|
|
908
|
+
status = canonical_task_status(str(task.get("status") or ""))
|
|
909
|
+
|
|
910
|
+
deps = _string_list(task.get("dependencies"))
|
|
911
|
+
deps_map[task_id] = (
|
|
912
|
+
[]
|
|
913
|
+
if status in _NON_EXECUTABLE_OBSOLETE_STATUSES
|
|
914
|
+
else [dep for dep in deps if dep in known_set]
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
for dep_id in deps:
|
|
918
|
+
if dep_id not in known_set:
|
|
919
|
+
warnings.append(f"Task {task_id} has unknown dependency id: {dep_id}")
|
|
920
|
+
else:
|
|
921
|
+
dep_task = next(
|
|
922
|
+
(
|
|
923
|
+
candidate
|
|
924
|
+
for candidate in tasks
|
|
925
|
+
if str(candidate.get("id") or "").strip() == dep_id
|
|
926
|
+
),
|
|
927
|
+
None,
|
|
928
|
+
)
|
|
929
|
+
dep_status = canonical_task_status(str((dep_task or {}).get("status") or ""))
|
|
930
|
+
if (
|
|
931
|
+
status not in _NON_EXECUTABLE_OBSOLETE_STATUSES
|
|
932
|
+
and dep_status in _NON_EXECUTABLE_OBSOLETE_STATUSES
|
|
933
|
+
):
|
|
934
|
+
warnings.append(f"Task {task_id} depends on non-executable task id: {dep_id}")
|
|
935
|
+
|
|
936
|
+
if status in _NON_EXECUTABLE_OBSOLETE_STATUSES:
|
|
937
|
+
continue
|
|
938
|
+
|
|
939
|
+
acceptance = _string_list(task.get("acceptance_criteria"))
|
|
940
|
+
if not acceptance:
|
|
941
|
+
warnings.append(f"Task {task_id} is missing acceptance_criteria")
|
|
942
|
+
|
|
943
|
+
if task_is_missing_runnable_scope(task):
|
|
944
|
+
warnings.append(
|
|
945
|
+
task_readiness_warning(
|
|
946
|
+
task_id=task_id,
|
|
947
|
+
title=str(task.get("title") or "").strip(),
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
_normalized_mcp_scope, mcp_scope_warnings = normalize_task_mcp_scope(
|
|
952
|
+
task.get("mcp_scope"),
|
|
953
|
+
warning_prefix=f"Task {task_id}",
|
|
954
|
+
)
|
|
955
|
+
warnings.extend(mcp_scope_warnings)
|
|
956
|
+
warnings.extend(_validate_task_asset_briefing_shape(task, task_id=task_id))
|
|
957
|
+
for violation in task_scope_constraint_violations(task, planning_constraints):
|
|
958
|
+
warnings.append(
|
|
959
|
+
f"Task {task_id} violates planning scope constraints: {violation.path} "
|
|
960
|
+
f"({violation.classification}; {violation.reason_code})"
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
cycle = _find_cycle(known_ids, deps_map)
|
|
964
|
+
if cycle is not None:
|
|
965
|
+
warnings.append("Circular dependency detected: " + " -> ".join(cycle))
|
|
966
|
+
|
|
967
|
+
return warnings
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _warn_legacy_schema(plan: dict[str, Any]) -> None:
|
|
971
|
+
try:
|
|
972
|
+
schema_version = int(plan.get("schema_version", 1) or 1)
|
|
973
|
+
except (TypeError, ValueError):
|
|
974
|
+
schema_version = 1
|
|
975
|
+
if schema_version <= 1:
|
|
976
|
+
warnings.warn(
|
|
977
|
+
"Plan schema_version 1 is deprecated. Load the run to migrate it to schema_version 2.",
|
|
978
|
+
DeprecationWarning,
|
|
979
|
+
stacklevel=2,
|
|
980
|
+
)
|
|
981
|
+
elif schema_version != 2:
|
|
982
|
+
warnings.warn(
|
|
983
|
+
f"Plan schema_version {schema_version} is not explicitly supported; "
|
|
984
|
+
"validation will apply the closest compatible rules.",
|
|
985
|
+
RuntimeWarning,
|
|
986
|
+
stacklevel=2,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def validate_plan_against_assets(
|
|
991
|
+
plan: dict[str, Any],
|
|
992
|
+
assets_source: Any,
|
|
993
|
+
*,
|
|
994
|
+
max_primary_per_task: int = 8,
|
|
995
|
+
) -> list[str]:
|
|
996
|
+
records = _asset_records_by_id(assets_source)
|
|
997
|
+
warnings: list[str] = []
|
|
998
|
+
for task in plan.get("tasks") or []:
|
|
999
|
+
if not isinstance(task, dict):
|
|
1000
|
+
continue
|
|
1001
|
+
task_id = str(task.get("id") or "").strip() or "(unknown)"
|
|
1002
|
+
try:
|
|
1003
|
+
briefing = task_asset_briefing(task)
|
|
1004
|
+
except AssetError as exc:
|
|
1005
|
+
warnings.append(f"Task {task_id} has invalid asset_briefing: {exc}")
|
|
1006
|
+
continue
|
|
1007
|
+
if briefing is None:
|
|
1008
|
+
continue
|
|
1009
|
+
primary_ids = [entry.asset_id for entry in briefing.primary]
|
|
1010
|
+
may_need_ids = [entry.asset_id for entry in briefing.may_need]
|
|
1011
|
+
if len(primary_ids) > max_primary_per_task:
|
|
1012
|
+
warnings.append(
|
|
1013
|
+
f"Task {task_id} references too many primary assets: "
|
|
1014
|
+
f"{len(primary_ids)} > {max_primary_per_task}"
|
|
1015
|
+
)
|
|
1016
|
+
overlap = sorted(set(primary_ids) & set(may_need_ids))
|
|
1017
|
+
if overlap:
|
|
1018
|
+
warnings.append(
|
|
1019
|
+
f"Task {task_id} references the same asset in primary and may_need: "
|
|
1020
|
+
+ ", ".join(overlap)
|
|
1021
|
+
)
|
|
1022
|
+
for asset_id in [*primary_ids, *may_need_ids]:
|
|
1023
|
+
record = records.get(asset_id)
|
|
1024
|
+
if record is None:
|
|
1025
|
+
warnings.append(f"Task {task_id} references missing asset id: {asset_id}")
|
|
1026
|
+
elif getattr(record, "deleted_at", None) is not None:
|
|
1027
|
+
warnings.append(f"Task {task_id} references deleted asset id: {asset_id}")
|
|
1028
|
+
return warnings
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _validate_task_asset_briefing_shape(
|
|
1032
|
+
task: dict[str, Any],
|
|
1033
|
+
*,
|
|
1034
|
+
task_id: str,
|
|
1035
|
+
max_primary_per_task: int = 8,
|
|
1036
|
+
) -> list[str]:
|
|
1037
|
+
if "asset_briefing" not in task:
|
|
1038
|
+
return []
|
|
1039
|
+
try:
|
|
1040
|
+
briefing = task_asset_briefing(task)
|
|
1041
|
+
except AssetError as exc:
|
|
1042
|
+
return [f"Task {task_id} has invalid asset_briefing: {exc}"]
|
|
1043
|
+
if briefing is None:
|
|
1044
|
+
return []
|
|
1045
|
+
warnings: list[str] = []
|
|
1046
|
+
primary_ids = [entry.asset_id for entry in briefing.primary]
|
|
1047
|
+
may_need_ids = [entry.asset_id for entry in briefing.may_need]
|
|
1048
|
+
if len(primary_ids) > max_primary_per_task:
|
|
1049
|
+
warnings.append(
|
|
1050
|
+
f"Task {task_id} references too many primary assets: "
|
|
1051
|
+
f"{len(primary_ids)} > {max_primary_per_task}"
|
|
1052
|
+
)
|
|
1053
|
+
overlap = sorted(set(primary_ids) & set(may_need_ids))
|
|
1054
|
+
if overlap:
|
|
1055
|
+
warnings.append(
|
|
1056
|
+
f"Task {task_id} references the same asset in primary and may_need: "
|
|
1057
|
+
+ ", ".join(overlap)
|
|
1058
|
+
)
|
|
1059
|
+
return warnings
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _asset_records_by_id(assets_source: Any) -> dict[str, Any]:
|
|
1063
|
+
records_method = getattr(assets_source, "records", None)
|
|
1064
|
+
if callable(records_method):
|
|
1065
|
+
return {
|
|
1066
|
+
str(record.id): record
|
|
1067
|
+
for record in records_method(include_deleted=True)
|
|
1068
|
+
if getattr(record, "id", "")
|
|
1069
|
+
}
|
|
1070
|
+
index = getattr(assets_source, "index", None)
|
|
1071
|
+
index_records = getattr(index, "records", None)
|
|
1072
|
+
if callable(index_records):
|
|
1073
|
+
return {
|
|
1074
|
+
str(record.id): record
|
|
1075
|
+
for record in index_records(include_deleted=True)
|
|
1076
|
+
if getattr(record, "id", "")
|
|
1077
|
+
}
|
|
1078
|
+
if isinstance(assets_source, dict):
|
|
1079
|
+
return assets_source
|
|
1080
|
+
return {}
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def raise_for_execution_ready_plan(
|
|
1084
|
+
plan: dict[str, Any],
|
|
1085
|
+
*,
|
|
1086
|
+
retry_failed: bool = False,
|
|
1087
|
+
retry_changes_requested: bool = False,
|
|
1088
|
+
retry_merge_conflicts: bool = False,
|
|
1089
|
+
only: str | None = None,
|
|
1090
|
+
) -> None:
|
|
1091
|
+
acceptance_issues = find_plan_acceptance_issues(
|
|
1092
|
+
plan,
|
|
1093
|
+
retry_failed=retry_failed,
|
|
1094
|
+
retry_changes_requested=retry_changes_requested,
|
|
1095
|
+
retry_merge_conflicts=retry_merge_conflicts,
|
|
1096
|
+
only=only,
|
|
1097
|
+
)
|
|
1098
|
+
if acceptance_issues:
|
|
1099
|
+
raise PlannerFailedError(_format_plan_acceptance_block(acceptance_issues))
|