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.
Files changed (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,1409 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ from contextlib import suppress
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+ from uuid import uuid4
13
+
14
+ from .atomic_io import atomic_write_text
15
+ from .forge import RunPaths
16
+
17
+ KNOWLEDGE_INDEX_SCHEMA_VERSION = 5
18
+ KnowledgeEntryKind = Literal["task_attempt", "issue", "fact", "decision"]
19
+ _SUPPORTED_KNOWLEDGE_KINDS: tuple[KnowledgeEntryKind, ...] = (
20
+ "task_attempt",
21
+ "issue",
22
+ "fact",
23
+ "decision",
24
+ )
25
+ OPEN_ISSUE_STATUSES = frozenset({"open", "active", "blocked"})
26
+ RESOLVED_ISSUE_STATUSES = frozenset({"resolved", "closed"})
27
+ ACCEPTED_TASK_ATTEMPT_STATUSES = frozenset({"accepted"})
28
+ PENDING_TASK_ATTEMPT_STATUSES = frozenset({"pending"})
29
+ REJECTED_TASK_ATTEMPT_STATUSES = frozenset({"rejected"})
30
+ _LIFECYCLE_DERIVED_ENTRY_KINDS = frozenset({"issue", "decision", "task_attempt"})
31
+ _KNOWLEDGE_INDEX_REBUILD_LOCK = threading.RLock()
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
36
+
37
+
38
+ def _normalize_path_value(value: str) -> str:
39
+ cleaned = str(value).strip().replace("\\", "/")
40
+ while cleaned.startswith("./"):
41
+ cleaned = cleaned[2:]
42
+ return cleaned.rstrip("/")
43
+
44
+
45
+ def _sanitize_component(value: str) -> str:
46
+ safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in str(value).strip())
47
+ return safe or "item"
48
+
49
+
50
+ def _timestamp_slug(value: str) -> str:
51
+ return (
52
+ value.replace("-", "")
53
+ .replace(":", "")
54
+ .replace("+00:00", "Z")
55
+ .replace("T", "T")
56
+ .replace(".", "")
57
+ )
58
+
59
+
60
+ def _repo_rel(root: Path, path: Path) -> str:
61
+ return os.fspath(path.resolve().relative_to(root.resolve()))
62
+
63
+
64
+ def _trim_preview(text: str, *, max_chars: int = 160) -> str:
65
+ raw = " ".join(line.strip() for line in text.splitlines() if line.strip())
66
+ if len(raw) <= max_chars:
67
+ return raw
68
+ if max_chars <= 3:
69
+ return raw[:max_chars]
70
+ return raw[: max_chars - 3].rstrip() + "..."
71
+
72
+
73
+ def _body_preview(body: str) -> str:
74
+ lines = [
75
+ line.strip()
76
+ for line in body.splitlines()
77
+ if line.strip() and not line.strip().startswith("#")
78
+ ]
79
+ return _trim_preview(" ".join(lines))
80
+
81
+
82
+ def _frontmatter_scalar(value: Any) -> str:
83
+ if value is None:
84
+ return "null"
85
+ return json.dumps(str(value), ensure_ascii=False)
86
+
87
+
88
+ def _parse_frontmatter_scalar(raw: str) -> Any:
89
+ text = raw.strip()
90
+ if not text:
91
+ return ""
92
+ try:
93
+ return json.loads(text)
94
+ except json.JSONDecodeError:
95
+ return text
96
+
97
+
98
+ def _serialize_frontmatter(data: dict[str, Any]) -> str:
99
+ lines = ["---"]
100
+ for key, value in data.items():
101
+ if isinstance(value, (list, tuple)):
102
+ lines.append(f"{key}:")
103
+ for item in value:
104
+ lines.append(f" - {_frontmatter_scalar(item)}")
105
+ continue
106
+ lines.append(f"{key}: {_frontmatter_scalar(value)}")
107
+ lines.append("---")
108
+ return "\n".join(lines)
109
+
110
+
111
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
112
+ lines = text.splitlines()
113
+ if not lines or lines[0].strip() != "---":
114
+ raise ValueError("knowledge entry is missing YAML frontmatter")
115
+ frontmatter_lines: list[str] = []
116
+ body_start = None
117
+ for idx in range(1, len(lines)):
118
+ if lines[idx].strip() == "---":
119
+ body_start = idx + 1
120
+ break
121
+ frontmatter_lines.append(lines[idx])
122
+ if body_start is None:
123
+ raise ValueError("knowledge entry frontmatter is not terminated")
124
+
125
+ data: dict[str, Any] = {}
126
+ current_list_key: str | None = None
127
+ for line in frontmatter_lines:
128
+ stripped = line.rstrip()
129
+ if stripped.startswith(" - "):
130
+ if current_list_key is None:
131
+ raise ValueError("knowledge entry list item missing key")
132
+ items = data.setdefault(current_list_key, [])
133
+ if not isinstance(items, list):
134
+ raise ValueError("knowledge entry frontmatter list state is invalid")
135
+ items.append(str(_parse_frontmatter_scalar(stripped[4:])))
136
+ continue
137
+ current_list_key = None
138
+ if ":" not in stripped:
139
+ raise ValueError(f"invalid knowledge frontmatter line: {stripped}")
140
+ key, raw_value = stripped.split(":", 1)
141
+ normalized_key = key.strip()
142
+ if not normalized_key:
143
+ raise ValueError("knowledge entry frontmatter key cannot be empty")
144
+ if raw_value.strip():
145
+ data[normalized_key] = _parse_frontmatter_scalar(raw_value)
146
+ continue
147
+ data[normalized_key] = []
148
+ current_list_key = normalized_key
149
+
150
+ body = "\n".join(lines[body_start:]).rstrip() + "\n"
151
+ return data, body
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class KnowledgeEntry:
156
+ kind: KnowledgeEntryKind
157
+ id: str
158
+ title: str
159
+ created_at: str
160
+ task_id: str
161
+ source: str
162
+ status: str
163
+ result: str | None = None
164
+ paths: tuple[str, ...] = ()
165
+ related_tasks: tuple[str, ...] = ()
166
+ tags: tuple[str, ...] = ()
167
+ report_path: str | None = None
168
+ patch_path: str | None = None
169
+ verify_artifact_path: str | None = None
170
+ budget_artifact_path: str | None = None
171
+ session_artifact_dir: str | None = None
172
+ signature: str | None = None
173
+ decision_key: str | None = None
174
+ resolves: tuple[str, ...] = ()
175
+ capture_artifact_path: str | None = None
176
+ body: str = ""
177
+ file_path: Path | None = None
178
+
179
+ def to_frontmatter_payload(self) -> dict[str, Any]:
180
+ return {
181
+ "kind": self.kind,
182
+ "id": self.id,
183
+ "title": self.title,
184
+ "created_at": self.created_at,
185
+ "task_id": self.task_id,
186
+ "source": self.source,
187
+ "status": self.status,
188
+ "result": self.result,
189
+ "paths": list(self.paths),
190
+ "related_tasks": list(self.related_tasks),
191
+ "tags": list(self.tags),
192
+ "report_path": self.report_path,
193
+ "patch_path": self.patch_path,
194
+ "verify_artifact_path": self.verify_artifact_path,
195
+ "budget_artifact_path": self.budget_artifact_path,
196
+ "session_artifact_dir": self.session_artifact_dir,
197
+ "signature": self.signature,
198
+ "decision_key": self.decision_key,
199
+ "resolves": list(self.resolves),
200
+ "capture_artifact_path": self.capture_artifact_path,
201
+ }
202
+
203
+ def to_markdown(self) -> str:
204
+ return (
205
+ _serialize_frontmatter(self.to_frontmatter_payload()) + "\n" + self.body.rstrip() + "\n"
206
+ )
207
+
208
+ @classmethod
209
+ def from_markdown(cls, *, text: str, file_path: Path | None = None) -> KnowledgeEntry:
210
+ frontmatter, body = _parse_frontmatter(text)
211
+ kind = str(frontmatter.get("kind") or "").strip()
212
+ if kind not in _SUPPORTED_KNOWLEDGE_KINDS:
213
+ raise ValueError(f"unsupported knowledge kind: {kind}")
214
+ return cls(
215
+ kind=kind, # type: ignore[arg-type]
216
+ id=str(frontmatter.get("id") or "").strip(),
217
+ title=str(frontmatter.get("title") or "").strip(),
218
+ created_at=str(frontmatter.get("created_at") or "").strip(),
219
+ task_id=str(frontmatter.get("task_id") or "").strip(),
220
+ source=str(frontmatter.get("source") or "").strip(),
221
+ status=_normalize_task_attempt_status(
222
+ kind=kind,
223
+ status=str(frontmatter.get("status") or "").strip(),
224
+ result=_optional_text(frontmatter.get("result")),
225
+ ),
226
+ result=_normalize_task_attempt_result(
227
+ kind=kind,
228
+ result=_optional_text(frontmatter.get("result")),
229
+ status=str(frontmatter.get("status") or "").strip(),
230
+ ),
231
+ paths=tuple(
232
+ str(item).strip() for item in frontmatter.get("paths") or [] if str(item).strip()
233
+ ),
234
+ related_tasks=tuple(
235
+ str(item).strip()
236
+ for item in frontmatter.get("related_tasks") or []
237
+ if str(item).strip()
238
+ ),
239
+ tags=tuple(
240
+ str(item).strip() for item in frontmatter.get("tags") or [] if str(item).strip()
241
+ ),
242
+ report_path=_optional_text(frontmatter.get("report_path")),
243
+ patch_path=_optional_text(frontmatter.get("patch_path")),
244
+ verify_artifact_path=_optional_text(frontmatter.get("verify_artifact_path")),
245
+ budget_artifact_path=_optional_text(frontmatter.get("budget_artifact_path")),
246
+ session_artifact_dir=_optional_text(frontmatter.get("session_artifact_dir")),
247
+ signature=_optional_text(frontmatter.get("signature")),
248
+ decision_key=_optional_text(frontmatter.get("decision_key")),
249
+ resolves=tuple(
250
+ str(item).strip() for item in frontmatter.get("resolves") or [] if str(item).strip()
251
+ ),
252
+ capture_artifact_path=_optional_text(frontmatter.get("capture_artifact_path")),
253
+ body=body,
254
+ file_path=file_path,
255
+ )
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class KnowledgeIndexEntry:
260
+ id: str
261
+ kind: KnowledgeEntryKind
262
+ title: str
263
+ preview: str
264
+ run_id: str
265
+ task_id: str
266
+ source: str
267
+ status: str
268
+ tags: tuple[str, ...]
269
+ paths: tuple[str, ...]
270
+ related_tasks: tuple[str, ...]
271
+ created_at: str
272
+ knowledge_file_path: str
273
+ result: str | None = None
274
+ signature: str | None = None
275
+ decision_key: str | None = None
276
+ resolves: tuple[str, ...] = ()
277
+ capture_artifact_path: str | None = None
278
+ effective_status: str = ""
279
+
280
+ def to_payload(self) -> dict[str, Any]:
281
+ return {
282
+ "id": self.id,
283
+ "kind": self.kind,
284
+ "title": self.title,
285
+ "preview": self.preview,
286
+ "run_id": self.run_id,
287
+ "task_id": self.task_id,
288
+ "source": self.source,
289
+ "status": self.status,
290
+ "result": self.result,
291
+ "tags": list(self.tags),
292
+ "paths": list(self.paths),
293
+ "related_tasks": list(self.related_tasks),
294
+ "created_at": self.created_at,
295
+ "knowledge_file_path": self.knowledge_file_path,
296
+ "signature": self.signature,
297
+ "decision_key": self.decision_key,
298
+ "resolves": list(self.resolves),
299
+ "capture_artifact_path": self.capture_artifact_path,
300
+ "effective_status": self.effective_status or self.status,
301
+ }
302
+
303
+
304
+ @dataclass(frozen=True)
305
+ class KnowledgeIndex:
306
+ path: Path
307
+ entries: tuple[KnowledgeIndexEntry, ...]
308
+ generated_at: str
309
+ invalid_entries: tuple[KnowledgeIndexInvalidEntry, ...] = ()
310
+
311
+ def to_payload(self) -> dict[str, Any]:
312
+ return {
313
+ "schema_version": KNOWLEDGE_INDEX_SCHEMA_VERSION,
314
+ "generated_at": self.generated_at,
315
+ "entry_count": len(self.entries),
316
+ "invalid_entry_count": len(self.invalid_entries),
317
+ "invalid_entries": [entry.to_payload() for entry in self.invalid_entries],
318
+ "entries": [entry.to_payload() for entry in self.entries],
319
+ }
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class KnowledgeIndexInvalidEntry:
324
+ knowledge_file_path: str
325
+ error: str
326
+
327
+ def to_payload(self) -> dict[str, Any]:
328
+ return {
329
+ "knowledge_file_path": self.knowledge_file_path,
330
+ "error": self.error,
331
+ }
332
+
333
+
334
+ def _optional_text(value: Any) -> str | None:
335
+ text = str(value or "").strip()
336
+ return text or None
337
+
338
+
339
+ def _normalize_optional_signature(value: Any) -> str | None:
340
+ text = str(value or "").strip()
341
+ return text or None
342
+
343
+
344
+ def _normalize_resolve_ids(values: list[str] | tuple[str, ...] | None) -> tuple[str, ...]:
345
+ return tuple(dict.fromkeys(str(item).strip() for item in (values or []) if str(item).strip()))
346
+
347
+
348
+ def _knowledge_index_payload_is_compatible(raw: dict[str, Any]) -> bool:
349
+ if raw.get("schema_version") != KNOWLEDGE_INDEX_SCHEMA_VERSION:
350
+ return False
351
+ entries = raw.get("entries")
352
+ if not isinstance(entries, list):
353
+ return False
354
+ for item in entries:
355
+ if not isinstance(item, dict):
356
+ return False
357
+ kind = str(item.get("kind") or "").strip()
358
+ if (
359
+ kind in _LIFECYCLE_DERIVED_ENTRY_KINDS
360
+ and not str(item.get("effective_status") or "").strip()
361
+ ):
362
+ return False
363
+ return True
364
+
365
+
366
+ def _normalize_task_attempt_result(*, kind: str, result: str | None, status: str) -> str | None:
367
+ if kind != "task_attempt":
368
+ return result
369
+ text = str(result or "").strip()
370
+ if text:
371
+ return text
372
+ legacy_status = str(status or "").strip()
373
+ if legacy_status in {"success", "failure"}:
374
+ return legacy_status
375
+ return None
376
+
377
+
378
+ def _normalize_task_attempt_status(*, kind: str, status: str, result: str | None) -> str:
379
+ if kind != "task_attempt":
380
+ return status
381
+ text = str(status or "").strip()
382
+ if text in {"accepted", "pending", "rejected"}:
383
+ return text
384
+ if text == "success":
385
+ return "accepted"
386
+ if text == "failure":
387
+ return "rejected"
388
+ normalized_result = str(result or "").strip()
389
+ if normalized_result == "success":
390
+ return "accepted"
391
+ if normalized_result == "failure":
392
+ return "rejected"
393
+ return text
394
+
395
+
396
+ def derive_run_id_from_knowledge_file_path(knowledge_file_path: str | Path | None) -> str:
397
+ if knowledge_file_path is None:
398
+ return ""
399
+ parts = Path(str(knowledge_file_path)).parts
400
+ for index in range(len(parts) - 2):
401
+ if parts[index] == ".alysis" and parts[index + 1] == "runs":
402
+ return parts[index + 2]
403
+ return ""
404
+
405
+
406
+ def is_effectively_open_status(status: str | None) -> bool:
407
+ return str(status or "").strip() in OPEN_ISSUE_STATUSES
408
+
409
+
410
+ def is_effectively_accepted_task_attempt(status: str | None) -> bool:
411
+ return str(status or "").strip() in ACCEPTED_TASK_ATTEMPT_STATUSES
412
+
413
+
414
+ def _derive_effective_issue_statuses(records: list[KnowledgeEntry]) -> dict[str, str]:
415
+ issues = [record for record in records if record.kind == "issue"]
416
+ effective_statuses = {record.id: record.status for record in issues}
417
+ issue_ids = set(effective_statuses)
418
+ for record in issues:
419
+ if record.status not in RESOLVED_ISSUE_STATUSES or not record.resolves:
420
+ continue
421
+ for resolved_id in record.resolves:
422
+ if resolved_id in issue_ids and is_effectively_open_status(
423
+ effective_statuses.get(resolved_id)
424
+ ):
425
+ effective_statuses[resolved_id] = "resolved"
426
+ return effective_statuses
427
+
428
+
429
+ def _derive_effective_decision_statuses(records: list[KnowledgeEntry]) -> dict[str, str]:
430
+ effective_statuses = {
431
+ record.id: record.status for record in records if record.kind == "decision"
432
+ }
433
+ decisions_by_key: dict[str, list[KnowledgeEntry]] = {}
434
+ for record in records:
435
+ if record.kind != "decision" or not record.decision_key:
436
+ continue
437
+ decisions_by_key.setdefault(record.decision_key, []).append(record)
438
+ for decision_records in decisions_by_key.values():
439
+ latest = max(
440
+ decision_records,
441
+ key=lambda record: (
442
+ record.created_at,
443
+ record.id,
444
+ record.file_path.as_posix() if record.file_path is not None else "",
445
+ ),
446
+ )
447
+ for record in decision_records:
448
+ effective_statuses[record.id] = latest.status
449
+ return effective_statuses
450
+
451
+
452
+ def _derive_effective_task_attempt_statuses(records: list[KnowledgeEntry]) -> dict[str, str]:
453
+ attempts = [record for record in records if record.kind == "task_attempt"]
454
+ effective_statuses = {record.id: record.status for record in attempts}
455
+ ordered_attempts = sorted(
456
+ attempts,
457
+ key=lambda record: (
458
+ record.created_at,
459
+ record.id,
460
+ record.file_path.as_posix() if record.file_path is not None else "",
461
+ ),
462
+ )
463
+ attempt_ids = set(effective_statuses)
464
+ for record in ordered_attempts:
465
+ if record.kind != "task_attempt" or not record.resolves:
466
+ continue
467
+ for resolved_id in record.resolves:
468
+ if resolved_id in attempt_ids:
469
+ effective_statuses[resolved_id] = record.status
470
+ return effective_statuses
471
+
472
+
473
+ def _entry_kind_dir(paths: RunPaths, kind: KnowledgeEntryKind) -> Path:
474
+ if kind == "task_attempt":
475
+ return paths.knowledge_task_attempts_dir
476
+ if kind == "issue":
477
+ return paths.knowledge_issues_dir
478
+ if kind == "fact":
479
+ return paths.knowledge_facts_dir
480
+ return paths.knowledge_decisions_dir
481
+
482
+
483
+ def _knowledge_bucket_dir(paths: RunPaths, *, kind: KnowledgeEntryKind, task_id: str) -> Path:
484
+ bucket = _entry_kind_dir(paths, kind)
485
+ task_component = _sanitize_component(task_id or "general")
486
+ directory = bucket / task_component
487
+ directory.mkdir(parents=True, exist_ok=True)
488
+ return directory
489
+
490
+
491
+ def _write_text_atomic(path: Path, text: str) -> None:
492
+ atomic_write_text(path, text)
493
+
494
+
495
+ def _unique_entry_identity(
496
+ *, kind: KnowledgeEntryKind, task_id: str, source: str, created_at: str
497
+ ) -> tuple[str, str]:
498
+ suffix = uuid4().hex[:8]
499
+ identity = f"{kind}-{_sanitize_component(task_id)}-{_sanitize_component(source)}-{_timestamp_slug(created_at)}-{suffix}"
500
+ filename_stem = f"{_timestamp_slug(created_at)}_{_sanitize_component(source)}_{suffix}"
501
+ return identity, filename_stem
502
+
503
+
504
+ def _publish_markdown_entry_atomic(*, directory: Path, stem: str, markdown_text: str) -> Path:
505
+ directory.mkdir(parents=True, exist_ok=True)
506
+ final_path = directory / f"{stem}.md"
507
+ if final_path.exists():
508
+ raise FileExistsError(f"knowledge entry already exists: {final_path}")
509
+ fd, temp_name = tempfile.mkstemp(
510
+ dir=directory,
511
+ prefix=f".{stem}.",
512
+ suffix=".tmp",
513
+ text=True,
514
+ )
515
+ temp_path = Path(temp_name)
516
+ try:
517
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
518
+ handle.write(markdown_text)
519
+ os.replace(temp_path, final_path)
520
+ finally:
521
+ with suppress(FileNotFoundError):
522
+ temp_path.unlink()
523
+ return final_path
524
+
525
+
526
+ def list_workspace_knowledge_entry_paths(root: Path) -> list[Path]:
527
+ knowledge_root = root.resolve() / ".alysis" / "runs"
528
+ if not knowledge_root.exists():
529
+ return []
530
+ paths: list[Path] = []
531
+ for kind_dir_name in ("task_attempts", "issues", "facts", "decisions"):
532
+ for path in sorted(knowledge_root.glob(f"*/knowledge/{kind_dir_name}/**/*.md")):
533
+ if path.is_file():
534
+ paths.append(path)
535
+ return paths
536
+
537
+
538
+ def load_knowledge_entry(path: Path) -> KnowledgeEntry:
539
+ return KnowledgeEntry.from_markdown(
540
+ text=path.read_text(encoding="utf-8"),
541
+ file_path=path,
542
+ )
543
+
544
+
545
+ def _invalid_entry_error(exc: Exception) -> str:
546
+ return _trim_preview(f"{type(exc).__name__}: {exc}", max_chars=240)
547
+
548
+
549
+ def rebuild_knowledge_index(paths: RunPaths) -> KnowledgeIndex:
550
+ """Rebuild the shared workspace index without concurrent replace races."""
551
+ with _KNOWLEDGE_INDEX_REBUILD_LOCK:
552
+ return _rebuild_knowledge_index_unlocked(paths)
553
+
554
+
555
+ def _rebuild_knowledge_index_unlocked(paths: RunPaths) -> KnowledgeIndex:
556
+ records: list[KnowledgeEntry] = []
557
+ invalid_entries: list[KnowledgeIndexInvalidEntry] = []
558
+ for path in list_workspace_knowledge_entry_paths(paths.root):
559
+ try:
560
+ record = load_knowledge_entry(path)
561
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
562
+ invalid_entries.append(
563
+ KnowledgeIndexInvalidEntry(
564
+ knowledge_file_path=_repo_rel(paths.root, path),
565
+ error=_invalid_entry_error(exc),
566
+ )
567
+ )
568
+ continue
569
+ records.append(record)
570
+ effective_issue_statuses = _derive_effective_issue_statuses(records)
571
+ effective_decision_statuses = _derive_effective_decision_statuses(records)
572
+ effective_task_attempt_statuses = _derive_effective_task_attempt_statuses(records)
573
+ entries: list[KnowledgeIndexEntry] = []
574
+ for record in records:
575
+ if record.file_path is None:
576
+ continue
577
+ entries.append(
578
+ KnowledgeIndexEntry(
579
+ id=record.id,
580
+ kind=record.kind,
581
+ title=record.title,
582
+ preview=_body_preview(record.body),
583
+ run_id=derive_run_id_from_knowledge_file_path(
584
+ _repo_rel(paths.root, record.file_path)
585
+ ),
586
+ task_id=record.task_id,
587
+ source=record.source,
588
+ status=record.status,
589
+ result=record.result,
590
+ tags=tuple(record.tags),
591
+ paths=tuple(_normalize_path_value(item) for item in record.paths if item.strip()),
592
+ related_tasks=tuple(record.related_tasks),
593
+ created_at=record.created_at,
594
+ knowledge_file_path=_repo_rel(paths.root, record.file_path),
595
+ signature=record.signature,
596
+ decision_key=record.decision_key,
597
+ resolves=record.resolves,
598
+ capture_artifact_path=record.capture_artifact_path,
599
+ effective_status=(
600
+ effective_task_attempt_statuses.get(record.id)
601
+ or effective_issue_statuses.get(record.id)
602
+ or effective_decision_statuses.get(record.id)
603
+ or record.status
604
+ ),
605
+ )
606
+ )
607
+
608
+ entries.sort(
609
+ key=lambda entry: (
610
+ entry.created_at,
611
+ entry.kind,
612
+ entry.id,
613
+ entry.knowledge_file_path,
614
+ ),
615
+ reverse=True,
616
+ )
617
+ invalid_entries.sort(
618
+ key=lambda entry: (
619
+ entry.knowledge_file_path,
620
+ entry.error,
621
+ )
622
+ )
623
+ index = KnowledgeIndex(
624
+ path=paths.knowledge_index_path,
625
+ entries=tuple(entries),
626
+ generated_at=_now_iso(),
627
+ invalid_entries=tuple(invalid_entries),
628
+ )
629
+ _write_text_atomic(
630
+ paths.knowledge_index_path,
631
+ json.dumps(index.to_payload(), indent=2, sort_keys=True) + "\n",
632
+ )
633
+ return index
634
+
635
+
636
+ def load_knowledge_index(paths: RunPaths, *, rebuild: bool = False) -> KnowledgeIndex:
637
+ """Load the shared index without racing a Windows atomic replacement."""
638
+ with _KNOWLEDGE_INDEX_REBUILD_LOCK:
639
+ return _load_knowledge_index_unlocked(paths, rebuild=rebuild)
640
+
641
+
642
+ def _load_knowledge_index_unlocked(paths: RunPaths, *, rebuild: bool = False) -> KnowledgeIndex:
643
+ if rebuild or not paths.knowledge_index_path.exists():
644
+ return rebuild_knowledge_index(paths)
645
+ try:
646
+ raw = json.loads(paths.knowledge_index_path.read_text(encoding="utf-8"))
647
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
648
+ return rebuild_knowledge_index(paths)
649
+ if not isinstance(raw, dict):
650
+ return rebuild_knowledge_index(paths)
651
+ if not _knowledge_index_payload_is_compatible(raw):
652
+ return rebuild_knowledge_index(paths)
653
+ try:
654
+ entries = tuple(
655
+ KnowledgeIndexEntry(
656
+ id=str(item.get("id") or "").strip(),
657
+ kind=str(item.get("kind") or "").strip(), # type: ignore[arg-type]
658
+ title=str(item.get("title") or "").strip(),
659
+ preview=str(item.get("preview") or "").strip(),
660
+ run_id=(
661
+ str(item.get("run_id") or "").strip()
662
+ or derive_run_id_from_knowledge_file_path(item.get("knowledge_file_path"))
663
+ ),
664
+ task_id=str(item.get("task_id") or "").strip(),
665
+ source=str(item.get("source") or "").strip(),
666
+ status=_normalize_task_attempt_status(
667
+ kind=str(item.get("kind") or "").strip(),
668
+ status=str(item.get("status") or "").strip(),
669
+ result=_optional_text(item.get("result")),
670
+ ),
671
+ result=_normalize_task_attempt_result(
672
+ kind=str(item.get("kind") or "").strip(),
673
+ result=_optional_text(item.get("result")),
674
+ status=str(item.get("status") or "").strip(),
675
+ ),
676
+ tags=tuple(str(tag).strip() for tag in item.get("tags") or [] if str(tag).strip()),
677
+ paths=tuple(
678
+ str(path).strip() for path in item.get("paths") or [] if str(path).strip()
679
+ ),
680
+ related_tasks=tuple(
681
+ str(task_id).strip()
682
+ for task_id in item.get("related_tasks") or []
683
+ if str(task_id).strip()
684
+ ),
685
+ created_at=str(item.get("created_at") or "").strip(),
686
+ knowledge_file_path=str(item.get("knowledge_file_path") or "").strip(),
687
+ signature=_normalize_optional_signature(item.get("signature")),
688
+ decision_key=_optional_text(item.get("decision_key")),
689
+ resolves=_normalize_resolve_ids(item.get("resolves")),
690
+ capture_artifact_path=_optional_text(item.get("capture_artifact_path")),
691
+ effective_status=_normalize_task_attempt_status(
692
+ kind=str(item.get("kind") or "").strip(),
693
+ status=str(item.get("effective_status") or item.get("status") or "").strip(),
694
+ result=_optional_text(item.get("result")),
695
+ ),
696
+ )
697
+ for item in raw.get("entries") or []
698
+ if isinstance(item, dict)
699
+ )
700
+ invalid_entries = tuple(
701
+ KnowledgeIndexInvalidEntry(
702
+ knowledge_file_path=str(item.get("knowledge_file_path") or "").strip(),
703
+ error=str(item.get("error") or "").strip(),
704
+ )
705
+ for item in raw.get("invalid_entries") or []
706
+ if isinstance(item, dict)
707
+ )
708
+ except AttributeError:
709
+ return rebuild_knowledge_index(paths)
710
+ return KnowledgeIndex(
711
+ path=paths.knowledge_index_path,
712
+ entries=entries,
713
+ generated_at=str(raw.get("generated_at") or "").strip(),
714
+ invalid_entries=invalid_entries,
715
+ )
716
+
717
+
718
+ def load_task_attempt_entries_for_task(
719
+ *, paths: RunPaths, task_id: str
720
+ ) -> tuple[KnowledgeEntry, ...]:
721
+ directory = paths.knowledge_task_attempts_dir / str(task_id).strip()
722
+ if not directory.exists():
723
+ return ()
724
+ entries: list[KnowledgeEntry] = []
725
+ for path in sorted(directory.glob("*.md")):
726
+ if not path.is_file():
727
+ continue
728
+ try:
729
+ entry = load_knowledge_entry(path)
730
+ except (OSError, UnicodeDecodeError, ValueError):
731
+ continue
732
+ if entry.kind == "task_attempt":
733
+ entries.append(entry)
734
+ return tuple(entries)
735
+
736
+
737
+ def find_latest_task_attempt_entry(
738
+ *,
739
+ paths: RunPaths,
740
+ task_id: str,
741
+ source: str | None = None,
742
+ include_resolution_entries: bool = False,
743
+ ) -> KnowledgeEntry | None:
744
+ entries = load_task_attempt_entries_for_task(paths=paths, task_id=task_id)
745
+ candidates = [
746
+ entry
747
+ for entry in entries
748
+ if (source is None or entry.source == source)
749
+ and (include_resolution_entries or not entry.resolves)
750
+ ]
751
+ if not candidates:
752
+ return None
753
+ return max(
754
+ candidates,
755
+ key=lambda entry: (
756
+ entry.created_at,
757
+ entry.id,
758
+ entry.file_path.as_posix() if entry.file_path is not None else "",
759
+ ),
760
+ )
761
+
762
+
763
+ def _has_task_attempt_resolution(
764
+ *,
765
+ paths: RunPaths,
766
+ task_id: str,
767
+ resolved_attempt_id: str,
768
+ acceptance_state: str,
769
+ ) -> bool:
770
+ return any(
771
+ resolved_attempt_id in entry.resolves and entry.status == acceptance_state
772
+ for entry in load_task_attempt_entries_for_task(paths=paths, task_id=task_id)
773
+ )
774
+
775
+
776
+ def write_task_attempt_entry(
777
+ *,
778
+ paths: RunPaths,
779
+ task: dict[str, Any],
780
+ source: str,
781
+ result: str | None,
782
+ summary: str,
783
+ changed_files: list[str],
784
+ verify_summary: str | None,
785
+ report_path: Path | None,
786
+ patch_path: Path | None,
787
+ verify_artifact_path: Path | None,
788
+ budget_artifact_path: Path | None,
789
+ session_artifact_dir: Path | None,
790
+ acceptance_state: str | None = None,
791
+ resolves: list[str] | tuple[str, ...] | None = None,
792
+ created_at: str | None = None,
793
+ extra_tags: list[str] | tuple[str, ...] = (),
794
+ ) -> KnowledgeEntry:
795
+ created = created_at or _now_iso()
796
+ task_id = str(task.get("id") or "").strip() or "task"
797
+ task_title = str(task.get("title") or "").strip() or task_id
798
+ entry_id, filename_stem = _unique_entry_identity(
799
+ kind="task_attempt",
800
+ task_id=task_id,
801
+ source=source,
802
+ created_at=created,
803
+ )
804
+ related_tasks = tuple(
805
+ item for item in (str(dep).strip() for dep in task.get("dependencies") or []) if item
806
+ )
807
+ raw_result = str(result or "").strip() or None
808
+ normalized_acceptance_state = str(acceptance_state or "").strip() or (
809
+ "accepted" if raw_result == "success" else "rejected"
810
+ )
811
+ resolve_ids = _normalize_resolve_ids(resolves)
812
+ fallback_paths = [
813
+ *[str(path).strip() for path in task.get("write_scope") or []],
814
+ *[str(path).strip() for path in task.get("estimated_files") or []],
815
+ ]
816
+ effective_paths = tuple(
817
+ dict.fromkeys(
818
+ _normalize_path_value(path)
819
+ for path in (list(changed_files) or fallback_paths)
820
+ if _normalize_path_value(str(path))
821
+ )
822
+ )
823
+ body_lines = [
824
+ f"# Task Attempt: {task_id}",
825
+ "",
826
+ f"- Raw Result: `{raw_result}`" if raw_result else "- Raw Result: (resolution only)",
827
+ f"- Acceptance State: `{normalized_acceptance_state}`",
828
+ f"- Source: `{source}`",
829
+ f"- Summary: {summary or '(none)'}",
830
+ "",
831
+ "## Changed Files",
832
+ "",
833
+ ]
834
+ if effective_paths:
835
+ body_lines.extend(f"- `{path}`" for path in effective_paths)
836
+ else:
837
+ body_lines.append("- (none)")
838
+ body_lines.extend(
839
+ [
840
+ "",
841
+ "## Lifecycle",
842
+ "",
843
+ ]
844
+ )
845
+ if resolve_ids:
846
+ body_lines.extend(f"- Resolves: `{item}`" for item in resolve_ids)
847
+ else:
848
+ body_lines.append("- Resolves: (none)")
849
+ body_lines.extend(
850
+ [
851
+ "",
852
+ "## Verification",
853
+ "",
854
+ f"- Summary: {verify_summary or '(none)'}",
855
+ "",
856
+ "## Related Artifacts",
857
+ "",
858
+ f"- Report: `{_repo_rel(paths.root, report_path)}`"
859
+ if report_path
860
+ else "- Report: (none)",
861
+ f"- Patch: `{_repo_rel(paths.root, patch_path)}`" if patch_path else "- Patch: (none)",
862
+ (
863
+ f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
864
+ if verify_artifact_path
865
+ else "- Verify Artifact: (none)"
866
+ ),
867
+ (
868
+ f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
869
+ if budget_artifact_path
870
+ else "- Budget Artifact: (none)"
871
+ ),
872
+ (
873
+ f"- Session Artifacts: `{_repo_rel(paths.root, session_artifact_dir)}`"
874
+ if session_artifact_dir
875
+ else "- Session Artifacts: (none retained)"
876
+ ),
877
+ ]
878
+ )
879
+ entry = KnowledgeEntry(
880
+ kind="task_attempt",
881
+ id=entry_id,
882
+ title=f"{task_id}: {task_title}",
883
+ created_at=created,
884
+ task_id=task_id,
885
+ source=source,
886
+ status=normalized_acceptance_state,
887
+ result=raw_result,
888
+ paths=effective_paths,
889
+ related_tasks=related_tasks,
890
+ tags=tuple(
891
+ dict.fromkeys(
892
+ [
893
+ source,
894
+ normalized_acceptance_state,
895
+ *([raw_result] if raw_result else []),
896
+ *[str(tag).strip() for tag in extra_tags if str(tag).strip()],
897
+ ]
898
+ )
899
+ ),
900
+ report_path=_repo_rel(paths.root, report_path) if report_path else None,
901
+ patch_path=_repo_rel(paths.root, patch_path) if patch_path else None,
902
+ verify_artifact_path=(
903
+ _repo_rel(paths.root, verify_artifact_path) if verify_artifact_path else None
904
+ ),
905
+ budget_artifact_path=(
906
+ _repo_rel(paths.root, budget_artifact_path) if budget_artifact_path else None
907
+ ),
908
+ session_artifact_dir=(
909
+ _repo_rel(paths.root, session_artifact_dir) if session_artifact_dir else None
910
+ ),
911
+ resolves=resolve_ids,
912
+ body="\n".join(body_lines).rstrip() + "\n",
913
+ )
914
+ directory = _knowledge_bucket_dir(paths, kind="task_attempt", task_id=task_id)
915
+ file_path = _publish_markdown_entry_atomic(
916
+ directory=directory,
917
+ stem=filename_stem,
918
+ markdown_text=entry.to_markdown(),
919
+ )
920
+ return KnowledgeEntry.from_markdown(
921
+ text=file_path.read_text(encoding="utf-8"), file_path=file_path
922
+ )
923
+
924
+
925
+ def write_task_attempt_resolution_entry(
926
+ *,
927
+ paths: RunPaths,
928
+ task: dict[str, Any],
929
+ source: str,
930
+ acceptance_state: str,
931
+ resolved_attempt_id: str,
932
+ summary: str,
933
+ changed_files: list[str],
934
+ verify_summary: str | None,
935
+ report_path: Path | None,
936
+ patch_path: Path | None,
937
+ verify_artifact_path: Path | None,
938
+ budget_artifact_path: Path | None,
939
+ session_artifact_dir: Path | None,
940
+ created_at: str | None = None,
941
+ extra_tags: list[str] | tuple[str, ...] = (),
942
+ ) -> KnowledgeEntry:
943
+ task_id = str(task.get("id") or "").strip() or "task"
944
+ if _has_task_attempt_resolution(
945
+ paths=paths,
946
+ task_id=task_id,
947
+ resolved_attempt_id=resolved_attempt_id,
948
+ acceptance_state=acceptance_state,
949
+ ):
950
+ existing_entries = [
951
+ entry
952
+ for entry in load_task_attempt_entries_for_task(paths=paths, task_id=task_id)
953
+ if entry.source == source
954
+ and entry.status == acceptance_state
955
+ and resolved_attempt_id in entry.resolves
956
+ ]
957
+ if existing_entries:
958
+ return max(
959
+ existing_entries,
960
+ key=lambda entry: (
961
+ entry.created_at,
962
+ entry.id,
963
+ entry.file_path.as_posix() if entry.file_path is not None else "",
964
+ ),
965
+ )
966
+ return write_task_attempt_entry(
967
+ paths=paths,
968
+ task=task,
969
+ source=source,
970
+ result=None,
971
+ summary=summary,
972
+ changed_files=changed_files,
973
+ verify_summary=verify_summary,
974
+ report_path=report_path,
975
+ patch_path=patch_path,
976
+ verify_artifact_path=verify_artifact_path,
977
+ budget_artifact_path=budget_artifact_path,
978
+ session_artifact_dir=session_artifact_dir,
979
+ acceptance_state=acceptance_state,
980
+ resolves=[resolved_attempt_id],
981
+ created_at=created_at,
982
+ extra_tags=[*extra_tags, "acceptance_resolution"],
983
+ )
984
+
985
+
986
+ def write_issue_entry(
987
+ *,
988
+ paths: RunPaths,
989
+ task: dict[str, Any],
990
+ source: str,
991
+ title: str,
992
+ summary: str,
993
+ paths_in_scope: list[str] | tuple[str, ...] = (),
994
+ report_path: Path | None = None,
995
+ patch_path: Path | None = None,
996
+ verify_artifact_path: Path | None = None,
997
+ budget_artifact_path: Path | None = None,
998
+ session_artifact_dir: Path | None = None,
999
+ related_tasks: list[str] | tuple[str, ...] | None = None,
1000
+ tags: list[str] | tuple[str, ...] = (),
1001
+ created_at: str | None = None,
1002
+ status: str = "open",
1003
+ signature: str | None = None,
1004
+ resolves: list[str] | tuple[str, ...] | None = None,
1005
+ ) -> KnowledgeEntry:
1006
+ task_id = str(task.get("id") or "").strip() or "task"
1007
+ related = related_tasks or [
1008
+ str(dep).strip() for dep in task.get("dependencies") or [] if str(dep).strip()
1009
+ ]
1010
+ return write_issue_entry_for_task_id(
1011
+ paths=paths,
1012
+ task_id=task_id,
1013
+ source=source,
1014
+ title=title,
1015
+ summary=summary,
1016
+ paths_in_scope=paths_in_scope,
1017
+ report_path=report_path,
1018
+ patch_path=patch_path,
1019
+ verify_artifact_path=verify_artifact_path,
1020
+ budget_artifact_path=budget_artifact_path,
1021
+ session_artifact_dir=session_artifact_dir,
1022
+ related_tasks=related,
1023
+ tags=tags,
1024
+ created_at=created_at,
1025
+ status=status,
1026
+ signature=signature,
1027
+ resolves=resolves,
1028
+ )
1029
+
1030
+
1031
+ def write_issue_entry_for_task_id(
1032
+ *,
1033
+ paths: RunPaths,
1034
+ task_id: str,
1035
+ source: str,
1036
+ title: str,
1037
+ summary: str,
1038
+ paths_in_scope: list[str] | tuple[str, ...] = (),
1039
+ report_path: Path | None = None,
1040
+ patch_path: Path | None = None,
1041
+ verify_artifact_path: Path | None = None,
1042
+ budget_artifact_path: Path | None = None,
1043
+ session_artifact_dir: Path | None = None,
1044
+ related_tasks: list[str] | tuple[str, ...] | None = None,
1045
+ tags: list[str] | tuple[str, ...] = (),
1046
+ created_at: str | None = None,
1047
+ status: str = "open",
1048
+ signature: str | None = None,
1049
+ resolves: list[str] | tuple[str, ...] | None = None,
1050
+ ) -> KnowledgeEntry:
1051
+ created = created_at or _now_iso()
1052
+ entry_id, filename_stem = _unique_entry_identity(
1053
+ kind="issue",
1054
+ task_id=task_id,
1055
+ source=source,
1056
+ created_at=created,
1057
+ )
1058
+ normalized_paths = tuple(
1059
+ dict.fromkeys(
1060
+ _normalize_path_value(path)
1061
+ for path in paths_in_scope
1062
+ if _normalize_path_value(str(path))
1063
+ )
1064
+ )
1065
+ related = tuple(
1066
+ dict.fromkeys(str(item).strip() for item in (related_tasks or []) if str(item).strip())
1067
+ )
1068
+ normalized_signature = _normalize_optional_signature(signature)
1069
+ resolve_ids = _normalize_resolve_ids(resolves)
1070
+ body_lines = [
1071
+ f"# Issue: {title}",
1072
+ "",
1073
+ f"- Status: `{status}`",
1074
+ f"- Source: `{source}`",
1075
+ f"- Signature: `{normalized_signature}`" if normalized_signature else "- Signature: (none)",
1076
+ f"- Summary: {summary or '(none)'}",
1077
+ "",
1078
+ "## Related Paths",
1079
+ "",
1080
+ ]
1081
+ if normalized_paths:
1082
+ body_lines.extend(f"- `{path}`" for path in normalized_paths)
1083
+ else:
1084
+ body_lines.append("- (none)")
1085
+ body_lines.extend(["", "## Lifecycle", ""])
1086
+ if resolve_ids:
1087
+ body_lines.extend(f"- Resolves: `{item}`" for item in resolve_ids)
1088
+ else:
1089
+ body_lines.append("- Resolves: (none)")
1090
+ body_lines.extend(
1091
+ [
1092
+ "",
1093
+ "## Related Artifacts",
1094
+ "",
1095
+ f"- Report: `{_repo_rel(paths.root, report_path)}`"
1096
+ if report_path
1097
+ else "- Report: (none)",
1098
+ f"- Patch: `{_repo_rel(paths.root, patch_path)}`" if patch_path else "- Patch: (none)",
1099
+ (
1100
+ f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
1101
+ if verify_artifact_path
1102
+ else "- Verify Artifact: (none)"
1103
+ ),
1104
+ (
1105
+ f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
1106
+ if budget_artifact_path
1107
+ else "- Budget Artifact: (none)"
1108
+ ),
1109
+ (
1110
+ f"- Session Artifacts: `{_repo_rel(paths.root, session_artifact_dir)}`"
1111
+ if session_artifact_dir
1112
+ else "- Session Artifacts: (none retained)"
1113
+ ),
1114
+ ]
1115
+ )
1116
+ entry = KnowledgeEntry(
1117
+ kind="issue",
1118
+ id=entry_id,
1119
+ title=title,
1120
+ created_at=created,
1121
+ task_id=task_id,
1122
+ source=source,
1123
+ status=status,
1124
+ paths=normalized_paths,
1125
+ related_tasks=related,
1126
+ tags=tuple(
1127
+ dict.fromkeys([source, status, *[str(tag).strip() for tag in tags if str(tag).strip()]])
1128
+ ),
1129
+ report_path=_repo_rel(paths.root, report_path) if report_path else None,
1130
+ patch_path=_repo_rel(paths.root, patch_path) if patch_path else None,
1131
+ verify_artifact_path=(
1132
+ _repo_rel(paths.root, verify_artifact_path) if verify_artifact_path else None
1133
+ ),
1134
+ budget_artifact_path=(
1135
+ _repo_rel(paths.root, budget_artifact_path) if budget_artifact_path else None
1136
+ ),
1137
+ session_artifact_dir=(
1138
+ _repo_rel(paths.root, session_artifact_dir) if session_artifact_dir else None
1139
+ ),
1140
+ signature=normalized_signature,
1141
+ resolves=resolve_ids,
1142
+ body="\n".join(body_lines).rstrip() + "\n",
1143
+ )
1144
+ directory = _knowledge_bucket_dir(paths, kind="issue", task_id=task_id)
1145
+ file_path = _publish_markdown_entry_atomic(
1146
+ directory=directory,
1147
+ stem=filename_stem,
1148
+ markdown_text=entry.to_markdown(),
1149
+ )
1150
+ return KnowledgeEntry.from_markdown(
1151
+ text=file_path.read_text(encoding="utf-8"), file_path=file_path
1152
+ )
1153
+
1154
+
1155
+ def write_fact_entry(
1156
+ *,
1157
+ paths: RunPaths,
1158
+ task: dict[str, Any],
1159
+ source: str,
1160
+ title: str,
1161
+ summary: str,
1162
+ paths_in_scope: list[str] | tuple[str, ...] = (),
1163
+ report_path: Path | None = None,
1164
+ patch_path: Path | None = None,
1165
+ verify_artifact_path: Path | None = None,
1166
+ budget_artifact_path: Path | None = None,
1167
+ session_artifact_dir: Path | None = None,
1168
+ capture_artifact_path: Path | None = None,
1169
+ related_tasks: list[str] | tuple[str, ...] | None = None,
1170
+ tags: list[str] | tuple[str, ...] = (),
1171
+ created_at: str | None = None,
1172
+ ) -> KnowledgeEntry:
1173
+ created = created_at or _now_iso()
1174
+ task_id = str(task.get("id") or "").strip() or "task"
1175
+ entry_id, filename_stem = _unique_entry_identity(
1176
+ kind="fact",
1177
+ task_id=task_id,
1178
+ source=source,
1179
+ created_at=created,
1180
+ )
1181
+ normalized_paths = tuple(
1182
+ dict.fromkeys(
1183
+ _normalize_path_value(path)
1184
+ for path in paths_in_scope
1185
+ if _normalize_path_value(str(path))
1186
+ )
1187
+ )
1188
+ related = tuple(
1189
+ dict.fromkeys(
1190
+ str(item).strip()
1191
+ for item in (
1192
+ related_tasks
1193
+ or [str(dep).strip() for dep in task.get("dependencies") or [] if str(dep).strip()]
1194
+ )
1195
+ if str(item).strip()
1196
+ )
1197
+ )
1198
+ capture_rel = _repo_rel(paths.root, capture_artifact_path) if capture_artifact_path else None
1199
+ body_lines = [
1200
+ f"# Fact: {title}",
1201
+ "",
1202
+ "- Status: `recorded`",
1203
+ f"- Source: `{source}`",
1204
+ f"- Summary: {summary or '(none)'}",
1205
+ "",
1206
+ "## Related Paths",
1207
+ "",
1208
+ ]
1209
+ if normalized_paths:
1210
+ body_lines.extend(f"- `{path}`" for path in normalized_paths)
1211
+ else:
1212
+ body_lines.append("- (none)")
1213
+ body_lines.extend(
1214
+ [
1215
+ "",
1216
+ "## Related Artifacts",
1217
+ "",
1218
+ f"- Report: `{_repo_rel(paths.root, report_path)}`"
1219
+ if report_path
1220
+ else "- Report: (none)",
1221
+ f"- Patch: `{_repo_rel(paths.root, patch_path)}`" if patch_path else "- Patch: (none)",
1222
+ (
1223
+ f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
1224
+ if verify_artifact_path
1225
+ else "- Verify Artifact: (none)"
1226
+ ),
1227
+ (
1228
+ f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
1229
+ if budget_artifact_path
1230
+ else "- Budget Artifact: (none)"
1231
+ ),
1232
+ (
1233
+ f"- Session Artifacts: `{_repo_rel(paths.root, session_artifact_dir)}`"
1234
+ if session_artifact_dir
1235
+ else "- Session Artifacts: (none retained)"
1236
+ ),
1237
+ f"- Capture Artifact: `{capture_rel}`" if capture_rel else "- Capture Artifact: (none)",
1238
+ ]
1239
+ )
1240
+ entry = KnowledgeEntry(
1241
+ kind="fact",
1242
+ id=entry_id,
1243
+ title=title,
1244
+ created_at=created,
1245
+ task_id=task_id,
1246
+ source=source,
1247
+ status="recorded",
1248
+ paths=normalized_paths,
1249
+ related_tasks=related,
1250
+ tags=tuple(
1251
+ dict.fromkeys(["fact", source, *[str(tag).strip() for tag in tags if str(tag).strip()]])
1252
+ ),
1253
+ report_path=_repo_rel(paths.root, report_path) if report_path else None,
1254
+ patch_path=_repo_rel(paths.root, patch_path) if patch_path else None,
1255
+ verify_artifact_path=(
1256
+ _repo_rel(paths.root, verify_artifact_path) if verify_artifact_path else None
1257
+ ),
1258
+ budget_artifact_path=(
1259
+ _repo_rel(paths.root, budget_artifact_path) if budget_artifact_path else None
1260
+ ),
1261
+ session_artifact_dir=(
1262
+ _repo_rel(paths.root, session_artifact_dir) if session_artifact_dir else None
1263
+ ),
1264
+ capture_artifact_path=capture_rel,
1265
+ body="\n".join(body_lines).rstrip() + "\n",
1266
+ )
1267
+ directory = _knowledge_bucket_dir(paths, kind="fact", task_id=task_id)
1268
+ file_path = _publish_markdown_entry_atomic(
1269
+ directory=directory,
1270
+ stem=filename_stem,
1271
+ markdown_text=entry.to_markdown(),
1272
+ )
1273
+ return KnowledgeEntry.from_markdown(
1274
+ text=file_path.read_text(encoding="utf-8"), file_path=file_path
1275
+ )
1276
+
1277
+
1278
+ def write_decision_entry(
1279
+ *,
1280
+ paths: RunPaths,
1281
+ task: dict[str, Any],
1282
+ source: str,
1283
+ decision_key: str,
1284
+ title: str,
1285
+ summary: str,
1286
+ status: str,
1287
+ paths_in_scope: list[str] | tuple[str, ...] = (),
1288
+ report_path: Path | None = None,
1289
+ patch_path: Path | None = None,
1290
+ verify_artifact_path: Path | None = None,
1291
+ budget_artifact_path: Path | None = None,
1292
+ session_artifact_dir: Path | None = None,
1293
+ capture_artifact_path: Path | None = None,
1294
+ related_tasks: list[str] | tuple[str, ...] | None = None,
1295
+ tags: list[str] | tuple[str, ...] = (),
1296
+ created_at: str | None = None,
1297
+ ) -> KnowledgeEntry:
1298
+ created = created_at or _now_iso()
1299
+ task_id = str(task.get("id") or "").strip() or "task"
1300
+ entry_id, filename_stem = _unique_entry_identity(
1301
+ kind="decision",
1302
+ task_id=task_id,
1303
+ source=source,
1304
+ created_at=created,
1305
+ )
1306
+ normalized_paths = tuple(
1307
+ dict.fromkeys(
1308
+ _normalize_path_value(path)
1309
+ for path in paths_in_scope
1310
+ if _normalize_path_value(str(path))
1311
+ )
1312
+ )
1313
+ related = tuple(
1314
+ dict.fromkeys(
1315
+ str(item).strip()
1316
+ for item in (
1317
+ related_tasks
1318
+ or [str(dep).strip() for dep in task.get("dependencies") or [] if str(dep).strip()]
1319
+ )
1320
+ if str(item).strip()
1321
+ )
1322
+ )
1323
+ capture_rel = _repo_rel(paths.root, capture_artifact_path) if capture_artifact_path else None
1324
+ body_lines = [
1325
+ f"# Decision: {title}",
1326
+ "",
1327
+ f"- Decision Key: `{decision_key}`",
1328
+ f"- Status: `{status}`",
1329
+ f"- Source: `{source}`",
1330
+ f"- Summary: {summary or '(none)'}",
1331
+ "",
1332
+ "## Related Paths",
1333
+ "",
1334
+ ]
1335
+ if normalized_paths:
1336
+ body_lines.extend(f"- `{path}`" for path in normalized_paths)
1337
+ else:
1338
+ body_lines.append("- (none)")
1339
+ body_lines.extend(
1340
+ [
1341
+ "",
1342
+ "## Related Artifacts",
1343
+ "",
1344
+ f"- Report: `{_repo_rel(paths.root, report_path)}`"
1345
+ if report_path
1346
+ else "- Report: (none)",
1347
+ f"- Patch: `{_repo_rel(paths.root, patch_path)}`" if patch_path else "- Patch: (none)",
1348
+ (
1349
+ f"- Verify Artifact: `{_repo_rel(paths.root, verify_artifact_path)}`"
1350
+ if verify_artifact_path
1351
+ else "- Verify Artifact: (none)"
1352
+ ),
1353
+ (
1354
+ f"- Budget Artifact: `{_repo_rel(paths.root, budget_artifact_path)}`"
1355
+ if budget_artifact_path
1356
+ else "- Budget Artifact: (none)"
1357
+ ),
1358
+ (
1359
+ f"- Session Artifacts: `{_repo_rel(paths.root, session_artifact_dir)}`"
1360
+ if session_artifact_dir
1361
+ else "- Session Artifacts: (none retained)"
1362
+ ),
1363
+ f"- Capture Artifact: `{capture_rel}`" if capture_rel else "- Capture Artifact: (none)",
1364
+ ]
1365
+ )
1366
+ entry = KnowledgeEntry(
1367
+ kind="decision",
1368
+ id=entry_id,
1369
+ title=title,
1370
+ created_at=created,
1371
+ task_id=task_id,
1372
+ source=source,
1373
+ status=status,
1374
+ paths=normalized_paths,
1375
+ related_tasks=related,
1376
+ tags=tuple(
1377
+ dict.fromkeys(
1378
+ [
1379
+ "decision",
1380
+ source,
1381
+ status,
1382
+ *[str(tag).strip() for tag in tags if str(tag).strip()],
1383
+ ]
1384
+ )
1385
+ ),
1386
+ report_path=_repo_rel(paths.root, report_path) if report_path else None,
1387
+ patch_path=_repo_rel(paths.root, patch_path) if patch_path else None,
1388
+ verify_artifact_path=(
1389
+ _repo_rel(paths.root, verify_artifact_path) if verify_artifact_path else None
1390
+ ),
1391
+ budget_artifact_path=(
1392
+ _repo_rel(paths.root, budget_artifact_path) if budget_artifact_path else None
1393
+ ),
1394
+ session_artifact_dir=(
1395
+ _repo_rel(paths.root, session_artifact_dir) if session_artifact_dir else None
1396
+ ),
1397
+ decision_key=decision_key,
1398
+ capture_artifact_path=capture_rel,
1399
+ body="\n".join(body_lines).rstrip() + "\n",
1400
+ )
1401
+ directory = _knowledge_bucket_dir(paths, kind="decision", task_id=task_id)
1402
+ file_path = _publish_markdown_entry_atomic(
1403
+ directory=directory,
1404
+ stem=filename_stem,
1405
+ markdown_text=entry.to_markdown(),
1406
+ )
1407
+ return KnowledgeEntry.from_markdown(
1408
+ text=file_path.read_text(encoding="utf-8"), file_path=file_path
1409
+ )