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
alysis_code/forge.py
ADDED
|
@@ -0,0 +1,2064 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import warnings
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .atomic_io import atomic_write_json, atomic_write_text
|
|
16
|
+
from .branding import PROJECT_DIR_NAME, resolve_project_dir
|
|
17
|
+
from .error_text import sanitize_optional_error_summary
|
|
18
|
+
from .mcp.forge_scope import describe_task_mcp_scope, normalize_task_mcp_scope
|
|
19
|
+
from .repo_scan import (
|
|
20
|
+
REPO_SCAN_SCHEMA_VERSION,
|
|
21
|
+
RepoScanResult,
|
|
22
|
+
render_repo_scan_markdown,
|
|
23
|
+
render_repo_scan_summary_lines,
|
|
24
|
+
scan_workspace,
|
|
25
|
+
)
|
|
26
|
+
from .run_state import (
|
|
27
|
+
RUN_STATUS_APPROVED,
|
|
28
|
+
RUN_STATUS_DRAFT,
|
|
29
|
+
RUN_STATUS_RUNNING,
|
|
30
|
+
apply_run_status,
|
|
31
|
+
plan_fingerprint,
|
|
32
|
+
pointer_status,
|
|
33
|
+
reconcile_status_after_crash,
|
|
34
|
+
)
|
|
35
|
+
from .runtime_artifacts import is_runtime_artifact_path
|
|
36
|
+
from .serialized_paths import safe_serialized_path
|
|
37
|
+
from .task_dependencies import infer_ordered_predecessor_dependency
|
|
38
|
+
from .task_readiness import (
|
|
39
|
+
TASK_KIND_ANALYSIS_ONLY,
|
|
40
|
+
has_runnable_local_file_scope,
|
|
41
|
+
manual_task_scope_error_message,
|
|
42
|
+
normalize_task_file_fields,
|
|
43
|
+
)
|
|
44
|
+
from .verify_gate import compact_verification_payload
|
|
45
|
+
from .workspace_binding import (
|
|
46
|
+
WorkspaceAction,
|
|
47
|
+
WorkspaceBinding,
|
|
48
|
+
WorkspaceBindingError,
|
|
49
|
+
WorkspaceRiskLevel,
|
|
50
|
+
ensure_workspace_policy,
|
|
51
|
+
resolve_workspace_binding,
|
|
52
|
+
)
|
|
53
|
+
from .workspace_context import (
|
|
54
|
+
WorkspaceContext,
|
|
55
|
+
WorkspaceContextError,
|
|
56
|
+
resolve_workspace_context,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from .execution_shared import ExecutionLogArtifactsResult
|
|
61
|
+
|
|
62
|
+
MAX_TEXT_ASSET_BYTES = 200 * 1024
|
|
63
|
+
# 5 added the run lifecycle block: ``status`` (see :mod:`alysis_code.run_state`),
|
|
64
|
+
# ``status_updated_at``, ``status_history``, ``run_owner``, and ``plan_fingerprint``.
|
|
65
|
+
# Readers must keep accepting older pointers; they simply read as ``draft``.
|
|
66
|
+
CURRENT_RUN_POINTER_SCHEMA_VERSION = 5
|
|
67
|
+
PLAN_SCHEMA_VERSION = 2
|
|
68
|
+
LOGGER = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ForgeError(RuntimeError):
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class RunPaths:
|
|
77
|
+
root: Path
|
|
78
|
+
run_id: str
|
|
79
|
+
runtime_dir: Path
|
|
80
|
+
runs_dir: Path
|
|
81
|
+
run_dir: Path
|
|
82
|
+
plan_dir: Path
|
|
83
|
+
plan_json_path: Path
|
|
84
|
+
plan_md_path: Path
|
|
85
|
+
decisions_path: Path
|
|
86
|
+
risks_path: Path
|
|
87
|
+
assets_dir: Path
|
|
88
|
+
assets_text_dir: Path
|
|
89
|
+
asset_store_dir: Path
|
|
90
|
+
assets_index_path: Path
|
|
91
|
+
assets_index_lock_path: Path
|
|
92
|
+
assets_raw_dir: Path
|
|
93
|
+
assets_extracted_dir: Path
|
|
94
|
+
assets_comprehensions_dir: Path
|
|
95
|
+
notes_dir: Path
|
|
96
|
+
notes_path: Path
|
|
97
|
+
planner_chat_path: Path
|
|
98
|
+
planner_summary_path: Path
|
|
99
|
+
plan_replans_dir: Path
|
|
100
|
+
execution_dir: Path
|
|
101
|
+
execution_reports_dir: Path
|
|
102
|
+
execution_patches_dir: Path
|
|
103
|
+
execution_logs_dir: Path
|
|
104
|
+
execution_sessions_dir: Path
|
|
105
|
+
execution_reviews_dir: Path
|
|
106
|
+
execution_verify_dir: Path
|
|
107
|
+
execution_context_dir: Path
|
|
108
|
+
execution_budgets_dir: Path
|
|
109
|
+
execution_asset_briefings_dir: Path
|
|
110
|
+
execution_asset_usage_dir: Path
|
|
111
|
+
execution_knowledge_capture_dir: Path
|
|
112
|
+
execution_integration_dir: Path
|
|
113
|
+
execution_integration_issues_path: Path
|
|
114
|
+
knowledge_dir: Path
|
|
115
|
+
knowledge_index_path: Path
|
|
116
|
+
knowledge_task_attempts_dir: Path
|
|
117
|
+
knowledge_issues_dir: Path
|
|
118
|
+
knowledge_facts_dir: Path
|
|
119
|
+
knowledge_decisions_dir: Path
|
|
120
|
+
knowledge_selected_dir: Path
|
|
121
|
+
plan_context_dir: Path | None = None
|
|
122
|
+
workspace_context_json_path: Path | None = None
|
|
123
|
+
workspace_summary_md_path: Path | None = None
|
|
124
|
+
focus_path: Path | None = None
|
|
125
|
+
focus_relpath: str = "."
|
|
126
|
+
workspace_kind: str = "plain_dir"
|
|
127
|
+
git_root: Path | None = None
|
|
128
|
+
has_head_commit: bool = False
|
|
129
|
+
greenfield: bool = False
|
|
130
|
+
current_branch: str | None = None
|
|
131
|
+
binding_requested_path: Path | None = None
|
|
132
|
+
binding_source: str = "explicit_path"
|
|
133
|
+
workspace_created_at_startup: bool = False
|
|
134
|
+
binding_risk_level: str = WorkspaceRiskLevel.HEALTHY
|
|
135
|
+
binding_risk_reasons: tuple[str, ...] = ()
|
|
136
|
+
binding_broad_workspace_override_used: bool = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def now_iso() -> str:
|
|
140
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Words for counter-based run ids: legendary smiths, bright stars, and smithing
|
|
144
|
+
# terms. Order is load-bearing — the word is a pure function of the counter, so
|
|
145
|
+
# reordering or removing entries renames future runs at existing counters.
|
|
146
|
+
RUN_NAME_WORDS: tuple[str, ...] = (
|
|
147
|
+
"vulcan",
|
|
148
|
+
"vega",
|
|
149
|
+
"wayland",
|
|
150
|
+
"sirius",
|
|
151
|
+
"brigid",
|
|
152
|
+
"altair",
|
|
153
|
+
"sindri",
|
|
154
|
+
"rigel",
|
|
155
|
+
"brokkr",
|
|
156
|
+
"deneb",
|
|
157
|
+
"ilmarinen",
|
|
158
|
+
"atlas",
|
|
159
|
+
"svarog",
|
|
160
|
+
"mira",
|
|
161
|
+
"goibniu",
|
|
162
|
+
"lyra",
|
|
163
|
+
"kalvis",
|
|
164
|
+
"orion",
|
|
165
|
+
"seppo",
|
|
166
|
+
"castor",
|
|
167
|
+
"anvil",
|
|
168
|
+
"pollux",
|
|
169
|
+
"ember",
|
|
170
|
+
"capella",
|
|
171
|
+
"ingot",
|
|
172
|
+
"antares",
|
|
173
|
+
"crucible",
|
|
174
|
+
"spica",
|
|
175
|
+
"hearth",
|
|
176
|
+
"mizar",
|
|
177
|
+
"kiln",
|
|
178
|
+
"alcor",
|
|
179
|
+
"rivet",
|
|
180
|
+
"procyon",
|
|
181
|
+
"alloy",
|
|
182
|
+
"canopus",
|
|
183
|
+
"temper",
|
|
184
|
+
"arcturus",
|
|
185
|
+
"bellows",
|
|
186
|
+
"regulus",
|
|
187
|
+
"tongs",
|
|
188
|
+
"thuban",
|
|
189
|
+
"flux",
|
|
190
|
+
"kochab",
|
|
191
|
+
"forgefire",
|
|
192
|
+
"alkaid",
|
|
193
|
+
"quench",
|
|
194
|
+
"hamal",
|
|
195
|
+
"smelt",
|
|
196
|
+
"ankaa",
|
|
197
|
+
"brazier",
|
|
198
|
+
"acrux",
|
|
199
|
+
"mantle",
|
|
200
|
+
"alphard",
|
|
201
|
+
"gilder",
|
|
202
|
+
"sabik",
|
|
203
|
+
"solder",
|
|
204
|
+
"shaula",
|
|
205
|
+
"chisel",
|
|
206
|
+
"nunki",
|
|
207
|
+
"sledge",
|
|
208
|
+
"markab",
|
|
209
|
+
"patina",
|
|
210
|
+
"polaris",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
_RUN_ID_COUNTER_RE = re.compile(r"^(\d{3,6})-[a-z]+$")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def format_run_id(counter: int) -> str:
|
|
217
|
+
word = RUN_NAME_WORDS[(counter - 1) % len(RUN_NAME_WORDS)]
|
|
218
|
+
return f"{counter:03d}-{word}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _next_run_counter(runs_dir: Path) -> int:
|
|
222
|
+
highest = 0
|
|
223
|
+
try:
|
|
224
|
+
entries = list(runs_dir.iterdir())
|
|
225
|
+
except OSError:
|
|
226
|
+
return 1
|
|
227
|
+
for entry in entries:
|
|
228
|
+
match = _RUN_ID_COUNTER_RE.match(entry.name)
|
|
229
|
+
if match is not None:
|
|
230
|
+
highest = max(highest, int(match.group(1)))
|
|
231
|
+
return highest + 1
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_RUN_COUNTER_MARK_NAME = ".run_counter"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _legacy_run_id() -> str:
|
|
238
|
+
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
239
|
+
import uuid
|
|
240
|
+
|
|
241
|
+
return f"{ts}_{uuid.uuid4().hex[:8]}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _stored_run_counter(runs_dir: Path) -> int:
|
|
245
|
+
try:
|
|
246
|
+
raw = (runs_dir / _RUN_COUNTER_MARK_NAME).read_text(encoding="utf-8")
|
|
247
|
+
return max(0, int(raw.strip()))
|
|
248
|
+
except (OSError, ValueError):
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _store_run_counter(runs_dir: Path, counter: int) -> None:
|
|
253
|
+
value = max(counter, _stored_run_counter(runs_dir))
|
|
254
|
+
try:
|
|
255
|
+
atomic_write_text(runs_dir / _RUN_COUNTER_MARK_NAME, f"{value}\n")
|
|
256
|
+
except OSError:
|
|
257
|
+
# The mark only guards against re-minting ids of deleted runs; the
|
|
258
|
+
# mkdir claim stays the uniqueness authority when it cannot be written.
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def make_run_id(
|
|
263
|
+
root: Path | None = None,
|
|
264
|
+
*,
|
|
265
|
+
runtime_dir_name: str = ".alysis",
|
|
266
|
+
) -> str:
|
|
267
|
+
if root is None:
|
|
268
|
+
# Without a workspace there is no run counter to scan; fall back to the
|
|
269
|
+
# legacy globally-unique id so uniqueness never depends on shared state.
|
|
270
|
+
return _legacy_run_id()
|
|
271
|
+
resolved = root.expanduser().resolve()
|
|
272
|
+
runs_dir = _runs_dir(resolved, runtime_dir_name=runtime_dir_name)
|
|
273
|
+
counter = max(_next_run_counter(runs_dir), _stored_run_counter(runs_dir) + 1)
|
|
274
|
+
if not resolved.is_dir():
|
|
275
|
+
# Workspace existence is validated later by make_run_paths; never create
|
|
276
|
+
# directories under a root that does not exist yet.
|
|
277
|
+
return format_run_id(counter)
|
|
278
|
+
try:
|
|
279
|
+
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
280
|
+
except OSError:
|
|
281
|
+
# The runs dir cannot be materialized (e.g. a stray file occupies the
|
|
282
|
+
# path). Hand out a legacy globally-unique id instead of an unclaimed
|
|
283
|
+
# counter so uniqueness never degrades; downstream directory creation
|
|
284
|
+
# surfaces the underlying error.
|
|
285
|
+
return _legacy_run_id()
|
|
286
|
+
while True:
|
|
287
|
+
candidate = format_run_id(counter)
|
|
288
|
+
try:
|
|
289
|
+
# mkdir is the atomic claim: concurrent creators that computed the
|
|
290
|
+
# same counter collide here and retry with the next one. The parent
|
|
291
|
+
# was materialized above, so FileExistsError can only mean the
|
|
292
|
+
# candidate itself is taken.
|
|
293
|
+
(runs_dir / candidate).mkdir(exist_ok=False)
|
|
294
|
+
except FileExistsError:
|
|
295
|
+
counter += 1
|
|
296
|
+
continue
|
|
297
|
+
except OSError:
|
|
298
|
+
return _legacy_run_id()
|
|
299
|
+
_store_run_counter(runs_dir, counter)
|
|
300
|
+
return candidate
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def ensure_repo_root(root: Path) -> Path:
|
|
304
|
+
resolved = root.expanduser().resolve()
|
|
305
|
+
if not resolved.exists():
|
|
306
|
+
raise ForgeError(f"Workspace path does not exist: {resolved}")
|
|
307
|
+
if not resolved.is_dir():
|
|
308
|
+
raise ForgeError(f"Workspace path is not a directory: {resolved}")
|
|
309
|
+
return resolved
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _ensure_under_root(root: Path, path: Path) -> None:
|
|
313
|
+
try:
|
|
314
|
+
path.resolve().relative_to(root.resolve())
|
|
315
|
+
except ValueError as e:
|
|
316
|
+
raise ForgeError(f"Path escapes workspace root: {path}") from e
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _runtime_dir(root: Path, *, runtime_dir_name: str = PROJECT_DIR_NAME) -> Path:
|
|
320
|
+
if runtime_dir_name == PROJECT_DIR_NAME:
|
|
321
|
+
# A repo carried over from before the rename has a committed
|
|
322
|
+
# ``.sylliptor/`` and no ``.alysis/``. Keep using it in place rather
|
|
323
|
+
# than starting an empty one beside it — renaming a tracked directory
|
|
324
|
+
# would show up as an unexplained diff in the user's working tree.
|
|
325
|
+
return resolve_project_dir(root)
|
|
326
|
+
return root / runtime_dir_name
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _runtime_state_dir(root: Path) -> Path:
|
|
330
|
+
return _runtime_dir(root)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _runs_dir(root: Path, *, runtime_dir_name: str = ".alysis") -> Path:
|
|
334
|
+
return _runtime_dir(root, runtime_dir_name=runtime_dir_name) / "runs"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def current_run_pointer_path(root: Path) -> Path:
|
|
338
|
+
return _runtime_state_dir(root) / "current_run.json"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def make_run_paths(
|
|
342
|
+
*,
|
|
343
|
+
root: Path,
|
|
344
|
+
run_id: str,
|
|
345
|
+
focus_path: Path | None = None,
|
|
346
|
+
focus_relpath: str = ".",
|
|
347
|
+
workspace_kind: str = "plain_dir",
|
|
348
|
+
git_root: Path | None = None,
|
|
349
|
+
has_head_commit: bool = False,
|
|
350
|
+
greenfield: bool | None = None,
|
|
351
|
+
current_branch: str | None = None,
|
|
352
|
+
binding_requested_path: Path | None = None,
|
|
353
|
+
binding_source: str = "explicit_path",
|
|
354
|
+
workspace_created_at_startup: bool = False,
|
|
355
|
+
binding_risk_level: str = WorkspaceRiskLevel.HEALTHY,
|
|
356
|
+
binding_risk_reasons: tuple[str, ...] | list[str] = (),
|
|
357
|
+
binding_broad_workspace_override_used: bool = False,
|
|
358
|
+
runtime_dir_name: str = ".alysis",
|
|
359
|
+
) -> RunPaths:
|
|
360
|
+
root = ensure_repo_root(root)
|
|
361
|
+
resolved_focus_path = root if focus_path is None else focus_path.expanduser().resolve()
|
|
362
|
+
_ensure_under_root(root, resolved_focus_path)
|
|
363
|
+
normalized_focus_relpath = _normalize_stored_relpath(focus_relpath)
|
|
364
|
+
resolved_git_root = git_root.expanduser().resolve() if git_root is not None else None
|
|
365
|
+
resolved_requested_path = (
|
|
366
|
+
resolved_focus_path
|
|
367
|
+
if binding_requested_path is None
|
|
368
|
+
else binding_requested_path.expanduser().resolve(strict=False)
|
|
369
|
+
)
|
|
370
|
+
_ensure_under_root(root, resolved_requested_path)
|
|
371
|
+
runtime_dir = _runtime_dir(root, runtime_dir_name=runtime_dir_name)
|
|
372
|
+
runs_dir = _runs_dir(root, runtime_dir_name=runtime_dir_name)
|
|
373
|
+
run_dir = runs_dir / run_id
|
|
374
|
+
plan_dir = run_dir / "plan"
|
|
375
|
+
execution_dir = run_dir / "execution"
|
|
376
|
+
plan_context_dir = plan_dir / "context"
|
|
377
|
+
asset_store_dir = run_dir / "assets"
|
|
378
|
+
resolved_greenfield = (
|
|
379
|
+
bool(greenfield)
|
|
380
|
+
if greenfield is not None
|
|
381
|
+
else derive_greenfield_workspace(
|
|
382
|
+
root=root,
|
|
383
|
+
workspace_kind=workspace_kind or "plain_dir",
|
|
384
|
+
has_head_commit=has_head_commit,
|
|
385
|
+
workspace_created_at_startup=workspace_created_at_startup,
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
return RunPaths(
|
|
389
|
+
root=root,
|
|
390
|
+
run_id=run_id,
|
|
391
|
+
runtime_dir=runtime_dir,
|
|
392
|
+
runs_dir=runs_dir,
|
|
393
|
+
run_dir=run_dir,
|
|
394
|
+
plan_dir=plan_dir,
|
|
395
|
+
plan_json_path=plan_dir / "plan.json",
|
|
396
|
+
plan_md_path=plan_dir / "PLAN.md",
|
|
397
|
+
decisions_path=plan_dir / "DECISIONS.md",
|
|
398
|
+
risks_path=plan_dir / "RISKS.md",
|
|
399
|
+
assets_dir=plan_dir / "assets",
|
|
400
|
+
assets_text_dir=plan_dir / "assets_text",
|
|
401
|
+
asset_store_dir=asset_store_dir,
|
|
402
|
+
assets_index_path=asset_store_dir / "index.json",
|
|
403
|
+
assets_index_lock_path=asset_store_dir / "index.lock",
|
|
404
|
+
assets_raw_dir=asset_store_dir / "raw",
|
|
405
|
+
assets_extracted_dir=asset_store_dir / "extracted",
|
|
406
|
+
assets_comprehensions_dir=asset_store_dir / "comprehensions",
|
|
407
|
+
notes_dir=plan_dir / "notes",
|
|
408
|
+
notes_path=plan_dir / "notes" / "user_notes.md",
|
|
409
|
+
planner_chat_path=plan_dir / "notes" / "planner_chat.md",
|
|
410
|
+
planner_summary_path=plan_dir / "notes" / "planner_summary.md",
|
|
411
|
+
plan_replans_dir=plan_dir / "replans",
|
|
412
|
+
execution_dir=execution_dir,
|
|
413
|
+
execution_reports_dir=execution_dir / "reports",
|
|
414
|
+
execution_patches_dir=execution_dir / "patches",
|
|
415
|
+
execution_logs_dir=execution_dir / "logs",
|
|
416
|
+
execution_sessions_dir=execution_dir / "sessions",
|
|
417
|
+
execution_reviews_dir=execution_dir / "reviews",
|
|
418
|
+
execution_verify_dir=execution_dir / "verify",
|
|
419
|
+
execution_context_dir=execution_dir / "context",
|
|
420
|
+
execution_budgets_dir=execution_dir / "budgets",
|
|
421
|
+
execution_asset_briefings_dir=execution_dir / "asset_briefings",
|
|
422
|
+
execution_asset_usage_dir=execution_dir / "asset_usage",
|
|
423
|
+
execution_knowledge_capture_dir=execution_dir / "knowledge_capture",
|
|
424
|
+
execution_integration_dir=execution_dir / "integration",
|
|
425
|
+
execution_integration_issues_path=execution_dir / "integration" / "integration_issues.md",
|
|
426
|
+
knowledge_dir=run_dir / "knowledge",
|
|
427
|
+
knowledge_index_path=run_dir / "knowledge" / "index.json",
|
|
428
|
+
knowledge_task_attempts_dir=run_dir / "knowledge" / "task_attempts",
|
|
429
|
+
knowledge_issues_dir=run_dir / "knowledge" / "issues",
|
|
430
|
+
knowledge_facts_dir=run_dir / "knowledge" / "facts",
|
|
431
|
+
knowledge_decisions_dir=run_dir / "knowledge" / "decisions",
|
|
432
|
+
knowledge_selected_dir=run_dir / "knowledge" / "selected",
|
|
433
|
+
plan_context_dir=plan_context_dir,
|
|
434
|
+
workspace_context_json_path=plan_context_dir / "workspace_context.json",
|
|
435
|
+
workspace_summary_md_path=plan_context_dir / "workspace_summary.md",
|
|
436
|
+
focus_path=resolved_focus_path,
|
|
437
|
+
focus_relpath=normalized_focus_relpath,
|
|
438
|
+
workspace_kind=workspace_kind or "plain_dir",
|
|
439
|
+
git_root=resolved_git_root,
|
|
440
|
+
has_head_commit=has_head_commit,
|
|
441
|
+
greenfield=resolved_greenfield,
|
|
442
|
+
current_branch=current_branch.strip() if current_branch else None,
|
|
443
|
+
binding_requested_path=resolved_requested_path,
|
|
444
|
+
binding_source=binding_source.strip() or "explicit_path",
|
|
445
|
+
workspace_created_at_startup=workspace_created_at_startup,
|
|
446
|
+
binding_risk_level=binding_risk_level.strip() or WorkspaceRiskLevel.HEALTHY,
|
|
447
|
+
binding_risk_reasons=tuple(
|
|
448
|
+
str(reason).strip() for reason in binding_risk_reasons if str(reason).strip()
|
|
449
|
+
),
|
|
450
|
+
binding_broad_workspace_override_used=binding_broad_workspace_override_used,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def derive_greenfield_workspace(
|
|
455
|
+
*,
|
|
456
|
+
root: Path,
|
|
457
|
+
workspace_kind: str,
|
|
458
|
+
has_head_commit: bool,
|
|
459
|
+
workspace_created_at_startup: bool = False,
|
|
460
|
+
) -> bool:
|
|
461
|
+
"""Classify workspaces where declared plan paths are usually create targets."""
|
|
462
|
+
normalized_kind = str(workspace_kind or "").strip()
|
|
463
|
+
if normalized_kind == "plain_dir":
|
|
464
|
+
return True
|
|
465
|
+
if not has_head_commit:
|
|
466
|
+
return True
|
|
467
|
+
if workspace_created_at_startup:
|
|
468
|
+
return True
|
|
469
|
+
return _workspace_tree_is_empty_or_near_empty(root)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _workspace_tree_is_empty_or_near_empty(root: Path) -> bool:
|
|
473
|
+
try:
|
|
474
|
+
root_resolved = root.resolve()
|
|
475
|
+
except OSError:
|
|
476
|
+
return False
|
|
477
|
+
visited_dirs = 0
|
|
478
|
+
for current_dir, dirnames, filenames in os.walk(root_resolved):
|
|
479
|
+
visited_dirs += 1
|
|
480
|
+
if visited_dirs > 128:
|
|
481
|
+
return False
|
|
482
|
+
current_path = Path(current_dir)
|
|
483
|
+
kept_dirs: list[str] = []
|
|
484
|
+
for dirname in dirnames:
|
|
485
|
+
rel_path = (current_path / dirname).relative_to(root_resolved).as_posix()
|
|
486
|
+
if not is_runtime_artifact_path(rel_path, root=root_resolved):
|
|
487
|
+
kept_dirs.append(dirname)
|
|
488
|
+
dirnames[:] = kept_dirs
|
|
489
|
+
for filename in filenames:
|
|
490
|
+
rel_path = (current_path / filename).relative_to(root_resolved).as_posix()
|
|
491
|
+
if not is_runtime_artifact_path(rel_path, root=root_resolved):
|
|
492
|
+
return False
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _repo_rel(root: Path, path: Path) -> str:
|
|
497
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _normalize_stored_relpath(value: str | None) -> str:
|
|
501
|
+
raw = str(value or ".").strip() or "."
|
|
502
|
+
if PurePosixPath(raw).is_absolute() or PureWindowsPath(raw).is_absolute():
|
|
503
|
+
raise ForgeError("workspace relative path must be relative")
|
|
504
|
+
normalized = PurePosixPath(raw.replace("\\", "/")).as_posix()
|
|
505
|
+
return "." if normalized in {"", "."} else normalized
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
509
|
+
atomic_write_json(path, data)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
513
|
+
try:
|
|
514
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
515
|
+
except FileNotFoundError as e:
|
|
516
|
+
raise ForgeError(f"Missing file: {path}") from e
|
|
517
|
+
except json.JSONDecodeError as e:
|
|
518
|
+
raise ForgeError(f"Invalid JSON file: {path}") from e
|
|
519
|
+
if not isinstance(raw, dict):
|
|
520
|
+
raise ForgeError(f"Invalid JSON structure in {path}")
|
|
521
|
+
return raw
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _next_task_id(tasks: list[dict[str, Any]]) -> str:
|
|
525
|
+
highest = 0
|
|
526
|
+
for task in tasks:
|
|
527
|
+
tid = str(task.get("id") or "")
|
|
528
|
+
if tid.startswith("T"):
|
|
529
|
+
tail = tid[1:]
|
|
530
|
+
if tail.isdigit():
|
|
531
|
+
highest = max(highest, int(tail))
|
|
532
|
+
return f"T{highest + 1:02d}"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def add_task(
|
|
536
|
+
plan: dict[str, Any],
|
|
537
|
+
*,
|
|
538
|
+
title: str,
|
|
539
|
+
description: str = "",
|
|
540
|
+
acceptance_criteria: list[str] | None = None,
|
|
541
|
+
dependencies: list[str] | None = None,
|
|
542
|
+
estimated_files: list[str] | None = None,
|
|
543
|
+
write_scope: list[str] | None = None,
|
|
544
|
+
branch: str = "",
|
|
545
|
+
status: str = "planned",
|
|
546
|
+
attempts: int = 0,
|
|
547
|
+
mcp_scope: dict[str, Any] | None = None,
|
|
548
|
+
_allow_execution_unready: bool = False,
|
|
549
|
+
) -> dict[str, Any]:
|
|
550
|
+
tasks = plan.setdefault("tasks", [])
|
|
551
|
+
if not isinstance(tasks, list):
|
|
552
|
+
raise ForgeError("plan.json field 'tasks' must be an array")
|
|
553
|
+
|
|
554
|
+
title_text = title.strip()
|
|
555
|
+
description_text = description.strip()
|
|
556
|
+
acceptance = acceptance_criteria or []
|
|
557
|
+
scope = normalize_task_file_fields(
|
|
558
|
+
title=title_text,
|
|
559
|
+
description=description_text,
|
|
560
|
+
acceptance_criteria=acceptance,
|
|
561
|
+
estimated_files=estimated_files,
|
|
562
|
+
write_scope=write_scope,
|
|
563
|
+
warning_prefix=f"Task '{title_text}'",
|
|
564
|
+
)
|
|
565
|
+
if (
|
|
566
|
+
scope.requires_runnable_scope
|
|
567
|
+
and not has_runnable_local_file_scope(
|
|
568
|
+
estimated_files=scope.estimated_files,
|
|
569
|
+
write_scope=scope.write_scope,
|
|
570
|
+
)
|
|
571
|
+
and not _allow_execution_unready
|
|
572
|
+
):
|
|
573
|
+
raise ForgeError(manual_task_scope_error_message(title=title_text))
|
|
574
|
+
|
|
575
|
+
task = {
|
|
576
|
+
"id": _next_task_id(tasks),
|
|
577
|
+
"title": title_text,
|
|
578
|
+
"description": description_text,
|
|
579
|
+
"acceptance_criteria": acceptance,
|
|
580
|
+
"dependencies": dependencies or [],
|
|
581
|
+
"estimated_files": scope.estimated_files,
|
|
582
|
+
"write_scope": scope.write_scope,
|
|
583
|
+
"branch": branch,
|
|
584
|
+
"status": status,
|
|
585
|
+
"attempts": attempts,
|
|
586
|
+
"task_kind": scope.task_kind,
|
|
587
|
+
"task_kind_reason": scope.task_kind_reason,
|
|
588
|
+
}
|
|
589
|
+
if isinstance(mcp_scope, dict):
|
|
590
|
+
task["mcp_scope"] = dict(mcp_scope)
|
|
591
|
+
if scope.task_kind == TASK_KIND_ANALYSIS_ONLY:
|
|
592
|
+
task["analysis_only"] = True
|
|
593
|
+
tasks.append(task)
|
|
594
|
+
if not dependencies:
|
|
595
|
+
inferred_dependency = infer_ordered_predecessor_dependency(tasks=tasks, task=task)
|
|
596
|
+
if inferred_dependency is not None:
|
|
597
|
+
task["dependencies"] = [inferred_dependency.depends_on]
|
|
598
|
+
plan["updated_at"] = now_iso()
|
|
599
|
+
return task
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def find_task(plan: dict[str, Any], task_id: str) -> dict[str, Any] | None:
|
|
603
|
+
tasks = plan.get("tasks") or []
|
|
604
|
+
if not isinstance(tasks, list):
|
|
605
|
+
raise ForgeError("plan.json field 'tasks' must be an array")
|
|
606
|
+
target = task_id.strip().lower()
|
|
607
|
+
for task in tasks:
|
|
608
|
+
tid = str(task.get("id") or "").strip().lower()
|
|
609
|
+
if tid == target:
|
|
610
|
+
return task
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def set_task_status(plan: dict[str, Any], task_id: str, status: str) -> dict[str, Any]:
|
|
615
|
+
task = find_task(plan, task_id)
|
|
616
|
+
if not task:
|
|
617
|
+
raise ForgeError(f"Task not found: {task_id}")
|
|
618
|
+
task["status"] = status
|
|
619
|
+
plan["updated_at"] = now_iso()
|
|
620
|
+
return task
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def requirement_text(item: Any) -> str:
|
|
624
|
+
if isinstance(item, dict):
|
|
625
|
+
for key in ("text", "requirement", "title", "description", "content"):
|
|
626
|
+
raw = item.get(key)
|
|
627
|
+
if isinstance(raw, str) and raw.strip():
|
|
628
|
+
return raw.strip()
|
|
629
|
+
return ""
|
|
630
|
+
return str(item).strip()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def requirement_is_execution_ready(item: Any) -> bool:
|
|
634
|
+
if isinstance(item, dict):
|
|
635
|
+
return item.get("execution_ready") is not False
|
|
636
|
+
return True
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def add_requirement(
|
|
640
|
+
plan: dict[str, Any],
|
|
641
|
+
text: str,
|
|
642
|
+
*,
|
|
643
|
+
execution_ready: bool = True,
|
|
644
|
+
source: str | None = None,
|
|
645
|
+
) -> None:
|
|
646
|
+
reqs = plan.setdefault("requirements", [])
|
|
647
|
+
if not isinstance(reqs, list):
|
|
648
|
+
raise ForgeError("plan.json field 'requirements' must be an array")
|
|
649
|
+
clean_text = text.strip()
|
|
650
|
+
if not clean_text:
|
|
651
|
+
return
|
|
652
|
+
if execution_ready and not source:
|
|
653
|
+
reqs.append(clean_text)
|
|
654
|
+
else:
|
|
655
|
+
requirement: dict[str, Any] = {
|
|
656
|
+
"text": clean_text,
|
|
657
|
+
"execution_ready": bool(execution_ready),
|
|
658
|
+
}
|
|
659
|
+
if source:
|
|
660
|
+
requirement["source"] = source
|
|
661
|
+
reqs.append(requirement)
|
|
662
|
+
plan["updated_at"] = now_iso()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _default_plan_json(run_id: str) -> dict[str, Any]:
|
|
666
|
+
ts = now_iso()
|
|
667
|
+
return {
|
|
668
|
+
"schema_version": PLAN_SCHEMA_VERSION,
|
|
669
|
+
"run_id": run_id,
|
|
670
|
+
"created_at": ts,
|
|
671
|
+
"updated_at": ts,
|
|
672
|
+
"project_goal": "",
|
|
673
|
+
"summary": "",
|
|
674
|
+
"requirements": [],
|
|
675
|
+
"tasks": [],
|
|
676
|
+
"planning_constraints": {
|
|
677
|
+
"schema_version": 1,
|
|
678
|
+
"target_roots": [],
|
|
679
|
+
"forbidden_roots": [],
|
|
680
|
+
"decoy_roots": [],
|
|
681
|
+
"unrelated_roots": [],
|
|
682
|
+
},
|
|
683
|
+
"assets": [],
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _resolve_workspace_context_or_error(path: Path) -> WorkspaceContext:
|
|
688
|
+
try:
|
|
689
|
+
return resolve_workspace_context(path)
|
|
690
|
+
except WorkspaceContextError as e:
|
|
691
|
+
raise ForgeError(str(e)) from e
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _run_paths_from_workspace_context(
|
|
695
|
+
*,
|
|
696
|
+
context: WorkspaceContext,
|
|
697
|
+
run_id: str,
|
|
698
|
+
binding_requested_path: Path | None = None,
|
|
699
|
+
binding_source: str = "explicit_path",
|
|
700
|
+
workspace_created_at_startup: bool = False,
|
|
701
|
+
binding_risk_level: str = WorkspaceRiskLevel.HEALTHY,
|
|
702
|
+
binding_risk_reasons: tuple[str, ...] | list[str] = (),
|
|
703
|
+
binding_broad_workspace_override_used: bool = False,
|
|
704
|
+
runtime_dir_name: str = ".alysis",
|
|
705
|
+
) -> RunPaths:
|
|
706
|
+
return make_run_paths(
|
|
707
|
+
root=context.workspace_root,
|
|
708
|
+
run_id=run_id,
|
|
709
|
+
focus_path=context.focus_path,
|
|
710
|
+
focus_relpath=context.focus_relpath,
|
|
711
|
+
workspace_kind=context.workspace_kind,
|
|
712
|
+
git_root=context.git_root,
|
|
713
|
+
has_head_commit=context.has_head_commit,
|
|
714
|
+
current_branch=context.current_branch,
|
|
715
|
+
binding_requested_path=binding_requested_path or context.input_path,
|
|
716
|
+
binding_source=binding_source,
|
|
717
|
+
workspace_created_at_startup=workspace_created_at_startup,
|
|
718
|
+
binding_risk_level=binding_risk_level,
|
|
719
|
+
binding_risk_reasons=binding_risk_reasons,
|
|
720
|
+
binding_broad_workspace_override_used=binding_broad_workspace_override_used,
|
|
721
|
+
runtime_dir_name=runtime_dir_name,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def rebind_run_paths_to_workspace_binding(
|
|
726
|
+
*,
|
|
727
|
+
paths: RunPaths,
|
|
728
|
+
workspace_binding: WorkspaceBinding,
|
|
729
|
+
) -> RunPaths:
|
|
730
|
+
stored_workspace_root = paths.root.resolve()
|
|
731
|
+
current_workspace_root = workspace_binding.workspace_context.workspace_root.resolve()
|
|
732
|
+
if stored_workspace_root != current_workspace_root:
|
|
733
|
+
raise ForgeError(
|
|
734
|
+
"Cannot resume a Forge run across workspace roots without creating a fresh run."
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Preserve the existing run directory/runtime layout while rebinding focus and
|
|
738
|
+
# workspace metadata to the chat's current workspace binding.
|
|
739
|
+
return _run_paths_from_workspace_context(
|
|
740
|
+
context=workspace_binding.workspace_context,
|
|
741
|
+
run_id=paths.run_id,
|
|
742
|
+
binding_requested_path=workspace_binding.requested_path,
|
|
743
|
+
binding_source=workspace_binding.binding_source,
|
|
744
|
+
workspace_created_at_startup=workspace_binding.created_path,
|
|
745
|
+
binding_risk_level=workspace_binding.risk_level,
|
|
746
|
+
binding_risk_reasons=workspace_binding.risk_reasons,
|
|
747
|
+
binding_broad_workspace_override_used=workspace_binding.broad_workspace_override_used,
|
|
748
|
+
runtime_dir_name=paths.runtime_dir.name,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _workspace_binding_error(binding: WorkspaceBinding) -> ForgeError:
|
|
753
|
+
detail = "; ".join(binding.risk_reasons) or "workspace selection is not allowed"
|
|
754
|
+
if binding.risk_level == WorkspaceRiskLevel.BLOCKED:
|
|
755
|
+
return ForgeError(f"Workspace binding blocked for {binding.requested_path}: {detail}.")
|
|
756
|
+
if binding.risk_level == WorkspaceRiskLevel.GUARDED:
|
|
757
|
+
return ForgeError(
|
|
758
|
+
f"Workspace binding is guarded for {binding.requested_path}: {detail}. "
|
|
759
|
+
"Pass allow_broad_workspace=True to continue."
|
|
760
|
+
)
|
|
761
|
+
return ForgeError(detail)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _candidate_pointer_roots(*, input_path: Path, workspace_root: Path) -> list[Path]:
|
|
765
|
+
candidates: list[Path] = [workspace_root]
|
|
766
|
+
current = input_path
|
|
767
|
+
while True:
|
|
768
|
+
if current not in candidates:
|
|
769
|
+
candidates.append(current)
|
|
770
|
+
if current == workspace_root or current.parent == current:
|
|
771
|
+
break
|
|
772
|
+
current = current.parent
|
|
773
|
+
return candidates
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _load_current_run_pointer(
|
|
777
|
+
*,
|
|
778
|
+
pointer_root: Path,
|
|
779
|
+
pointer_path: Path,
|
|
780
|
+
) -> tuple[dict[str, Any], str, str]:
|
|
781
|
+
pointer = _read_json(pointer_path)
|
|
782
|
+
run_id = str(pointer.get("run_id") or "").strip()
|
|
783
|
+
if not run_id:
|
|
784
|
+
raise ForgeError(f"Invalid current run pointer: {pointer_path}")
|
|
785
|
+
run_path_raw = str(pointer.get("run_path") or "").strip()
|
|
786
|
+
if not run_path_raw:
|
|
787
|
+
raise ForgeError(f"Invalid current run pointer (missing run_path): {pointer_path}")
|
|
788
|
+
|
|
789
|
+
run_path = Path(run_path_raw)
|
|
790
|
+
if run_path.is_absolute():
|
|
791
|
+
raise ForgeError("current run pointer must use workspace-relative run_path")
|
|
792
|
+
|
|
793
|
+
run_dir = (pointer_root / run_path).resolve()
|
|
794
|
+
_ensure_under_root(pointer_root, run_dir)
|
|
795
|
+
runtime_dir_name = pointer_path.parent.name
|
|
796
|
+
expected_run_dir = (
|
|
797
|
+
_runs_dir(pointer_root, runtime_dir_name=runtime_dir_name) / run_id
|
|
798
|
+
).resolve()
|
|
799
|
+
_ensure_under_root(pointer_root, expected_run_dir)
|
|
800
|
+
if run_dir != expected_run_dir:
|
|
801
|
+
raise ForgeError(
|
|
802
|
+
"Invalid current run pointer: run_id and run_path disagree "
|
|
803
|
+
f"(run_id={run_id}, run_path={run_path_raw})."
|
|
804
|
+
)
|
|
805
|
+
if not run_dir.exists():
|
|
806
|
+
raise ForgeError(
|
|
807
|
+
f"Current run directory does not exist: {run_dir}. Start a new run with "
|
|
808
|
+
"'alysis forge plan'."
|
|
809
|
+
)
|
|
810
|
+
return pointer, run_id, runtime_dir_name
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _pointer_focus_relpath(pointer: dict[str, Any]) -> str:
|
|
814
|
+
try:
|
|
815
|
+
return _normalize_stored_relpath(str(pointer.get("focus_relpath") or "."))
|
|
816
|
+
except ForgeError as e:
|
|
817
|
+
raise ForgeError("current run pointer focus_relpath must be relative") from e
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _focus_path_from_rel(*, root: Path, focus_relpath: str) -> Path:
|
|
821
|
+
if focus_relpath == ".":
|
|
822
|
+
return root
|
|
823
|
+
focus_path = (root / focus_relpath).resolve()
|
|
824
|
+
_ensure_under_root(root, focus_path)
|
|
825
|
+
return focus_path
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _pointer_requested_path(
|
|
829
|
+
*,
|
|
830
|
+
pointer_root: Path,
|
|
831
|
+
pointer: dict[str, Any],
|
|
832
|
+
default_path: Path,
|
|
833
|
+
) -> Path:
|
|
834
|
+
raw = str(pointer.get("binding_requested_path") or "").strip()
|
|
835
|
+
if not raw:
|
|
836
|
+
return default_path
|
|
837
|
+
candidate = Path(raw).expanduser()
|
|
838
|
+
if not candidate.is_absolute():
|
|
839
|
+
candidate = pointer_root / candidate
|
|
840
|
+
return candidate.resolve(strict=False)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _pointer_binding_source(pointer: dict[str, Any]) -> str:
|
|
844
|
+
raw = str(pointer.get("binding_source") or "").strip()
|
|
845
|
+
if raw:
|
|
846
|
+
return raw
|
|
847
|
+
return "current_run_pointer"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _pointer_binding_risk_reasons(pointer: dict[str, Any]) -> tuple[str, ...]:
|
|
851
|
+
raw = pointer.get("binding_risk_reasons")
|
|
852
|
+
if not isinstance(raw, list):
|
|
853
|
+
return ()
|
|
854
|
+
return tuple(str(reason).strip() for reason in raw if str(reason).strip())
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _pointer_broad_workspace_override_used(pointer: dict[str, Any]) -> bool:
|
|
858
|
+
return bool(pointer.get("binding_broad_workspace_override_used", False))
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _run_paths_from_pointer(
|
|
862
|
+
*,
|
|
863
|
+
pointer_root: Path,
|
|
864
|
+
pointer_path: Path,
|
|
865
|
+
canonical_workspace_root: Path,
|
|
866
|
+
) -> RunPaths:
|
|
867
|
+
pointer, run_id, runtime_dir_name = _load_current_run_pointer(
|
|
868
|
+
pointer_root=pointer_root, pointer_path=pointer_path
|
|
869
|
+
)
|
|
870
|
+
metadata_context = _resolve_workspace_context_or_error(pointer_root)
|
|
871
|
+
if pointer_root == canonical_workspace_root:
|
|
872
|
+
focus_relpath = _pointer_focus_relpath(pointer)
|
|
873
|
+
focus_path = _focus_path_from_rel(root=pointer_root, focus_relpath=focus_relpath)
|
|
874
|
+
requested_path = _pointer_requested_path(
|
|
875
|
+
pointer_root=pointer_root,
|
|
876
|
+
pointer=pointer,
|
|
877
|
+
default_path=focus_path,
|
|
878
|
+
)
|
|
879
|
+
return make_run_paths(
|
|
880
|
+
root=pointer_root,
|
|
881
|
+
run_id=run_id,
|
|
882
|
+
focus_path=focus_path,
|
|
883
|
+
focus_relpath=focus_relpath,
|
|
884
|
+
workspace_kind=metadata_context.workspace_kind,
|
|
885
|
+
git_root=metadata_context.git_root,
|
|
886
|
+
has_head_commit=metadata_context.has_head_commit,
|
|
887
|
+
current_branch=metadata_context.current_branch,
|
|
888
|
+
binding_requested_path=requested_path,
|
|
889
|
+
binding_source=_pointer_binding_source(pointer),
|
|
890
|
+
workspace_created_at_startup=bool(pointer.get("workspace_created_at_startup", False)),
|
|
891
|
+
binding_risk_level=(
|
|
892
|
+
str(pointer.get("binding_risk_level") or "").strip() or WorkspaceRiskLevel.HEALTHY
|
|
893
|
+
),
|
|
894
|
+
binding_risk_reasons=_pointer_binding_risk_reasons(pointer),
|
|
895
|
+
binding_broad_workspace_override_used=_pointer_broad_workspace_override_used(pointer),
|
|
896
|
+
runtime_dir_name=runtime_dir_name,
|
|
897
|
+
)
|
|
898
|
+
requested_path = _pointer_requested_path(
|
|
899
|
+
pointer_root=pointer_root,
|
|
900
|
+
pointer=pointer,
|
|
901
|
+
default_path=pointer_root,
|
|
902
|
+
)
|
|
903
|
+
return make_run_paths(
|
|
904
|
+
root=pointer_root,
|
|
905
|
+
run_id=run_id,
|
|
906
|
+
focus_path=pointer_root,
|
|
907
|
+
focus_relpath=".",
|
|
908
|
+
workspace_kind=metadata_context.workspace_kind,
|
|
909
|
+
git_root=metadata_context.git_root,
|
|
910
|
+
has_head_commit=metadata_context.has_head_commit,
|
|
911
|
+
current_branch=metadata_context.current_branch,
|
|
912
|
+
binding_requested_path=requested_path,
|
|
913
|
+
binding_source=_pointer_binding_source(pointer),
|
|
914
|
+
workspace_created_at_startup=bool(pointer.get("workspace_created_at_startup", False)),
|
|
915
|
+
binding_risk_level=(
|
|
916
|
+
str(pointer.get("binding_risk_level") or "").strip() or WorkspaceRiskLevel.HEALTHY
|
|
917
|
+
),
|
|
918
|
+
binding_risk_reasons=_pointer_binding_risk_reasons(pointer),
|
|
919
|
+
binding_broad_workspace_override_used=_pointer_broad_workspace_override_used(pointer),
|
|
920
|
+
runtime_dir_name=runtime_dir_name,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def render_plan_markdown(plan: dict[str, Any], *, asset_index: Any | None = None) -> str:
|
|
925
|
+
goal = str(plan.get("project_goal") or "").strip() or "(not set yet)"
|
|
926
|
+
summary = str(plan.get("summary") or "").strip() or "(not set yet)"
|
|
927
|
+
requirements = plan.get("requirements") or []
|
|
928
|
+
superseded_requirements = plan.get("superseded_requirements") or []
|
|
929
|
+
tasks = plan.get("tasks") or []
|
|
930
|
+
assets = plan.get("assets") or []
|
|
931
|
+
asset_titles, pinned_assets = _asset_markdown_metadata(asset_index)
|
|
932
|
+
|
|
933
|
+
lines: list[str] = [
|
|
934
|
+
"# PLAN",
|
|
935
|
+
"",
|
|
936
|
+
f"- Run ID: `{plan.get('run_id', '')}`",
|
|
937
|
+
f"- Created: `{plan.get('created_at', '')}`",
|
|
938
|
+
f"- Updated: `{plan.get('updated_at', '')}`",
|
|
939
|
+
"",
|
|
940
|
+
"## Project Goal",
|
|
941
|
+
"",
|
|
942
|
+
goal,
|
|
943
|
+
"",
|
|
944
|
+
"## Summary",
|
|
945
|
+
"",
|
|
946
|
+
summary,
|
|
947
|
+
"",
|
|
948
|
+
"## Requirements",
|
|
949
|
+
"",
|
|
950
|
+
]
|
|
951
|
+
|
|
952
|
+
if requirements:
|
|
953
|
+
for req in requirements:
|
|
954
|
+
text = requirement_text(req)
|
|
955
|
+
if not text:
|
|
956
|
+
continue
|
|
957
|
+
suffix = "" if requirement_is_execution_ready(req) else " (not execution-ready)"
|
|
958
|
+
lines.append(f"- {text}{suffix}")
|
|
959
|
+
else:
|
|
960
|
+
lines.append("- (none)")
|
|
961
|
+
|
|
962
|
+
if superseded_requirements:
|
|
963
|
+
lines.extend(["", "## Superseded Requirements", ""])
|
|
964
|
+
for item in superseded_requirements:
|
|
965
|
+
if isinstance(item, dict):
|
|
966
|
+
text = str(item.get("text") or "").strip()
|
|
967
|
+
reason = str(item.get("reason") or "").strip()
|
|
968
|
+
else:
|
|
969
|
+
text = str(item).strip()
|
|
970
|
+
reason = ""
|
|
971
|
+
if not text:
|
|
972
|
+
continue
|
|
973
|
+
suffix = f" ({reason})" if reason else ""
|
|
974
|
+
lines.append(f"- {text}{suffix}")
|
|
975
|
+
|
|
976
|
+
planning_constraints = plan.get("planning_constraints")
|
|
977
|
+
if isinstance(planning_constraints, dict):
|
|
978
|
+
constraint_lines: list[str] = []
|
|
979
|
+
for label, key in (
|
|
980
|
+
("Target roots", "target_roots"),
|
|
981
|
+
("Forbidden roots", "forbidden_roots"),
|
|
982
|
+
("Decoy roots", "decoy_roots"),
|
|
983
|
+
("Unrelated roots", "unrelated_roots"),
|
|
984
|
+
):
|
|
985
|
+
entries = [
|
|
986
|
+
str(entry.get("path") or "").strip()
|
|
987
|
+
for entry in planning_constraints.get(key) or []
|
|
988
|
+
if isinstance(entry, dict) and str(entry.get("path") or "").strip()
|
|
989
|
+
]
|
|
990
|
+
if entries:
|
|
991
|
+
constraint_lines.append(f"- {label}: " + ", ".join(f"`{path}`" for path in entries))
|
|
992
|
+
if constraint_lines:
|
|
993
|
+
lines.extend(["", "## Planning Constraints", "", *constraint_lines])
|
|
994
|
+
|
|
995
|
+
def _md_cell(value: Any) -> str:
|
|
996
|
+
text = str(value or "").strip()
|
|
997
|
+
if not text:
|
|
998
|
+
return "-"
|
|
999
|
+
return text.replace("|", r"\|").replace("\n", " ")
|
|
1000
|
+
|
|
1001
|
+
lines.extend(["", "## Tasks Overview", ""])
|
|
1002
|
+
if tasks:
|
|
1003
|
+
status_counts: dict[str, int] = {}
|
|
1004
|
+
for task in tasks:
|
|
1005
|
+
status = str(task.get("status") or "planned").strip() or "planned"
|
|
1006
|
+
status_counts[status] = status_counts.get(status, 0) + 1
|
|
1007
|
+
counts_label = ", ".join(
|
|
1008
|
+
f"{status}: {count}" for status, count in sorted(status_counts.items())
|
|
1009
|
+
)
|
|
1010
|
+
if counts_label:
|
|
1011
|
+
lines.append(f"Status counts: {counts_label}")
|
|
1012
|
+
lines.append("")
|
|
1013
|
+
lines.append("| ID | Status | Title | Dependencies |")
|
|
1014
|
+
lines.append("|----|--------|-------|--------------|")
|
|
1015
|
+
if tasks:
|
|
1016
|
+
for task in tasks:
|
|
1017
|
+
tid = _md_cell(task.get("id"))
|
|
1018
|
+
status = _md_cell(task.get("status") or "planned")
|
|
1019
|
+
title = _md_cell(task.get("title"))
|
|
1020
|
+
deps = task.get("dependencies") or []
|
|
1021
|
+
deps_label = ", ".join(str(dep) for dep in deps) if deps else "-"
|
|
1022
|
+
lines.append(f"| {tid} | {status} | {title} | {_md_cell(deps_label)} |")
|
|
1023
|
+
else:
|
|
1024
|
+
lines.append("| - | - | (none) | - |")
|
|
1025
|
+
|
|
1026
|
+
lines.extend(["", "## Tasks", ""])
|
|
1027
|
+
if tasks:
|
|
1028
|
+
for task in tasks:
|
|
1029
|
+
tid = task.get("id", "")
|
|
1030
|
+
title = task.get("title", "")
|
|
1031
|
+
status = task.get("status", "")
|
|
1032
|
+
lines.append(f"### {tid} - {title}")
|
|
1033
|
+
lines.append(f"- Status: `{status}`")
|
|
1034
|
+
attempts = int(task.get("attempts") or 0)
|
|
1035
|
+
lines.append(f"- Attempts: `{attempts}`")
|
|
1036
|
+
desc = str(task.get("description") or "").strip()
|
|
1037
|
+
lines.append(f"- Description: {desc or '(none)'}")
|
|
1038
|
+
deps = task.get("dependencies") or []
|
|
1039
|
+
lines.append(f"- Dependencies: {', '.join(deps) if deps else '(none)'}")
|
|
1040
|
+
asset_lines = _task_asset_markdown_lines(task, asset_titles)
|
|
1041
|
+
if asset_lines:
|
|
1042
|
+
lines.append("- Assets:")
|
|
1043
|
+
lines.extend(asset_lines)
|
|
1044
|
+
acc = task.get("acceptance_criteria") or []
|
|
1045
|
+
if acc:
|
|
1046
|
+
lines.append("- Acceptance Criteria:")
|
|
1047
|
+
for c in acc:
|
|
1048
|
+
lines.append(f" - {c}")
|
|
1049
|
+
else:
|
|
1050
|
+
lines.append("- Acceptance Criteria: (none)")
|
|
1051
|
+
files = task.get("estimated_files") or []
|
|
1052
|
+
lines.append(f"- Estimated Files: {', '.join(files) if files else '(none)'}")
|
|
1053
|
+
write_scope = task.get("write_scope") or []
|
|
1054
|
+
lines.append(f"- Write Scope: {', '.join(write_scope) if write_scope else '(derived)'}")
|
|
1055
|
+
lines.append(
|
|
1056
|
+
"- MCP Scope: "
|
|
1057
|
+
+ describe_task_mcp_scope(
|
|
1058
|
+
normalize_task_mcp_scope(
|
|
1059
|
+
task.get("mcp_scope"),
|
|
1060
|
+
warning_prefix=f"Task {tid or '(unknown)'}",
|
|
1061
|
+
)[0]
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
branch = str(task.get("branch") or "")
|
|
1065
|
+
lines.append(f"- Branch: {branch or '(not set)'}")
|
|
1066
|
+
lines.append("")
|
|
1067
|
+
else:
|
|
1068
|
+
lines.append("- (none)")
|
|
1069
|
+
lines.append("")
|
|
1070
|
+
|
|
1071
|
+
if pinned_assets:
|
|
1072
|
+
lines.extend(["## Pinned Assets", ""])
|
|
1073
|
+
lines.append("These assets are available to all tasks regardless of binding:")
|
|
1074
|
+
for asset_id, title in pinned_assets:
|
|
1075
|
+
lines.append(f'- "{title}" ({asset_id})')
|
|
1076
|
+
lines.append("")
|
|
1077
|
+
|
|
1078
|
+
lines.extend(["## Assets", ""])
|
|
1079
|
+
if assets:
|
|
1080
|
+
for asset in assets:
|
|
1081
|
+
stored = asset.get("stored_path", "")
|
|
1082
|
+
size = asset.get("size_bytes", 0)
|
|
1083
|
+
lines.append(f"- `{stored}` ({size} bytes)")
|
|
1084
|
+
else:
|
|
1085
|
+
lines.append("- (none)")
|
|
1086
|
+
|
|
1087
|
+
lines.extend(
|
|
1088
|
+
[
|
|
1089
|
+
"",
|
|
1090
|
+
"## Notes",
|
|
1091
|
+
"",
|
|
1092
|
+
"- Transcript: `plan/notes/user_notes.md`",
|
|
1093
|
+
"- Planner Chat: `plan/notes/planner_chat.md`",
|
|
1094
|
+
"- Planner Summary: `plan/notes/planner_summary.md`",
|
|
1095
|
+
"- Plan Validation: `plan/notes/plan_validation.md`",
|
|
1096
|
+
]
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _asset_markdown_metadata(
|
|
1103
|
+
asset_index: Any | None,
|
|
1104
|
+
) -> tuple[dict[str, str], list[tuple[str, str]]]:
|
|
1105
|
+
if asset_index is None:
|
|
1106
|
+
return {}, []
|
|
1107
|
+
records_method = getattr(asset_index, "records", None)
|
|
1108
|
+
if not callable(records_method):
|
|
1109
|
+
return {}, []
|
|
1110
|
+
try:
|
|
1111
|
+
records = records_method(include_deleted=True)
|
|
1112
|
+
except Exception:
|
|
1113
|
+
return {}, []
|
|
1114
|
+
titles: dict[str, str] = {}
|
|
1115
|
+
pinned: list[tuple[str, str]] = []
|
|
1116
|
+
for record in records:
|
|
1117
|
+
asset_id = str(getattr(record, "id", "") or "").strip()
|
|
1118
|
+
if not asset_id:
|
|
1119
|
+
continue
|
|
1120
|
+
title = str(getattr(record, "title", "") or "").strip() or "<unknown>"
|
|
1121
|
+
titles[asset_id] = title
|
|
1122
|
+
if bool(getattr(record, "pinned", False)) and getattr(record, "deleted_at", None) is None:
|
|
1123
|
+
pinned.append((asset_id, title))
|
|
1124
|
+
return titles, sorted(pinned)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _task_asset_markdown_lines(task: dict[str, Any], asset_titles: dict[str, str]) -> list[str]:
|
|
1128
|
+
briefing = task.get("asset_briefing")
|
|
1129
|
+
if not isinstance(briefing, dict):
|
|
1130
|
+
return []
|
|
1131
|
+
lines: list[str] = []
|
|
1132
|
+
for group in ("primary", "may_need"):
|
|
1133
|
+
entries = briefing.get(group)
|
|
1134
|
+
if not isinstance(entries, list):
|
|
1135
|
+
continue
|
|
1136
|
+
for entry in entries:
|
|
1137
|
+
if not isinstance(entry, dict):
|
|
1138
|
+
continue
|
|
1139
|
+
asset_id = str(entry.get("asset_id") or "").strip()
|
|
1140
|
+
if not asset_id:
|
|
1141
|
+
continue
|
|
1142
|
+
title = asset_titles.get(asset_id, "<unknown>")
|
|
1143
|
+
rationale = str(entry.get("rationale") or "").strip()
|
|
1144
|
+
lines.append(f' - "{title}" ({asset_id}) - {group} - {rationale}')
|
|
1145
|
+
return lines
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _asset_index_for_markdown(paths: RunPaths) -> Any | None:
|
|
1149
|
+
if not hasattr(paths, "assets_index_path"):
|
|
1150
|
+
return None
|
|
1151
|
+
try:
|
|
1152
|
+
from .assets.index import AssetIndex
|
|
1153
|
+
|
|
1154
|
+
return AssetIndex(paths)
|
|
1155
|
+
except Exception:
|
|
1156
|
+
return None
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def load_plan(paths: RunPaths, *, migrate_legacy: bool = True) -> dict[str, Any]:
|
|
1160
|
+
plan = _read_json(paths.plan_json_path)
|
|
1161
|
+
if migrate_legacy and _plan_schema_version(plan) < PLAN_SCHEMA_VERSION:
|
|
1162
|
+
plan = _migrate_legacy_assets_on_load(paths, plan)
|
|
1163
|
+
tasks = plan.get("tasks")
|
|
1164
|
+
assets = plan.get("assets")
|
|
1165
|
+
requirements = plan.get("requirements")
|
|
1166
|
+
if not isinstance(tasks, list):
|
|
1167
|
+
raise ForgeError("Invalid plan.json: 'tasks' must be an array")
|
|
1168
|
+
if not isinstance(assets, list):
|
|
1169
|
+
raise ForgeError("Invalid plan.json: 'assets' must be an array")
|
|
1170
|
+
if not isinstance(requirements, list):
|
|
1171
|
+
raise ForgeError("Invalid plan.json: 'requirements' must be an array")
|
|
1172
|
+
return plan
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _plan_schema_version(plan: dict[str, Any]) -> int:
|
|
1176
|
+
try:
|
|
1177
|
+
return int(plan.get("schema_version", 1) or 1)
|
|
1178
|
+
except (TypeError, ValueError):
|
|
1179
|
+
return 1
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def _migrate_legacy_assets_on_load(paths: RunPaths, plan: dict[str, Any]) -> dict[str, Any]:
|
|
1183
|
+
try:
|
|
1184
|
+
from .assets.legacy_migration import migrate_legacy_assets
|
|
1185
|
+
from .assets.surface import build_asset_surface
|
|
1186
|
+
from .config import load_config
|
|
1187
|
+
|
|
1188
|
+
cfg = load_config()
|
|
1189
|
+
surface = build_asset_surface(cfg=cfg, run_paths=paths)
|
|
1190
|
+
result = migrate_legacy_assets(
|
|
1191
|
+
cfg=cfg,
|
|
1192
|
+
run_paths=paths,
|
|
1193
|
+
surface=surface,
|
|
1194
|
+
comprehend_mode="async",
|
|
1195
|
+
)
|
|
1196
|
+
except Exception as exc: # noqa: BLE001 - plan loading must remain recoverable
|
|
1197
|
+
LOGGER.warning(
|
|
1198
|
+
"legacy_asset_migration load_plan failed run_id=%s: %s",
|
|
1199
|
+
paths.run_id,
|
|
1200
|
+
exc,
|
|
1201
|
+
)
|
|
1202
|
+
return plan
|
|
1203
|
+
if result.plan_v2_written:
|
|
1204
|
+
try:
|
|
1205
|
+
return _read_json(paths.plan_json_path)
|
|
1206
|
+
except ForgeError:
|
|
1207
|
+
return plan
|
|
1208
|
+
return plan
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _plan_semantic_fingerprint(plan: dict[str, Any]) -> str:
|
|
1212
|
+
semantic_plan = dict(plan)
|
|
1213
|
+
semantic_plan.pop("updated_at", None)
|
|
1214
|
+
return json.dumps(
|
|
1215
|
+
semantic_plan,
|
|
1216
|
+
sort_keys=True,
|
|
1217
|
+
ensure_ascii=False,
|
|
1218
|
+
separators=(",", ":"),
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def save_plan(paths: RunPaths, plan: dict[str, Any]) -> None:
|
|
1223
|
+
persisted_plan: dict[str, Any] | None
|
|
1224
|
+
try:
|
|
1225
|
+
persisted_plan = _read_json(paths.plan_json_path)
|
|
1226
|
+
except ForgeError:
|
|
1227
|
+
persisted_plan = None
|
|
1228
|
+
|
|
1229
|
+
# Keep no-op persistence byte-stable: only semantic plan changes should refresh updated_at.
|
|
1230
|
+
semantic_changed = persisted_plan is None or (
|
|
1231
|
+
_plan_semantic_fingerprint(persisted_plan) != _plan_semantic_fingerprint(plan)
|
|
1232
|
+
)
|
|
1233
|
+
if semantic_changed:
|
|
1234
|
+
plan["updated_at"] = now_iso()
|
|
1235
|
+
elif persisted_plan is not None and "updated_at" in persisted_plan:
|
|
1236
|
+
plan["updated_at"] = persisted_plan["updated_at"]
|
|
1237
|
+
|
|
1238
|
+
rendered_plan_md = render_plan_markdown(plan, asset_index=_asset_index_for_markdown(paths))
|
|
1239
|
+
if semantic_changed or not paths.plan_json_path.exists():
|
|
1240
|
+
_write_json(paths.plan_json_path, plan)
|
|
1241
|
+
|
|
1242
|
+
write_plan_md = semantic_changed or not paths.plan_md_path.exists()
|
|
1243
|
+
if not write_plan_md:
|
|
1244
|
+
try:
|
|
1245
|
+
write_plan_md = paths.plan_md_path.read_text(encoding="utf-8") != rendered_plan_md
|
|
1246
|
+
except OSError:
|
|
1247
|
+
write_plan_md = True
|
|
1248
|
+
if write_plan_md:
|
|
1249
|
+
atomic_write_text(paths.plan_md_path, rendered_plan_md)
|
|
1250
|
+
|
|
1251
|
+
if semantic_changed:
|
|
1252
|
+
# Best-effort: the plan is already on disk, and a pointer that cannot be
|
|
1253
|
+
# updated is a stale status line, not a lost plan.
|
|
1254
|
+
with suppress(Exception):
|
|
1255
|
+
sync_current_run_plan_status(paths, plan)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def load_workspace_context_artifact(paths: RunPaths) -> RepoScanResult | None:
|
|
1259
|
+
artifact_path = paths.workspace_context_json_path
|
|
1260
|
+
if artifact_path is None or not artifact_path.exists():
|
|
1261
|
+
return None
|
|
1262
|
+
raw = _read_json(artifact_path)
|
|
1263
|
+
if int(raw.get("schema_version") or 0) != REPO_SCAN_SCHEMA_VERSION:
|
|
1264
|
+
return None
|
|
1265
|
+
scan = RepoScanResult.from_dict(raw)
|
|
1266
|
+
if not scan.workspace_root:
|
|
1267
|
+
return None
|
|
1268
|
+
return scan
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def format_workspace_context_summary_lines(scan: RepoScanResult) -> list[str]:
|
|
1272
|
+
return render_repo_scan_summary_lines(scan)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def refresh_workspace_context_artifacts(paths: RunPaths) -> RepoScanResult:
|
|
1276
|
+
plan_context_dir = paths.plan_context_dir
|
|
1277
|
+
artifact_path = paths.workspace_context_json_path
|
|
1278
|
+
summary_path = paths.workspace_summary_md_path
|
|
1279
|
+
if plan_context_dir is None or artifact_path is None or summary_path is None:
|
|
1280
|
+
raise ForgeError("Run paths are missing workspace context artifact locations.")
|
|
1281
|
+
plan_context_dir.mkdir(parents=True, exist_ok=True)
|
|
1282
|
+
context = WorkspaceContext(
|
|
1283
|
+
input_path=paths.focus_path or paths.root,
|
|
1284
|
+
focus_path=paths.focus_path or paths.root,
|
|
1285
|
+
workspace_root=paths.root,
|
|
1286
|
+
git_root=paths.git_root,
|
|
1287
|
+
focus_relpath=paths.focus_relpath or ".",
|
|
1288
|
+
workspace_kind=paths.workspace_kind or "plain_dir",
|
|
1289
|
+
has_head_commit=paths.has_head_commit,
|
|
1290
|
+
current_branch=paths.current_branch,
|
|
1291
|
+
)
|
|
1292
|
+
scan = scan_workspace(context=context)
|
|
1293
|
+
_write_json(artifact_path, scan.to_dict())
|
|
1294
|
+
atomic_write_text(summary_path, render_repo_scan_markdown(scan))
|
|
1295
|
+
return scan
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def ensure_workspace_context_artifacts(
|
|
1299
|
+
paths: RunPaths,
|
|
1300
|
+
*,
|
|
1301
|
+
refresh_if_stale: bool = False,
|
|
1302
|
+
) -> RepoScanResult:
|
|
1303
|
+
scan = load_workspace_context_artifact(paths)
|
|
1304
|
+
if scan is None:
|
|
1305
|
+
return refresh_workspace_context_artifacts(paths)
|
|
1306
|
+
if refresh_if_stale and _workspace_context_artifacts_stale(paths, scan):
|
|
1307
|
+
return refresh_workspace_context_artifacts(paths)
|
|
1308
|
+
if paths.workspace_summary_md_path is None or not paths.workspace_summary_md_path.exists():
|
|
1309
|
+
return refresh_workspace_context_artifacts(paths)
|
|
1310
|
+
return scan
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _workspace_context_artifacts_stale(paths: RunPaths, scan: RepoScanResult) -> bool:
|
|
1314
|
+
artifact_path = paths.workspace_context_json_path
|
|
1315
|
+
summary_path = paths.workspace_summary_md_path
|
|
1316
|
+
if artifact_path is None or summary_path is None:
|
|
1317
|
+
return True
|
|
1318
|
+
if not artifact_path.exists() or not summary_path.exists():
|
|
1319
|
+
return True
|
|
1320
|
+
if scan.workspace_root != os.fspath(paths.root):
|
|
1321
|
+
return True
|
|
1322
|
+
if scan.focus_relpath != (paths.focus_relpath or "."):
|
|
1323
|
+
return True
|
|
1324
|
+
if scan.workspace_kind != (paths.workspace_kind or "plain_dir"):
|
|
1325
|
+
return True
|
|
1326
|
+
if (scan.git_root or None) != (
|
|
1327
|
+
os.fspath(paths.git_root) if paths.git_root is not None else None
|
|
1328
|
+
):
|
|
1329
|
+
return True
|
|
1330
|
+
if bool(scan.has_head_commit) != bool(paths.has_head_commit):
|
|
1331
|
+
return True
|
|
1332
|
+
if (scan.current_branch or None) != (paths.current_branch or None):
|
|
1333
|
+
return True
|
|
1334
|
+
|
|
1335
|
+
json_mtime_ns = artifact_path.stat().st_mtime_ns
|
|
1336
|
+
focus_path = paths.focus_path or paths.root
|
|
1337
|
+
tracked_paths = [paths.root, focus_path]
|
|
1338
|
+
tracked_paths.extend((paths.root / rel_path).resolve() for rel_path in scan.observed_paths)
|
|
1339
|
+
for candidate in tracked_paths:
|
|
1340
|
+
try:
|
|
1341
|
+
stat = candidate.stat()
|
|
1342
|
+
except OSError:
|
|
1343
|
+
return True
|
|
1344
|
+
if stat.st_mtime_ns > json_mtime_ns:
|
|
1345
|
+
return True
|
|
1346
|
+
return False
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _sanitize_note_text(value: str) -> str:
|
|
1350
|
+
# Prevent UnicodeEncodeError for invalid surrogate code points from terminal input.
|
|
1351
|
+
return value.encode("utf-8", errors="replace").decode("utf-8")
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def append_transcript_note(paths: RunPaths, *, role: str, message: str) -> None:
|
|
1355
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
1356
|
+
if not paths.notes_path.exists():
|
|
1357
|
+
paths.notes_path.write_text("# Planning Notes\n\n", encoding="utf-8")
|
|
1358
|
+
ts = now_iso()
|
|
1359
|
+
with paths.notes_path.open("a", encoding="utf-8") as fh:
|
|
1360
|
+
safe_role = _sanitize_note_text(role).strip()
|
|
1361
|
+
safe_message = _sanitize_note_text(message).strip()
|
|
1362
|
+
fh.write(f"- [{ts}] {safe_role}: {safe_message}\n")
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def append_planner_chat(paths: RunPaths, *, role: str, message: str) -> None:
|
|
1366
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
1367
|
+
if not paths.planner_chat_path.exists():
|
|
1368
|
+
paths.planner_chat_path.write_text("# Planner Chat\n\n", encoding="utf-8")
|
|
1369
|
+
ts = now_iso()
|
|
1370
|
+
with paths.planner_chat_path.open("a", encoding="utf-8") as fh:
|
|
1371
|
+
safe_role = _sanitize_note_text(role).strip()
|
|
1372
|
+
safe_message = _sanitize_note_text(message).strip()
|
|
1373
|
+
fh.write(f"- [{ts}] {safe_role}: {safe_message}\n")
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def append_planner_summary(paths: RunPaths, summary_line: str) -> None:
|
|
1377
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
1378
|
+
if not paths.planner_summary_path.exists():
|
|
1379
|
+
paths.planner_summary_path.write_text("# Planner Summary\n\n", encoding="utf-8")
|
|
1380
|
+
ts = now_iso()
|
|
1381
|
+
with paths.planner_summary_path.open("a", encoding="utf-8") as fh:
|
|
1382
|
+
safe_summary = _sanitize_note_text(summary_line).strip()
|
|
1383
|
+
fh.write(f"- [{ts}] {safe_summary}\n")
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def finalize_plan(plan: dict[str, Any]) -> dict[str, Any]:
|
|
1387
|
+
raw_requirements = plan.get("requirements") or []
|
|
1388
|
+
requirements = [requirement_text(x) for x in raw_requirements if requirement_text(x)]
|
|
1389
|
+
execution_ready_requirements = [
|
|
1390
|
+
requirement_text(x)
|
|
1391
|
+
for x in raw_requirements
|
|
1392
|
+
if requirement_text(x) and requirement_is_execution_ready(x)
|
|
1393
|
+
]
|
|
1394
|
+
if not plan.get("project_goal"):
|
|
1395
|
+
if requirements:
|
|
1396
|
+
plan["project_goal"] = requirements[0]
|
|
1397
|
+
else:
|
|
1398
|
+
plan["project_goal"] = "Define the project goal and implementation scope."
|
|
1399
|
+
|
|
1400
|
+
if not plan.get("summary"):
|
|
1401
|
+
if requirements:
|
|
1402
|
+
plan["summary"] = " ".join(requirements[:3])
|
|
1403
|
+
else:
|
|
1404
|
+
plan["summary"] = "Initial planning scaffold created."
|
|
1405
|
+
|
|
1406
|
+
tasks = plan.get("tasks") or []
|
|
1407
|
+
if not tasks:
|
|
1408
|
+
if execution_ready_requirements:
|
|
1409
|
+
for req in execution_ready_requirements[:8]:
|
|
1410
|
+
title = req if len(req) <= 80 else req[:77] + "..."
|
|
1411
|
+
add_task(
|
|
1412
|
+
plan,
|
|
1413
|
+
title=title,
|
|
1414
|
+
description=req,
|
|
1415
|
+
_allow_execution_unready=True,
|
|
1416
|
+
)
|
|
1417
|
+
plan["updated_at"] = now_iso()
|
|
1418
|
+
return plan
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
_LIFECYCLE_POINTER_KEYS: tuple[str, ...] = (
|
|
1422
|
+
"status",
|
|
1423
|
+
"status_updated_at",
|
|
1424
|
+
"status_reason",
|
|
1425
|
+
"status_history",
|
|
1426
|
+
"run_owner",
|
|
1427
|
+
"plan_fingerprint",
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def read_current_run_pointer(root: Path) -> dict[str, Any] | None:
|
|
1432
|
+
"""The raw pointer payload for ``root``, or ``None`` when there is none."""
|
|
1433
|
+
try:
|
|
1434
|
+
return _read_json(current_run_pointer_path(root))
|
|
1435
|
+
except ForgeError:
|
|
1436
|
+
return None
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def write_current_run_pointer(paths: RunPaths) -> None:
|
|
1440
|
+
pointer = {
|
|
1441
|
+
"schema_version": CURRENT_RUN_POINTER_SCHEMA_VERSION,
|
|
1442
|
+
"run_id": paths.run_id,
|
|
1443
|
+
"run_path": _repo_rel(paths.root, paths.run_dir),
|
|
1444
|
+
"updated_at": now_iso(),
|
|
1445
|
+
"workspace_root": os.fspath(paths.root),
|
|
1446
|
+
"focus_path": os.fspath(paths.focus_path or paths.root),
|
|
1447
|
+
"focus_relpath": paths.focus_relpath or ".",
|
|
1448
|
+
"workspace_kind": paths.workspace_kind or "plain_dir",
|
|
1449
|
+
"has_head_commit": paths.has_head_commit,
|
|
1450
|
+
"greenfield": paths.greenfield,
|
|
1451
|
+
"binding_requested_path": os.fspath(
|
|
1452
|
+
paths.binding_requested_path or paths.focus_path or paths.root
|
|
1453
|
+
),
|
|
1454
|
+
"binding_source": paths.binding_source or "explicit_path",
|
|
1455
|
+
"workspace_created_at_startup": paths.workspace_created_at_startup,
|
|
1456
|
+
"binding_risk_level": paths.binding_risk_level or WorkspaceRiskLevel.HEALTHY,
|
|
1457
|
+
"binding_risk_reasons": list(paths.binding_risk_reasons),
|
|
1458
|
+
"binding_broad_workspace_override_used": paths.binding_broad_workspace_override_used,
|
|
1459
|
+
}
|
|
1460
|
+
if paths.git_root is not None:
|
|
1461
|
+
pointer["git_root"] = os.fspath(paths.git_root)
|
|
1462
|
+
if paths.current_branch:
|
|
1463
|
+
pointer["current_branch"] = paths.current_branch
|
|
1464
|
+
|
|
1465
|
+
# Rebinding a workspace must not silently reset the run's lifecycle. The binding
|
|
1466
|
+
# fields above are recomputed every time; the lifecycle block belongs to the run
|
|
1467
|
+
# and only ``set_current_run_status`` may move it.
|
|
1468
|
+
existing = read_current_run_pointer(paths.root)
|
|
1469
|
+
if existing is not None and str(existing.get("run_id") or "").strip() == paths.run_id:
|
|
1470
|
+
for key in _LIFECYCLE_POINTER_KEYS:
|
|
1471
|
+
if key in existing:
|
|
1472
|
+
pointer[key] = existing[key]
|
|
1473
|
+
else:
|
|
1474
|
+
pointer["status"] = RUN_STATUS_DRAFT
|
|
1475
|
+
pointer["status_updated_at"] = now_iso()
|
|
1476
|
+
pointer["status_history"] = [
|
|
1477
|
+
{"status": RUN_STATUS_DRAFT, "at": pointer["status_updated_at"]}
|
|
1478
|
+
]
|
|
1479
|
+
_write_json(current_run_pointer_path(paths.root), pointer)
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def set_current_run_status(
|
|
1483
|
+
paths: RunPaths,
|
|
1484
|
+
status: str,
|
|
1485
|
+
*,
|
|
1486
|
+
reason: str = "",
|
|
1487
|
+
owner: dict[str, Any] | None = None,
|
|
1488
|
+
plan: dict[str, Any] | None = None,
|
|
1489
|
+
) -> dict[str, Any] | None:
|
|
1490
|
+
"""Move the current-run pointer to ``status``, if it still tracks this run.
|
|
1491
|
+
|
|
1492
|
+
Returns the written payload, or ``None`` when the pointer is missing or has
|
|
1493
|
+
moved on to a different run -- a finishing run must never stamp its outcome
|
|
1494
|
+
onto whatever run became current in the meantime.
|
|
1495
|
+
"""
|
|
1496
|
+
pointer = read_current_run_pointer(paths.root)
|
|
1497
|
+
if pointer is None or str(pointer.get("run_id") or "").strip() != paths.run_id:
|
|
1498
|
+
return None
|
|
1499
|
+
updated = apply_run_status(
|
|
1500
|
+
pointer,
|
|
1501
|
+
status,
|
|
1502
|
+
reason=reason,
|
|
1503
|
+
owner=owner,
|
|
1504
|
+
plan_fingerprint=plan_fingerprint(plan) if plan is not None else None,
|
|
1505
|
+
)
|
|
1506
|
+
_write_json(current_run_pointer_path(paths.root), updated)
|
|
1507
|
+
return updated
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
def current_run_status(root: Path) -> str:
|
|
1511
|
+
"""The recorded lifecycle status for the workspace's current run."""
|
|
1512
|
+
return pointer_status(read_current_run_pointer(root))
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def sync_current_run_plan_status(paths: RunPaths, plan: dict[str, Any]) -> None:
|
|
1516
|
+
"""Keep the pointer's draft/approved status and fingerprint in step with the plan.
|
|
1517
|
+
|
|
1518
|
+
Called from :func:`save_plan`, which is the one choke point every plan edit goes
|
|
1519
|
+
through. Two things happen here:
|
|
1520
|
+
|
|
1521
|
+
* A plan crossing the execution-readiness gate flips ``draft`` <-> ``approved``,
|
|
1522
|
+
so a UI can show "ready to run" without re-deriving the gate itself.
|
|
1523
|
+
* While a run is ``running``, its own saves refresh the recorded fingerprint.
|
|
1524
|
+
That is what makes drift detection mean "changed while nobody was running it":
|
|
1525
|
+
in-run edits (scope amendments, replanning) are the run observing its own
|
|
1526
|
+
plan, whereas an edit made after the run died is exactly what `forge resume`
|
|
1527
|
+
must stop and surface.
|
|
1528
|
+
"""
|
|
1529
|
+
from .plan_repair import PLAN_STATUS_EXECUTION_READY, plan_status
|
|
1530
|
+
|
|
1531
|
+
pointer = read_current_run_pointer(paths.root)
|
|
1532
|
+
if pointer is None or str(pointer.get("run_id") or "").strip() != paths.run_id:
|
|
1533
|
+
return
|
|
1534
|
+
status = pointer_status(pointer)
|
|
1535
|
+
if status == RUN_STATUS_RUNNING:
|
|
1536
|
+
fingerprint = plan_fingerprint(plan)
|
|
1537
|
+
# Most in-run saves only move task status/attempts, which the fingerprint
|
|
1538
|
+
# deliberately ignores. Rewriting an identical pointer on every one of them
|
|
1539
|
+
# would be pure churn.
|
|
1540
|
+
if pointer.get("plan_fingerprint") != fingerprint:
|
|
1541
|
+
_write_json(
|
|
1542
|
+
current_run_pointer_path(paths.root),
|
|
1543
|
+
{**pointer, "plan_fingerprint": fingerprint},
|
|
1544
|
+
)
|
|
1545
|
+
return
|
|
1546
|
+
if status not in {RUN_STATUS_DRAFT, RUN_STATUS_APPROVED}:
|
|
1547
|
+
return
|
|
1548
|
+
try:
|
|
1549
|
+
ready = plan_status(plan) == PLAN_STATUS_EXECUTION_READY
|
|
1550
|
+
except Exception: # noqa: BLE001 - a plan we cannot grade is not a status change
|
|
1551
|
+
return
|
|
1552
|
+
desired = RUN_STATUS_APPROVED if ready else RUN_STATUS_DRAFT
|
|
1553
|
+
if desired == status:
|
|
1554
|
+
return
|
|
1555
|
+
_write_json(
|
|
1556
|
+
current_run_pointer_path(paths.root),
|
|
1557
|
+
apply_run_status(
|
|
1558
|
+
pointer,
|
|
1559
|
+
desired,
|
|
1560
|
+
reason=(
|
|
1561
|
+
"plan passes the execution-readiness gate"
|
|
1562
|
+
if ready
|
|
1563
|
+
else "plan no longer passes the execution-readiness gate"
|
|
1564
|
+
),
|
|
1565
|
+
plan_fingerprint=plan_fingerprint(plan) if ready else None,
|
|
1566
|
+
),
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def reconcile_current_run_status(root: Path) -> str | None:
|
|
1571
|
+
"""Demote a crashed ``running`` pointer to ``interrupted``. Returns the new status.
|
|
1572
|
+
|
|
1573
|
+
A run marked ``running`` holds the workspace mutation lock for its whole life,
|
|
1574
|
+
so ``running`` with no live lock means the owning process died without ever
|
|
1575
|
+
reaching a terminal state. Rewriting it here is what removes the need for
|
|
1576
|
+
outside tools to patch ``current_run.json`` themselves to un-stick a workspace.
|
|
1577
|
+
"""
|
|
1578
|
+
# Imported lazily: run_lock imports this module, so a top-level import would
|
|
1579
|
+
# close the cycle.
|
|
1580
|
+
from .run_lock import lock_is_live
|
|
1581
|
+
|
|
1582
|
+
pointer = read_current_run_pointer(root)
|
|
1583
|
+
if pointer is None or pointer_status(pointer) != RUN_STATUS_RUNNING:
|
|
1584
|
+
return None
|
|
1585
|
+
workspace_lock_dir = _runtime_dir(root) / "workspace_execution"
|
|
1586
|
+
run_path_raw = str(pointer.get("run_path") or "").strip()
|
|
1587
|
+
run_lock_dirs = [workspace_lock_dir]
|
|
1588
|
+
if run_path_raw and not Path(run_path_raw).is_absolute():
|
|
1589
|
+
run_lock_dirs.append(root / run_path_raw)
|
|
1590
|
+
if any(lock_is_live(candidate) for candidate in run_lock_dirs):
|
|
1591
|
+
return None
|
|
1592
|
+
updated = reconcile_status_after_crash(pointer, lock_is_live=False)
|
|
1593
|
+
if updated is None:
|
|
1594
|
+
return None
|
|
1595
|
+
_write_json(current_run_pointer_path(root), updated)
|
|
1596
|
+
return pointer_status(updated)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def refresh_current_run_pointer_if_tracking_same_run(paths: RunPaths) -> bool:
|
|
1600
|
+
pointer_path = current_run_pointer_path(paths.root)
|
|
1601
|
+
try:
|
|
1602
|
+
pointer = _read_json(pointer_path)
|
|
1603
|
+
except ForgeError:
|
|
1604
|
+
return False
|
|
1605
|
+
if str(pointer.get("run_id") or "").strip() != paths.run_id:
|
|
1606
|
+
return False
|
|
1607
|
+
write_current_run_pointer(paths)
|
|
1608
|
+
return True
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def load_current_run_paths(root: Path) -> RunPaths:
|
|
1612
|
+
input_path = ensure_repo_root(root)
|
|
1613
|
+
workspace_context = _resolve_workspace_context_or_error(input_path)
|
|
1614
|
+
candidates = _candidate_pointer_roots(
|
|
1615
|
+
input_path=input_path,
|
|
1616
|
+
workspace_root=workspace_context.workspace_root,
|
|
1617
|
+
)
|
|
1618
|
+
for pointer_root in candidates:
|
|
1619
|
+
pointer_path = current_run_pointer_path(pointer_root)
|
|
1620
|
+
if not pointer_path.exists():
|
|
1621
|
+
continue
|
|
1622
|
+
# Every surface that shows a run reaches it through here, so this is where a
|
|
1623
|
+
# crash leftover stops being shown as active. Best-effort on purpose: an
|
|
1624
|
+
# unwritable pointer must not make reading the run fail.
|
|
1625
|
+
with suppress(Exception):
|
|
1626
|
+
reconcile_current_run_status(pointer_root)
|
|
1627
|
+
return _run_paths_from_pointer(
|
|
1628
|
+
pointer_root=pointer_root,
|
|
1629
|
+
pointer_path=pointer_path,
|
|
1630
|
+
canonical_workspace_root=workspace_context.workspace_root,
|
|
1631
|
+
)
|
|
1632
|
+
raise ForgeError(f"Missing file: {current_run_pointer_path(workspace_context.workspace_root)}")
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def create_plan_run(
|
|
1636
|
+
root: Path,
|
|
1637
|
+
*,
|
|
1638
|
+
create_if_missing: bool = False,
|
|
1639
|
+
allow_broad_workspace: bool = False,
|
|
1640
|
+
workspace_binding: WorkspaceBinding | None = None,
|
|
1641
|
+
) -> RunPaths:
|
|
1642
|
+
if workspace_binding is None:
|
|
1643
|
+
try:
|
|
1644
|
+
binding = resolve_workspace_binding(
|
|
1645
|
+
root,
|
|
1646
|
+
create_if_missing=create_if_missing,
|
|
1647
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1648
|
+
source="explicit_path",
|
|
1649
|
+
)
|
|
1650
|
+
except WorkspaceBindingError as e:
|
|
1651
|
+
raise ForgeError(str(e)) from e
|
|
1652
|
+
else:
|
|
1653
|
+
binding = workspace_binding
|
|
1654
|
+
try:
|
|
1655
|
+
ensure_workspace_policy(
|
|
1656
|
+
binding,
|
|
1657
|
+
action=WorkspaceAction.FORGE_PLAN,
|
|
1658
|
+
allow_broad_workspace=allow_broad_workspace,
|
|
1659
|
+
)
|
|
1660
|
+
except WorkspaceBindingError:
|
|
1661
|
+
raise _workspace_binding_error(binding) from None
|
|
1662
|
+
paths = _run_paths_from_workspace_context(
|
|
1663
|
+
context=binding.workspace_context,
|
|
1664
|
+
run_id=make_run_id(binding.workspace_context.workspace_root),
|
|
1665
|
+
binding_requested_path=binding.requested_path,
|
|
1666
|
+
binding_source=binding.binding_source,
|
|
1667
|
+
workspace_created_at_startup=binding.created_path,
|
|
1668
|
+
binding_risk_level=binding.risk_level,
|
|
1669
|
+
binding_risk_reasons=binding.risk_reasons,
|
|
1670
|
+
binding_broad_workspace_override_used=binding.broad_workspace_override_used,
|
|
1671
|
+
)
|
|
1672
|
+
_ensure_under_root(paths.root, paths.run_dir)
|
|
1673
|
+
|
|
1674
|
+
paths.assets_dir.mkdir(parents=True, exist_ok=True)
|
|
1675
|
+
paths.asset_store_dir.mkdir(parents=True, exist_ok=True)
|
|
1676
|
+
paths.assets_raw_dir.mkdir(parents=True, exist_ok=True)
|
|
1677
|
+
paths.assets_extracted_dir.mkdir(parents=True, exist_ok=True)
|
|
1678
|
+
paths.assets_comprehensions_dir.mkdir(parents=True, exist_ok=True)
|
|
1679
|
+
paths.notes_dir.mkdir(parents=True, exist_ok=True)
|
|
1680
|
+
paths.plan_replans_dir.mkdir(parents=True, exist_ok=True)
|
|
1681
|
+
paths.decisions_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1682
|
+
paths.knowledge_task_attempts_dir.mkdir(parents=True, exist_ok=True)
|
|
1683
|
+
paths.knowledge_issues_dir.mkdir(parents=True, exist_ok=True)
|
|
1684
|
+
paths.knowledge_facts_dir.mkdir(parents=True, exist_ok=True)
|
|
1685
|
+
paths.knowledge_decisions_dir.mkdir(parents=True, exist_ok=True)
|
|
1686
|
+
paths.knowledge_selected_dir.mkdir(parents=True, exist_ok=True)
|
|
1687
|
+
|
|
1688
|
+
plan = _default_plan_json(paths.run_id)
|
|
1689
|
+
_write_json(paths.plan_json_path, plan)
|
|
1690
|
+
atomic_write_text(
|
|
1691
|
+
paths.plan_md_path,
|
|
1692
|
+
render_plan_markdown(plan, asset_index=_asset_index_for_markdown(paths)),
|
|
1693
|
+
)
|
|
1694
|
+
paths.decisions_path.write_text("", encoding="utf-8")
|
|
1695
|
+
paths.risks_path.write_text("", encoding="utf-8")
|
|
1696
|
+
paths.notes_path.write_text("# Planning Notes\n\n", encoding="utf-8")
|
|
1697
|
+
paths.planner_chat_path.write_text("# Planner Chat\n\n", encoding="utf-8")
|
|
1698
|
+
paths.planner_summary_path.write_text("# Planner Summary\n\n", encoding="utf-8")
|
|
1699
|
+
refresh_workspace_context_artifacts(paths)
|
|
1700
|
+
|
|
1701
|
+
write_current_run_pointer(paths)
|
|
1702
|
+
return paths
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
def _safe_unique_path(directory: Path, filename: str) -> Path:
|
|
1706
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1707
|
+
base_name = Path(filename).name
|
|
1708
|
+
stem = Path(base_name).stem
|
|
1709
|
+
suffix = Path(base_name).suffix
|
|
1710
|
+
candidate = directory / base_name
|
|
1711
|
+
i = 1
|
|
1712
|
+
while candidate.exists():
|
|
1713
|
+
candidate = directory / f"{stem}_{i}{suffix}"
|
|
1714
|
+
i += 1
|
|
1715
|
+
return candidate
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _try_extract_text(source: Path) -> str | None:
|
|
1719
|
+
size = source.stat().st_size
|
|
1720
|
+
if size > MAX_TEXT_ASSET_BYTES:
|
|
1721
|
+
return None
|
|
1722
|
+
raw = source.read_bytes()
|
|
1723
|
+
if b"\x00" in raw:
|
|
1724
|
+
return None
|
|
1725
|
+
try:
|
|
1726
|
+
return raw.decode("utf-8")
|
|
1727
|
+
except UnicodeDecodeError:
|
|
1728
|
+
return None
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def attach_asset(root: Path, source_path: Path) -> tuple[RunPaths, dict[str, Any]]:
|
|
1732
|
+
warnings.warn(
|
|
1733
|
+
"attach_asset() is deprecated. Use AssetSurface.add_asset() or the /assets modal / "
|
|
1734
|
+
"`alysis forge assets add` CLI subcommand. Legacy attached assets are "
|
|
1735
|
+
"auto-migrated on next plan load.",
|
|
1736
|
+
DeprecationWarning,
|
|
1737
|
+
stacklevel=2,
|
|
1738
|
+
)
|
|
1739
|
+
LOGGER.warning("attach_asset deprecated legacy_flow=true")
|
|
1740
|
+
input_path = ensure_repo_root(root)
|
|
1741
|
+
paths = load_current_run_paths(input_path)
|
|
1742
|
+
source = source_path.expanduser().resolve()
|
|
1743
|
+
if not source.exists():
|
|
1744
|
+
raise ForgeError(f"Attachment file does not exist: {source}")
|
|
1745
|
+
if not source.is_file():
|
|
1746
|
+
raise ForgeError(f"Attachment path is not a file: {source}")
|
|
1747
|
+
|
|
1748
|
+
dest = _safe_unique_path(paths.assets_dir, source.name)
|
|
1749
|
+
_ensure_under_root(paths.root, dest)
|
|
1750
|
+
shutil.copy2(source, dest)
|
|
1751
|
+
|
|
1752
|
+
plan = load_plan(paths, migrate_legacy=False)
|
|
1753
|
+
metadata: dict[str, Any] = {
|
|
1754
|
+
"original_path": os.fspath(source),
|
|
1755
|
+
"stored_path": _repo_rel(paths.root, dest),
|
|
1756
|
+
"size_bytes": dest.stat().st_size,
|
|
1757
|
+
"added_at": now_iso(),
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
extracted = _try_extract_text(source)
|
|
1761
|
+
if extracted is not None:
|
|
1762
|
+
text_name = source.stem + ".txt"
|
|
1763
|
+
text_dest = _safe_unique_path(paths.assets_text_dir, text_name)
|
|
1764
|
+
_ensure_under_root(paths.root, text_dest)
|
|
1765
|
+
text_dest.write_text(extracted, encoding="utf-8")
|
|
1766
|
+
metadata["text_copy_path"] = _repo_rel(paths.root, text_dest)
|
|
1767
|
+
|
|
1768
|
+
assets = plan.setdefault("assets", [])
|
|
1769
|
+
if not isinstance(assets, list):
|
|
1770
|
+
raise ForgeError("Invalid plan.json: 'assets' must be an array")
|
|
1771
|
+
assets.append(metadata)
|
|
1772
|
+
plan["schema_version"] = 1
|
|
1773
|
+
plan.pop("legacy_assets_migrated_at", None)
|
|
1774
|
+
save_plan(paths, plan)
|
|
1775
|
+
return paths, metadata
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def ensure_execution_dirs(paths: RunPaths) -> None:
|
|
1779
|
+
paths.execution_reports_dir.mkdir(parents=True, exist_ok=True)
|
|
1780
|
+
paths.execution_patches_dir.mkdir(parents=True, exist_ok=True)
|
|
1781
|
+
paths.execution_logs_dir.mkdir(parents=True, exist_ok=True)
|
|
1782
|
+
paths.execution_sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
1783
|
+
paths.execution_reviews_dir.mkdir(parents=True, exist_ok=True)
|
|
1784
|
+
paths.execution_verify_dir.mkdir(parents=True, exist_ok=True)
|
|
1785
|
+
paths.execution_context_dir.mkdir(parents=True, exist_ok=True)
|
|
1786
|
+
paths.execution_budgets_dir.mkdir(parents=True, exist_ok=True)
|
|
1787
|
+
paths.execution_asset_briefings_dir.mkdir(parents=True, exist_ok=True)
|
|
1788
|
+
paths.execution_asset_usage_dir.mkdir(parents=True, exist_ok=True)
|
|
1789
|
+
paths.execution_knowledge_capture_dir.mkdir(parents=True, exist_ok=True)
|
|
1790
|
+
paths.execution_integration_dir.mkdir(parents=True, exist_ok=True)
|
|
1791
|
+
paths.knowledge_task_attempts_dir.mkdir(parents=True, exist_ok=True)
|
|
1792
|
+
paths.knowledge_issues_dir.mkdir(parents=True, exist_ok=True)
|
|
1793
|
+
paths.knowledge_facts_dir.mkdir(parents=True, exist_ok=True)
|
|
1794
|
+
paths.knowledge_decisions_dir.mkdir(parents=True, exist_ok=True)
|
|
1795
|
+
paths.knowledge_selected_dir.mkdir(parents=True, exist_ok=True)
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _safe_task_filename(task_id: str) -> str:
|
|
1799
|
+
safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in task_id)
|
|
1800
|
+
return safe or "task"
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _real_execution_label(value: object) -> str:
|
|
1804
|
+
if value is True:
|
|
1805
|
+
return "yes"
|
|
1806
|
+
if value is False:
|
|
1807
|
+
return "no"
|
|
1808
|
+
return "unknown"
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _verification_results_lines(
|
|
1812
|
+
verify_payload: dict[str, Any] | None,
|
|
1813
|
+
) -> list[str]:
|
|
1814
|
+
compact_payload = compact_verification_payload(
|
|
1815
|
+
verify_payload,
|
|
1816
|
+
max_command_results=10,
|
|
1817
|
+
output_preview_chars=240,
|
|
1818
|
+
)
|
|
1819
|
+
if compact_payload is None:
|
|
1820
|
+
return ["- Structured verification details unavailable."]
|
|
1821
|
+
|
|
1822
|
+
lines: list[str] = [
|
|
1823
|
+
f"- Summary: {str(compact_payload.get('summary') or '(not available)')}",
|
|
1824
|
+
(
|
|
1825
|
+
f"- All Passed: {'yes' if compact_payload.get('all_passed') else 'no'}"
|
|
1826
|
+
if compact_payload.get("all_passed") is not None
|
|
1827
|
+
else "- All Passed: unknown"
|
|
1828
|
+
),
|
|
1829
|
+
]
|
|
1830
|
+
failed_commands = compact_payload.get("failed_commands")
|
|
1831
|
+
if isinstance(failed_commands, list) and failed_commands:
|
|
1832
|
+
lines.append(
|
|
1833
|
+
"- Failed Commands: "
|
|
1834
|
+
+ ", ".join(str(item).strip() for item in failed_commands if str(item).strip())
|
|
1835
|
+
)
|
|
1836
|
+
else:
|
|
1837
|
+
lines.append("- Failed Commands: (none)")
|
|
1838
|
+
|
|
1839
|
+
command_results = compact_payload.get("command_results")
|
|
1840
|
+
if not isinstance(command_results, list) or not command_results:
|
|
1841
|
+
lines.append("- Command Results: (none)")
|
|
1842
|
+
return lines
|
|
1843
|
+
|
|
1844
|
+
command_results_total = compact_payload.get("command_results_total")
|
|
1845
|
+
if isinstance(command_results_total, int):
|
|
1846
|
+
total_results = command_results_total
|
|
1847
|
+
elif isinstance(command_results_total, str) and command_results_total.strip():
|
|
1848
|
+
try:
|
|
1849
|
+
total_results = int(command_results_total)
|
|
1850
|
+
except ValueError:
|
|
1851
|
+
total_results = len(command_results)
|
|
1852
|
+
else:
|
|
1853
|
+
total_results = len(command_results)
|
|
1854
|
+
if bool(compact_payload.get("command_results_truncated")):
|
|
1855
|
+
lines.append(
|
|
1856
|
+
f"- Command Results: showing {len(command_results)} of {total_results} commands"
|
|
1857
|
+
)
|
|
1858
|
+
else:
|
|
1859
|
+
lines.append(f"- Command Results: {len(command_results)} command(s)")
|
|
1860
|
+
|
|
1861
|
+
for idx, item in enumerate(command_results, start=1):
|
|
1862
|
+
if not isinstance(item, dict):
|
|
1863
|
+
continue
|
|
1864
|
+
command = str(item.get("command") or "").strip() or "(unknown)"
|
|
1865
|
+
effective_command = str(item.get("effective_command") or command).strip() or command
|
|
1866
|
+
lines.extend(
|
|
1867
|
+
[
|
|
1868
|
+
"",
|
|
1869
|
+
f"### Verification Command {idx}",
|
|
1870
|
+
f"- Command: `{command}`",
|
|
1871
|
+
f"- Effective Command: `{effective_command}`",
|
|
1872
|
+
f"- Exit Code: {item.get('exit_code')}",
|
|
1873
|
+
f"- OK: {'yes' if item.get('ok') else 'no'}",
|
|
1874
|
+
f"- Real Execution: {_real_execution_label(item.get('real_execution'))}",
|
|
1875
|
+
f"- Fallback Used: {'yes' if item.get('fallback_used') else 'no'}",
|
|
1876
|
+
]
|
|
1877
|
+
)
|
|
1878
|
+
if item.get("fallback_reason") is not None:
|
|
1879
|
+
lines.append(f"- Fallback Reason: {str(item.get('fallback_reason') or '')}")
|
|
1880
|
+
if item.get("non_execution_reason") is not None:
|
|
1881
|
+
lines.append(f"- Non-Execution Reason: {str(item.get('non_execution_reason') or '')}")
|
|
1882
|
+
preview = str(item.get("output_preview") or "").rstrip()
|
|
1883
|
+
lines.extend(
|
|
1884
|
+
[
|
|
1885
|
+
"- Output Preview:",
|
|
1886
|
+
"",
|
|
1887
|
+
"```text",
|
|
1888
|
+
preview or "(empty)",
|
|
1889
|
+
"```",
|
|
1890
|
+
]
|
|
1891
|
+
)
|
|
1892
|
+
return lines
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def write_task_report(
|
|
1896
|
+
*,
|
|
1897
|
+
paths: RunPaths,
|
|
1898
|
+
task: dict[str, Any],
|
|
1899
|
+
result: str,
|
|
1900
|
+
result_kind: str | None = None,
|
|
1901
|
+
summary: str,
|
|
1902
|
+
started_at: str,
|
|
1903
|
+
finished_at: str,
|
|
1904
|
+
changed_files: list[str],
|
|
1905
|
+
verify_commands: list[str],
|
|
1906
|
+
patch_path: Path,
|
|
1907
|
+
budget_artifact_path: Path | None = None,
|
|
1908
|
+
execution_log_artifacts: ExecutionLogArtifactsResult | None = None,
|
|
1909
|
+
verify_artifact_path: Path | None = None,
|
|
1910
|
+
verify_summary: str | None = None,
|
|
1911
|
+
verify_payload: dict[str, Any] | None = None,
|
|
1912
|
+
verify_command_source: str | None = None,
|
|
1913
|
+
base_branch: str | None = None,
|
|
1914
|
+
task_branch: str | None = None,
|
|
1915
|
+
commit_hash: str | None = None,
|
|
1916
|
+
merge_commit_hash: str | None = None,
|
|
1917
|
+
merge_result: str | None = None,
|
|
1918
|
+
salvaged_nonzero_exit: bool = False,
|
|
1919
|
+
salvaged_agent_exception: bool = False,
|
|
1920
|
+
agent_exception_summary: str | None = None,
|
|
1921
|
+
noop_reason: str | None = None,
|
|
1922
|
+
task_kind: str | None = None,
|
|
1923
|
+
task_lifecycle_reason: str | None = None,
|
|
1924
|
+
remote_lines: list[str] | None = None,
|
|
1925
|
+
scope_amendments: list[dict[str, Any]] | None = None,
|
|
1926
|
+
scope_amended_patterns: list[str] | None = None,
|
|
1927
|
+
) -> Path:
|
|
1928
|
+
ensure_execution_dirs(paths)
|
|
1929
|
+
task_id = str(task.get("id") or "")
|
|
1930
|
+
task_title = str(task.get("title") or "")
|
|
1931
|
+
report_path = paths.execution_reports_dir / f"{_safe_task_filename(task_id)}.md"
|
|
1932
|
+
clean_agent_exception_summary = sanitize_optional_error_summary(agent_exception_summary)
|
|
1933
|
+
lines: list[str] = [
|
|
1934
|
+
f"# Task Execution Report: {task_id}",
|
|
1935
|
+
"",
|
|
1936
|
+
f"- Task Title: {task_title}",
|
|
1937
|
+
f"- Started At: {started_at}",
|
|
1938
|
+
f"- Finished At: {finished_at}",
|
|
1939
|
+
f"- Result: {result}",
|
|
1940
|
+
f"- Result Kind: {result_kind or '(n/a)'}",
|
|
1941
|
+
f"- Task Kind: {task_kind or str(task.get('task_kind') or '(n/a)')}",
|
|
1942
|
+
f"- Task Lifecycle Reason: {task_lifecycle_reason or str(task.get('task_kind_reason') or '(n/a)')}",
|
|
1943
|
+
f"- Salvaged Non-Zero Exit: {'yes' if salvaged_nonzero_exit else 'no'}",
|
|
1944
|
+
f"- Salvaged Agent Exception: {'yes' if salvaged_agent_exception else 'no'}",
|
|
1945
|
+
f"- Agent Exception Summary: {clean_agent_exception_summary or '(none)'}",
|
|
1946
|
+
f"- No-Op Reason: {noop_reason or '(n/a)'}",
|
|
1947
|
+
f"- Patch: `{_repo_rel(paths.root, patch_path)}`",
|
|
1948
|
+
(
|
|
1949
|
+
f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
|
|
1950
|
+
if budget_artifact_path is not None
|
|
1951
|
+
else "- Budget Artifact: (none)"
|
|
1952
|
+
),
|
|
1953
|
+
(
|
|
1954
|
+
f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
|
|
1955
|
+
if verify_artifact_path is not None
|
|
1956
|
+
else "- Verify Artifact: (none)"
|
|
1957
|
+
),
|
|
1958
|
+
(
|
|
1959
|
+
"- Session Logging: enabled"
|
|
1960
|
+
if execution_log_artifacts is None or execution_log_artifacts.logging_enabled
|
|
1961
|
+
else "- Session Logging: disabled (--no-log)"
|
|
1962
|
+
),
|
|
1963
|
+
(
|
|
1964
|
+
f"- Execution Log: `{_repo_rel(paths.root, execution_log_artifacts.copied_log_path)}`"
|
|
1965
|
+
if execution_log_artifacts is not None
|
|
1966
|
+
and execution_log_artifacts.log_retained
|
|
1967
|
+
and execution_log_artifacts.copied_log_path is not None
|
|
1968
|
+
else "- Execution Log: (not retained)"
|
|
1969
|
+
),
|
|
1970
|
+
(
|
|
1971
|
+
f"- Session Artifacts: `{_repo_rel(paths.root, execution_log_artifacts.session_artifact_dir)}`"
|
|
1972
|
+
if execution_log_artifacts is not None
|
|
1973
|
+
and execution_log_artifacts.session_artifacts_retained
|
|
1974
|
+
and execution_log_artifacts.session_artifact_dir is not None
|
|
1975
|
+
else "- Session Artifacts: (none retained)"
|
|
1976
|
+
),
|
|
1977
|
+
f"- Verify Summary: {verify_summary or '(not run)'}",
|
|
1978
|
+
f"- Verify Command Source: {verify_command_source or '(n/a)'}",
|
|
1979
|
+
f"- Base Branch: {base_branch or '(n/a)'}",
|
|
1980
|
+
f"- Task Branch: {task_branch or '(n/a)'}",
|
|
1981
|
+
f"- Commit: {commit_hash or '(none)'}",
|
|
1982
|
+
f"- Merge Commit: {merge_commit_hash or '(none)'}",
|
|
1983
|
+
f"- Merge Result: {merge_result or '(not applicable)'}",
|
|
1984
|
+
"",
|
|
1985
|
+
"## Summary",
|
|
1986
|
+
"",
|
|
1987
|
+
summary or "(no summary)",
|
|
1988
|
+
"",
|
|
1989
|
+
"## Runtime Artifacts",
|
|
1990
|
+
"",
|
|
1991
|
+
]
|
|
1992
|
+
if execution_log_artifacts is None:
|
|
1993
|
+
lines.append("- Session logging/artifact retention details unavailable.")
|
|
1994
|
+
else:
|
|
1995
|
+
lines.extend(
|
|
1996
|
+
[
|
|
1997
|
+
f"- Logging Enabled: {'yes' if execution_log_artifacts.logging_enabled else 'no'}",
|
|
1998
|
+
f"- Log Retained: {'yes' if execution_log_artifacts.log_retained else 'no'}",
|
|
1999
|
+
(
|
|
2000
|
+
f"- Source Log Path: `"
|
|
2001
|
+
f"{safe_serialized_path(execution_log_artifacts.source_log_path, workspace_root=paths.root)}`"
|
|
2002
|
+
if execution_log_artifacts.source_log_path is not None
|
|
2003
|
+
else "- Source Log Path: (none)"
|
|
2004
|
+
),
|
|
2005
|
+
(
|
|
2006
|
+
"- Session Artifacts Retained: yes"
|
|
2007
|
+
if execution_log_artifacts.session_artifacts_retained
|
|
2008
|
+
else "- Session Artifacts Retained: no"
|
|
2009
|
+
),
|
|
2010
|
+
]
|
|
2011
|
+
)
|
|
2012
|
+
if execution_log_artifacts.note is not None:
|
|
2013
|
+
lines.append(f"- Note: {execution_log_artifacts.note}")
|
|
2014
|
+
if execution_log_artifacts.cleanup_note is not None:
|
|
2015
|
+
lines.append(f"- Cleanup Note: {execution_log_artifacts.cleanup_note}")
|
|
2016
|
+
lines.extend(
|
|
2017
|
+
[
|
|
2018
|
+
"",
|
|
2019
|
+
"## Changed Files",
|
|
2020
|
+
"",
|
|
2021
|
+
]
|
|
2022
|
+
)
|
|
2023
|
+
if changed_files:
|
|
2024
|
+
for item in changed_files:
|
|
2025
|
+
lines.append(f"- `{item}`")
|
|
2026
|
+
else:
|
|
2027
|
+
lines.append("- (none detected)")
|
|
2028
|
+
|
|
2029
|
+
lines.extend(["", "## Scope Amendments", ""])
|
|
2030
|
+
if scope_amendments:
|
|
2031
|
+
for item in scope_amendments:
|
|
2032
|
+
path = str(item.get("path") or "")
|
|
2033
|
+
reason_code = str(item.get("reason_code") or "unknown")
|
|
2034
|
+
evidence = str(item.get("evidence") or "")
|
|
2035
|
+
lines.append(f"- `{path}` ({reason_code}): {evidence}")
|
|
2036
|
+
added = [str(pattern) for pattern in (scope_amended_patterns or []) if str(pattern).strip()]
|
|
2037
|
+
lines.append(
|
|
2038
|
+
"- write_scope additions: "
|
|
2039
|
+
+ (", ".join(f"`{pattern}`" for pattern in added) if added else "(already covered)")
|
|
2040
|
+
)
|
|
2041
|
+
else:
|
|
2042
|
+
lines.append("- (none)")
|
|
2043
|
+
|
|
2044
|
+
lines.extend(["", "## Verification Results", ""])
|
|
2045
|
+
lines.extend(_verification_results_lines(verify_payload))
|
|
2046
|
+
|
|
2047
|
+
lines.extend(["", "## How To Verify", ""])
|
|
2048
|
+
if verify_commands:
|
|
2049
|
+
for cmd in verify_commands:
|
|
2050
|
+
lines.append(f"- `{cmd}`")
|
|
2051
|
+
elif verify_command_source == "task_refinement.no_authoritative_commands":
|
|
2052
|
+
lines.append("- No authoritative verification commands were available for this task yet.")
|
|
2053
|
+
else:
|
|
2054
|
+
lines.append("- Run the task-specific tests and checks relevant to the changed files.")
|
|
2055
|
+
|
|
2056
|
+
lines.extend(["", "## Remote Sync", ""])
|
|
2057
|
+
if remote_lines:
|
|
2058
|
+
for line in remote_lines:
|
|
2059
|
+
lines.append(f"- {line}")
|
|
2060
|
+
else:
|
|
2061
|
+
lines.append("- (not used)")
|
|
2062
|
+
|
|
2063
|
+
atomic_write_text(report_path, "\n".join(lines).rstrip() + "\n")
|
|
2064
|
+
return report_path
|