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,1190 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import warnings
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .forge import RunPaths
11
+ from .knowledge_base import write_decision_entry, write_fact_entry
12
+ from .surface.base import Surface
13
+ from .surface.noop_surface import NoopSurface
14
+
15
+ KNOWLEDGE_CAPTURE_SCHEMA_VERSION = 1
16
+ KNOWLEDGE_CAPTURE_FENCE = "knowledge_capture_json"
17
+ _CAPTURE_BLOCK_RE = re.compile(
18
+ rf"```{KNOWLEDGE_CAPTURE_FENCE}\s*(\{{.*?\}})\s*```",
19
+ re.DOTALL,
20
+ )
21
+ _ALLOWED_TOP_LEVEL_KEYS = frozenset({"schema_version", "facts", "decisions", "open_questions"})
22
+ _ALLOWED_FACT_KEYS = frozenset({"title", "summary", "paths", "tags"})
23
+ _ALLOWED_DECISION_KEYS = frozenset({"decision_key", "title", "summary", "status", "paths", "tags"})
24
+ _DECISION_STATUSES = frozenset({"active", "invalidated"})
25
+ _MAX_FACTS = 5
26
+ _MAX_DECISIONS = 4
27
+ _MAX_OPEN_QUESTIONS = 4
28
+ _MAX_TITLE_CHARS = 120
29
+ _MAX_SUMMARY_CHARS = 400
30
+ _MAX_PATHS = 8
31
+ _MAX_TAGS = 6
32
+ _MAX_TAG_CHARS = 32
33
+ _MAX_OPEN_QUESTION_CHARS = 160
34
+ _MAX_DECISION_KEY_CHARS = 64
35
+ _TAG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
36
+ _DECISION_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
37
+
38
+
39
+ def _safe_component(value: str) -> str:
40
+ safe = "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in str(value).strip())
41
+ return safe or "item"
42
+
43
+
44
+ def _repo_rel(root: Path, path: Path) -> str:
45
+ return path.resolve().relative_to(root.resolve()).as_posix()
46
+
47
+
48
+ def _normalize_repo_rel_path(value: str) -> str | None:
49
+ cleaned = str(value).strip().replace("\\", "/")
50
+ while cleaned.startswith("./"):
51
+ cleaned = cleaned[2:]
52
+ cleaned = cleaned.strip().rstrip("/")
53
+ if not cleaned:
54
+ return None
55
+ if cleaned.startswith("/"):
56
+ return None
57
+ if cleaned == ".." or cleaned.startswith("../") or "/../" in cleaned:
58
+ return None
59
+ if cleaned == ".alysis" or cleaned.startswith(".alysis/"):
60
+ return None
61
+ return cleaned
62
+
63
+
64
+ def _bounded_text(value: Any, *, field: str, max_chars: int) -> str:
65
+ text = str(value or "").strip()
66
+ if not text:
67
+ raise ValueError(f"{field} must be non-empty")
68
+ if len(text) > max_chars:
69
+ raise ValueError(f"{field} exceeds {max_chars} chars")
70
+ return text
71
+
72
+
73
+ def _normalize_tags(value: Any) -> tuple[str, ...]:
74
+ if value is None:
75
+ return ()
76
+ if not isinstance(value, list):
77
+ raise ValueError("tags must be an array")
78
+ tags: list[str] = []
79
+ for raw in value[:_MAX_TAGS]:
80
+ tag = str(raw or "").strip().lower()
81
+ if not tag:
82
+ continue
83
+ if len(tag) > _MAX_TAG_CHARS:
84
+ raise ValueError(f"tag exceeds {_MAX_TAG_CHARS} chars: {tag}")
85
+ if not _TAG_RE.match(tag):
86
+ raise ValueError(f"invalid tag: {tag}")
87
+ if tag not in tags:
88
+ tags.append(tag)
89
+ return tuple(tags)
90
+
91
+
92
+ def _normalize_paths(value: Any) -> tuple[str, ...]:
93
+ if value is None:
94
+ return ()
95
+ if not isinstance(value, list):
96
+ raise ValueError("paths must be an array")
97
+ paths: list[str] = []
98
+ for raw in value[:_MAX_PATHS]:
99
+ normalized = _normalize_repo_rel_path(str(raw or ""))
100
+ if normalized is None:
101
+ raise ValueError(f"invalid repo-relative path: {raw}")
102
+ if normalized not in paths:
103
+ paths.append(normalized)
104
+ return tuple(paths)
105
+
106
+
107
+ def _normalize_open_questions(value: Any) -> tuple[str, ...]:
108
+ if value is None:
109
+ return ()
110
+ if not isinstance(value, list):
111
+ raise ValueError("open_questions must be an array")
112
+ out: list[str] = []
113
+ for raw in value[:_MAX_OPEN_QUESTIONS]:
114
+ text = str(raw or "").strip()
115
+ if not text:
116
+ continue
117
+ if len(text) > _MAX_OPEN_QUESTION_CHARS:
118
+ raise ValueError(f"open question exceeds {_MAX_OPEN_QUESTION_CHARS} chars: {text[:40]}")
119
+ out.append(text)
120
+ return tuple(out)
121
+
122
+
123
+ def _emit_surface_warning(
124
+ surface: object,
125
+ message: str,
126
+ *,
127
+ worker_id: str | None = None,
128
+ role: str | None = None,
129
+ ) -> bool:
130
+ surface_cls = getattr(surface, "__class__", None)
131
+ handler = getattr(surface, "emit_warning", None)
132
+ if callable(handler):
133
+ cls_handler = getattr(surface_cls, "emit_warning", None)
134
+ if cls_handler is not getattr(NoopSurface, "emit_warning", None):
135
+ handler(message, worker_id=worker_id, role=role)
136
+ return True
137
+ handler = getattr(surface, "on_warning", None)
138
+ if not callable(handler):
139
+ return False
140
+ cls_handler = getattr(surface_cls, "on_warning", None)
141
+ if cls_handler is getattr(NoopSurface, "on_warning", None):
142
+ return False
143
+ handler(message)
144
+ return True
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class CapturedFact:
149
+ title: str
150
+ summary: str
151
+ paths: tuple[str, ...]
152
+ tags: tuple[str, ...]
153
+
154
+ def to_payload(self) -> dict[str, Any]:
155
+ return {
156
+ "title": self.title,
157
+ "summary": self.summary,
158
+ "paths": list(self.paths),
159
+ "tags": list(self.tags),
160
+ }
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class CapturedDecision:
165
+ decision_key: str
166
+ title: str
167
+ summary: str
168
+ status: str
169
+ paths: tuple[str, ...]
170
+ tags: tuple[str, ...]
171
+
172
+ def to_payload(self) -> dict[str, Any]:
173
+ return {
174
+ "decision_key": self.decision_key,
175
+ "title": self.title,
176
+ "summary": self.summary,
177
+ "status": self.status,
178
+ "paths": list(self.paths),
179
+ "tags": list(self.tags),
180
+ }
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class KnowledgeCapturePayload:
185
+ schema_version: int
186
+ facts: tuple[CapturedFact, ...]
187
+ decisions: tuple[CapturedDecision, ...]
188
+ open_questions: tuple[str, ...]
189
+
190
+ def to_payload(self) -> dict[str, Any]:
191
+ return {
192
+ "schema_version": self.schema_version,
193
+ "facts": [item.to_payload() for item in self.facts],
194
+ "decisions": [item.to_payload() for item in self.decisions],
195
+ "open_questions": list(self.open_questions),
196
+ }
197
+
198
+
199
+ @dataclass(frozen=True)
200
+ class KnowledgeCaptureValidation:
201
+ valid: bool
202
+ errors: tuple[str, ...]
203
+ warnings: tuple[str, ...]
204
+ payload: KnowledgeCapturePayload | None = None
205
+
206
+ def to_payload(self) -> dict[str, Any]:
207
+ return {
208
+ "valid": self.valid,
209
+ "errors": list(self.errors),
210
+ "warnings": list(self.warnings),
211
+ "parsed_capture": self.payload.to_payload() if self.payload is not None else None,
212
+ }
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class PersistedKnowledgeCapture:
217
+ artifact_dir: Path
218
+ assistant_message_path: Path
219
+ capture_block_path: Path | None
220
+ parsed_capture_path: Path | None
221
+ validation_path: Path
222
+ promotion_path: Path
223
+ summary_path: Path
224
+ valid: bool
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class KnowledgeCapturePromotionResult:
229
+ artifact_dir: Path
230
+ promotion_path: Path
231
+ capture_valid: bool
232
+ promotion_attempted: bool
233
+ promotion_succeeded: bool
234
+ promotion_skipped_reason: str | None
235
+ promotion_errors: tuple[str, ...]
236
+ fact_entry_ids: tuple[str, ...]
237
+ decision_entry_ids: tuple[str, ...]
238
+
239
+ def to_payload(self) -> dict[str, Any]:
240
+ return {
241
+ "capture_valid": self.capture_valid,
242
+ "promotion_attempted": self.promotion_attempted,
243
+ "promotion_succeeded": self.promotion_succeeded,
244
+ "promotion_skipped_reason": self.promotion_skipped_reason,
245
+ "promotion_errors": list(self.promotion_errors),
246
+ "fact_entry_ids": list(self.fact_entry_ids),
247
+ "decision_entry_ids": list(self.decision_entry_ids),
248
+ }
249
+
250
+
251
+ def _load_json_payload(path: Path) -> dict[str, Any]:
252
+ raw = json.loads(path.read_text(encoding="utf-8"))
253
+ if not isinstance(raw, dict):
254
+ raise ValueError(f"expected JSON object in {path}")
255
+ return raw
256
+
257
+
258
+ def _promotion_result_from_payload(
259
+ *,
260
+ artifact_dir: Path,
261
+ promotion_path: Path,
262
+ payload: dict[str, Any],
263
+ ) -> KnowledgeCapturePromotionResult:
264
+ return KnowledgeCapturePromotionResult(
265
+ artifact_dir=artifact_dir,
266
+ promotion_path=promotion_path,
267
+ capture_valid=bool(payload.get("capture_valid")),
268
+ promotion_attempted=bool(payload.get("promotion_attempted")),
269
+ promotion_succeeded=bool(payload.get("promotion_succeeded")),
270
+ promotion_skipped_reason=str(payload.get("promotion_skipped_reason") or "").strip() or None,
271
+ promotion_errors=tuple(
272
+ str(item).strip() for item in payload.get("promotion_errors") or [] if str(item).strip()
273
+ ),
274
+ fact_entry_ids=tuple(
275
+ str(item).strip() for item in payload.get("fact_entry_ids") or [] if str(item).strip()
276
+ ),
277
+ decision_entry_ids=tuple(
278
+ str(item).strip()
279
+ for item in payload.get("decision_entry_ids") or []
280
+ if str(item).strip()
281
+ ),
282
+ )
283
+
284
+
285
+ def _write_promotion_result(
286
+ *,
287
+ artifact_dir: Path,
288
+ promotion_path: Path,
289
+ capture_valid: bool,
290
+ promotion_attempted: bool,
291
+ promotion_succeeded: bool,
292
+ promotion_skipped_reason: str | None,
293
+ promotion_errors: list[str] | tuple[str, ...],
294
+ fact_entry_ids: list[str] | tuple[str, ...],
295
+ decision_entry_ids: list[str] | tuple[str, ...],
296
+ ) -> KnowledgeCapturePromotionResult:
297
+ result = KnowledgeCapturePromotionResult(
298
+ artifact_dir=artifact_dir,
299
+ promotion_path=promotion_path,
300
+ capture_valid=capture_valid,
301
+ promotion_attempted=promotion_attempted,
302
+ promotion_succeeded=promotion_succeeded,
303
+ promotion_skipped_reason=str(promotion_skipped_reason or "").strip() or None,
304
+ promotion_errors=tuple(str(item).strip() for item in promotion_errors if str(item).strip()),
305
+ fact_entry_ids=tuple(str(item).strip() for item in fact_entry_ids if str(item).strip()),
306
+ decision_entry_ids=tuple(
307
+ str(item).strip() for item in decision_entry_ids if str(item).strip()
308
+ ),
309
+ )
310
+ _write_json(promotion_path, result.to_payload())
311
+ return result
312
+
313
+
314
+ def _load_promotion_result(artifact_dir: Path) -> KnowledgeCapturePromotionResult | None:
315
+ promotion_path = artifact_dir / "promotion.json"
316
+ if not promotion_path.exists():
317
+ return None
318
+ try:
319
+ payload = _load_json_payload(promotion_path)
320
+ except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
321
+ return None
322
+ return _promotion_result_from_payload(
323
+ artifact_dir=artifact_dir,
324
+ promotion_path=promotion_path,
325
+ payload=payload,
326
+ )
327
+
328
+
329
+ class RecordingSurface:
330
+ def __init__(self, delegate: Surface) -> None:
331
+ self._delegate = delegate
332
+ self.final_assistant_message = ""
333
+
334
+ @property
335
+ def canonical_message_tool_events(self) -> bool:
336
+ """Preserve the delegate's single-delivery event contract."""
337
+ return bool(getattr(self._delegate, "canonical_message_tool_events", False))
338
+
339
+ def on_status_update(self, status) -> None: # type: ignore[no-untyped-def]
340
+ self._delegate.on_status_update(status)
341
+
342
+ def emit_status_update(
343
+ self,
344
+ *,
345
+ tokens_in: int | None = None,
346
+ tokens_out: int | None = None,
347
+ cached_tokens: int | None = None,
348
+ cost_usd: float | None = None,
349
+ mode: str | None = None,
350
+ model: str | None = None,
351
+ step: int | None = None,
352
+ step_budget: int | None = None,
353
+ ) -> None:
354
+ handler = getattr(self._delegate, "emit_status_update", None)
355
+ if callable(handler):
356
+ handler(
357
+ tokens_in=tokens_in,
358
+ tokens_out=tokens_out,
359
+ cached_tokens=cached_tokens,
360
+ cost_usd=cost_usd,
361
+ mode=mode,
362
+ model=model,
363
+ step=step,
364
+ step_budget=step_budget,
365
+ )
366
+
367
+ def on_user_message(self, text: str) -> None:
368
+ self._delegate.on_user_message(text)
369
+
370
+ def on_progress_update(self, message: str) -> None:
371
+ self._delegate.on_progress_update(message)
372
+
373
+ def on_assistant_token(self, delta: str) -> None:
374
+ self._delegate.on_assistant_token(delta)
375
+
376
+ def on_assistant_message_done(self, text: str) -> None:
377
+ self.final_assistant_message = str(text or "")
378
+ self._delegate.on_assistant_message_done(text)
379
+
380
+ def emit_message_delta(
381
+ self,
382
+ text: str,
383
+ *,
384
+ worker_id: str | None = None,
385
+ role: str | None = None,
386
+ ) -> None:
387
+ handler = getattr(self._delegate, "emit_message_delta", None)
388
+ if callable(handler):
389
+ handler(text, worker_id=worker_id, role=role)
390
+
391
+ def emit_message_end(
392
+ self,
393
+ text: str = "",
394
+ *,
395
+ worker_id: str | None = None,
396
+ role: str | None = None,
397
+ ) -> None:
398
+ self.final_assistant_message = str(text or "")
399
+ handler = getattr(self._delegate, "emit_message_end", None)
400
+ if callable(handler):
401
+ handler(text, worker_id=worker_id, role=role)
402
+
403
+ def on_subagent_start(self, event) -> None: # type: ignore[no-untyped-def]
404
+ handler = getattr(self._delegate, "on_subagent_start", None)
405
+ if callable(handler):
406
+ handler(event)
407
+
408
+ def on_subagent_end(self, event) -> None: # type: ignore[no-untyped-def]
409
+ handler = getattr(self._delegate, "on_subagent_end", None)
410
+ if callable(handler):
411
+ handler(event)
412
+
413
+ def on_tool_start(self, event) -> None: # type: ignore[no-untyped-def]
414
+ self._delegate.on_tool_start(event)
415
+
416
+ def on_tool_output(self, event) -> None: # type: ignore[no-untyped-def]
417
+ self._delegate.on_tool_output(event)
418
+
419
+ def on_tool_end(self, event) -> None: # type: ignore[no-untyped-def]
420
+ self._delegate.on_tool_end(event)
421
+
422
+ def emit_tool_call_started(
423
+ self,
424
+ call_id: str,
425
+ name: str,
426
+ arguments_preview: str,
427
+ *,
428
+ worker_id: str | None = None,
429
+ role: str | None = None,
430
+ ) -> None:
431
+ handler = getattr(self._delegate, "emit_tool_call_started", None)
432
+ if callable(handler):
433
+ handler(
434
+ call_id,
435
+ name,
436
+ arguments_preview,
437
+ worker_id=worker_id,
438
+ role=role,
439
+ )
440
+
441
+ def emit_tool_call_progress(
442
+ self,
443
+ call_id: str,
444
+ text: str,
445
+ *,
446
+ worker_id: str | None = None,
447
+ role: str | None = None,
448
+ ) -> None:
449
+ handler = getattr(self._delegate, "emit_tool_call_progress", None)
450
+ if callable(handler):
451
+ handler(call_id, text, worker_id=worker_id, role=role)
452
+
453
+ def emit_tool_call_completed(
454
+ self,
455
+ call_id: str,
456
+ success: bool,
457
+ result_preview: str,
458
+ *,
459
+ worker_id: str | None = None,
460
+ role: str | None = None,
461
+ ) -> None:
462
+ handler = getattr(self._delegate, "emit_tool_call_completed", None)
463
+ if callable(handler):
464
+ handler(
465
+ call_id,
466
+ success,
467
+ result_preview,
468
+ worker_id=worker_id,
469
+ role=role,
470
+ )
471
+
472
+ def on_patch_generated(self, event) -> None: # type: ignore[no-untyped-def]
473
+ self._delegate.on_patch_generated(event)
474
+
475
+ def on_warning(self, warning: str) -> None:
476
+ self.emit_warning(warning)
477
+
478
+ def emit_warning(
479
+ self,
480
+ message: str,
481
+ *,
482
+ worker_id: str | None = None,
483
+ role: str | None = None,
484
+ ) -> None:
485
+ if _emit_surface_warning(self._delegate, message, worker_id=worker_id, role=role):
486
+ return
487
+ warnings.warn(message, stacklevel=2)
488
+
489
+ def on_error(self, err: str) -> None:
490
+ self.emit_error("execution_error", err, True)
491
+
492
+ def emit_error(
493
+ self,
494
+ code: str,
495
+ message: str,
496
+ recoverable: bool,
497
+ *,
498
+ worker_id: str | None = None,
499
+ role: str | None = None,
500
+ ) -> None:
501
+ delegate_cls = getattr(self._delegate, "__class__", None)
502
+ handler = getattr(self._delegate, "emit_error", None)
503
+ if callable(handler):
504
+ cls_handler = getattr(delegate_cls, "emit_error", None)
505
+ if cls_handler is not getattr(NoopSurface, "emit_error", None):
506
+ handler(code, message, recoverable, worker_id=worker_id, role=role)
507
+ return
508
+ fallback = getattr(self._delegate, "on_error", None)
509
+ if callable(fallback):
510
+ fallback(message)
511
+
512
+ def emit_info(
513
+ self,
514
+ message: str,
515
+ *,
516
+ worker_id: str | None = None,
517
+ role: str | None = None,
518
+ ) -> None:
519
+ delegate_cls = getattr(self._delegate, "__class__", None)
520
+ handler = getattr(self._delegate, "emit_info", None)
521
+ if callable(handler):
522
+ cls_handler = getattr(delegate_cls, "emit_info", None)
523
+ if cls_handler is not getattr(NoopSurface, "emit_info", None):
524
+ handler(message, worker_id=worker_id, role=role)
525
+ return
526
+ fallback = getattr(self._delegate, "on_progress_update", None)
527
+ if callable(fallback):
528
+ fallback(message)
529
+
530
+ def emit_mode_changed(self, mode: str) -> None:
531
+ handler = getattr(self._delegate, "emit_mode_changed", None)
532
+ if callable(handler):
533
+ handler(mode)
534
+
535
+ def emit_persona_changed(self, persona: str, effective_mode: str, source: str = "user") -> None:
536
+ handler = getattr(self._delegate, "emit_persona_changed", None)
537
+ if callable(handler):
538
+ handler(persona, effective_mode, source)
539
+
540
+ def request_approval(self, request): # type: ignore[no-untyped-def]
541
+ return self._delegate.request_approval(request)
542
+
543
+
544
+ def render_execution_knowledge_capture_rules() -> list[str]:
545
+ return [
546
+ "- Keep the normal human-readable final response.",
547
+ f"- If you learned reusable repo knowledge, append one fenced `{KNOWLEDGE_CAPTURE_FENCE}` block after the summary.",
548
+ "- Use JSON with `schema_version`, `facts`, `decisions`, and optional `open_questions`; omit the block if there is nothing durable to capture.",
549
+ "- Facts are immutable observations with `title`, `summary`, optional repo-relative `paths`, and optional `tags`.",
550
+ "- Decisions use `decision_key`, `title`, `summary`, `status` (`active` or `invalidated`), and optional repo-relative `paths`/`tags`.",
551
+ f"- Keep it bounded: at most {_MAX_FACTS} facts, {_MAX_DECISIONS} decisions, and short strings.",
552
+ ]
553
+
554
+
555
+ def extract_knowledge_capture_block(text: str) -> str | None:
556
+ matches = list(_CAPTURE_BLOCK_RE.finditer(str(text or "")))
557
+ if not matches:
558
+ return None
559
+ return matches[-1].group(1).strip()
560
+
561
+
562
+ def validate_knowledge_capture(*, final_text: str) -> KnowledgeCaptureValidation:
563
+ clean = str(final_text or "").strip()
564
+ if not clean:
565
+ return KnowledgeCaptureValidation(
566
+ valid=False,
567
+ errors=("final assistant message was not captured",),
568
+ warnings=(),
569
+ payload=None,
570
+ )
571
+
572
+ block = extract_knowledge_capture_block(clean)
573
+ if block is None:
574
+ return KnowledgeCaptureValidation(
575
+ valid=False,
576
+ errors=(f"missing fenced `{KNOWLEDGE_CAPTURE_FENCE}` block",),
577
+ warnings=(),
578
+ payload=None,
579
+ )
580
+
581
+ try:
582
+ raw = json.loads(block)
583
+ except json.JSONDecodeError as exc:
584
+ return KnowledgeCaptureValidation(
585
+ valid=False,
586
+ errors=(f"invalid structured knowledge JSON: {exc}",),
587
+ warnings=(),
588
+ payload=None,
589
+ )
590
+
591
+ if not isinstance(raw, dict):
592
+ return KnowledgeCaptureValidation(
593
+ valid=False,
594
+ errors=("structured knowledge capture must be a JSON object",),
595
+ warnings=(),
596
+ payload=None,
597
+ )
598
+
599
+ unknown_top_level = sorted(set(raw) - _ALLOWED_TOP_LEVEL_KEYS)
600
+ if unknown_top_level:
601
+ return KnowledgeCaptureValidation(
602
+ valid=False,
603
+ errors=(f"unknown structured knowledge keys: {', '.join(unknown_top_level)}",),
604
+ warnings=(),
605
+ payload=None,
606
+ )
607
+
608
+ schema_version = raw.get("schema_version")
609
+ if schema_version != KNOWLEDGE_CAPTURE_SCHEMA_VERSION:
610
+ return KnowledgeCaptureValidation(
611
+ valid=False,
612
+ errors=(f"schema_version must be {KNOWLEDGE_CAPTURE_SCHEMA_VERSION}",),
613
+ warnings=(),
614
+ payload=None,
615
+ )
616
+
617
+ facts_raw = raw.get("facts", [])
618
+ if not isinstance(facts_raw, list):
619
+ return KnowledgeCaptureValidation(
620
+ valid=False,
621
+ errors=("facts must be an array",),
622
+ warnings=(),
623
+ payload=None,
624
+ )
625
+ if len(facts_raw) > _MAX_FACTS:
626
+ return KnowledgeCaptureValidation(
627
+ valid=False,
628
+ errors=(f"facts exceeds max count {_MAX_FACTS}",),
629
+ warnings=(),
630
+ payload=None,
631
+ )
632
+
633
+ facts: list[CapturedFact] = []
634
+ for idx, item in enumerate(facts_raw, start=1):
635
+ if not isinstance(item, dict):
636
+ return KnowledgeCaptureValidation(
637
+ valid=False,
638
+ errors=(f"fact #{idx} must be an object",),
639
+ warnings=(),
640
+ payload=None,
641
+ )
642
+ unknown_keys = sorted(set(item) - _ALLOWED_FACT_KEYS)
643
+ if unknown_keys:
644
+ return KnowledgeCaptureValidation(
645
+ valid=False,
646
+ errors=(f"fact #{idx} has unknown keys: {', '.join(unknown_keys)}",),
647
+ warnings=(),
648
+ payload=None,
649
+ )
650
+ try:
651
+ facts.append(
652
+ CapturedFact(
653
+ title=_bounded_text(
654
+ item.get("title"), field=f"fact #{idx} title", max_chars=_MAX_TITLE_CHARS
655
+ ),
656
+ summary=_bounded_text(
657
+ item.get("summary"),
658
+ field=f"fact #{idx} summary",
659
+ max_chars=_MAX_SUMMARY_CHARS,
660
+ ),
661
+ paths=_normalize_paths(item.get("paths")),
662
+ tags=_normalize_tags(item.get("tags")),
663
+ )
664
+ )
665
+ except ValueError as exc:
666
+ return KnowledgeCaptureValidation(
667
+ valid=False,
668
+ errors=(str(exc),),
669
+ warnings=(),
670
+ payload=None,
671
+ )
672
+
673
+ decisions_raw = raw.get("decisions", [])
674
+ if not isinstance(decisions_raw, list):
675
+ return KnowledgeCaptureValidation(
676
+ valid=False,
677
+ errors=("decisions must be an array",),
678
+ warnings=(),
679
+ payload=None,
680
+ )
681
+ if len(decisions_raw) > _MAX_DECISIONS:
682
+ return KnowledgeCaptureValidation(
683
+ valid=False,
684
+ errors=(f"decisions exceeds max count {_MAX_DECISIONS}",),
685
+ warnings=(),
686
+ payload=None,
687
+ )
688
+
689
+ decisions: list[CapturedDecision] = []
690
+ seen_decision_keys: set[str] = set()
691
+ for idx, item in enumerate(decisions_raw, start=1):
692
+ if not isinstance(item, dict):
693
+ return KnowledgeCaptureValidation(
694
+ valid=False,
695
+ errors=(f"decision #{idx} must be an object",),
696
+ warnings=(),
697
+ payload=None,
698
+ )
699
+ unknown_keys = sorted(set(item) - _ALLOWED_DECISION_KEYS)
700
+ if unknown_keys:
701
+ return KnowledgeCaptureValidation(
702
+ valid=False,
703
+ errors=(f"decision #{idx} has unknown keys: {', '.join(unknown_keys)}",),
704
+ warnings=(),
705
+ payload=None,
706
+ )
707
+ try:
708
+ decision_key = _bounded_text(
709
+ item.get("decision_key"),
710
+ field=f"decision #{idx} decision_key",
711
+ max_chars=_MAX_DECISION_KEY_CHARS,
712
+ ).lower()
713
+ except ValueError as exc:
714
+ return KnowledgeCaptureValidation(
715
+ valid=False,
716
+ errors=(str(exc),),
717
+ warnings=(),
718
+ payload=None,
719
+ )
720
+ if not _DECISION_KEY_RE.match(decision_key):
721
+ return KnowledgeCaptureValidation(
722
+ valid=False,
723
+ errors=(f"invalid decision_key: {decision_key}",),
724
+ warnings=(),
725
+ payload=None,
726
+ )
727
+ if decision_key in seen_decision_keys:
728
+ return KnowledgeCaptureValidation(
729
+ valid=False,
730
+ errors=(f"duplicate decision_key: {decision_key}",),
731
+ warnings=(),
732
+ payload=None,
733
+ )
734
+ seen_decision_keys.add(decision_key)
735
+ status = str(item.get("status") or "").strip().lower()
736
+ if status not in _DECISION_STATUSES:
737
+ return KnowledgeCaptureValidation(
738
+ valid=False,
739
+ errors=(
740
+ f"decision #{idx} status must be one of: {', '.join(sorted(_DECISION_STATUSES))}",
741
+ ),
742
+ warnings=(),
743
+ payload=None,
744
+ )
745
+ try:
746
+ decisions.append(
747
+ CapturedDecision(
748
+ decision_key=decision_key,
749
+ title=_bounded_text(
750
+ item.get("title"),
751
+ field=f"decision #{idx} title",
752
+ max_chars=_MAX_TITLE_CHARS,
753
+ ),
754
+ summary=_bounded_text(
755
+ item.get("summary"),
756
+ field=f"decision #{idx} summary",
757
+ max_chars=_MAX_SUMMARY_CHARS,
758
+ ),
759
+ status=status,
760
+ paths=_normalize_paths(item.get("paths")),
761
+ tags=_normalize_tags(item.get("tags")),
762
+ )
763
+ )
764
+ except ValueError as exc:
765
+ return KnowledgeCaptureValidation(
766
+ valid=False,
767
+ errors=(str(exc),),
768
+ warnings=(),
769
+ payload=None,
770
+ )
771
+
772
+ try:
773
+ open_questions = _normalize_open_questions(raw.get("open_questions"))
774
+ except ValueError as exc:
775
+ return KnowledgeCaptureValidation(
776
+ valid=False,
777
+ errors=(str(exc),),
778
+ warnings=(),
779
+ payload=None,
780
+ )
781
+
782
+ return KnowledgeCaptureValidation(
783
+ valid=True,
784
+ errors=(),
785
+ warnings=(),
786
+ payload=KnowledgeCapturePayload(
787
+ schema_version=KNOWLEDGE_CAPTURE_SCHEMA_VERSION,
788
+ facts=tuple(facts),
789
+ decisions=tuple(decisions),
790
+ open_questions=open_questions,
791
+ ),
792
+ )
793
+
794
+
795
+ def _write_text(path: Path, text: str) -> Path:
796
+ path.parent.mkdir(parents=True, exist_ok=True)
797
+ path.write_text(text, encoding="utf-8")
798
+ return path
799
+
800
+
801
+ def _write_json(path: Path, payload: dict[str, Any]) -> Path:
802
+ path.parent.mkdir(parents=True, exist_ok=True)
803
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
804
+ return path
805
+
806
+
807
+ def _repo_rel_optional(root: Path, path: Path | None) -> str | None:
808
+ if path is None:
809
+ return None
810
+ return _repo_rel(root, path)
811
+
812
+
813
+ def _rooted_repo_path(root: Path, raw: Any) -> Path | None:
814
+ text = str(raw or "").strip()
815
+ if not text:
816
+ return None
817
+ candidate = Path(text)
818
+ if candidate.is_absolute():
819
+ return candidate
820
+ return root / candidate
821
+
822
+
823
+ def _build_capture_payload(raw: Any) -> KnowledgeCapturePayload:
824
+ if not isinstance(raw, dict):
825
+ raise ValueError("parsed structured capture must be a JSON object")
826
+ facts: list[CapturedFact] = []
827
+ for idx, item in enumerate(raw.get("facts") or [], start=1):
828
+ if not isinstance(item, dict):
829
+ raise ValueError(f"fact #{idx} must be an object")
830
+ facts.append(
831
+ CapturedFact(
832
+ title=_bounded_text(
833
+ item.get("title"),
834
+ field=f"fact #{idx} title",
835
+ max_chars=_MAX_TITLE_CHARS,
836
+ ),
837
+ summary=_bounded_text(
838
+ item.get("summary"),
839
+ field=f"fact #{idx} summary",
840
+ max_chars=_MAX_SUMMARY_CHARS,
841
+ ),
842
+ paths=_normalize_paths(item.get("paths")),
843
+ tags=_normalize_tags(item.get("tags")),
844
+ )
845
+ )
846
+ decisions: list[CapturedDecision] = []
847
+ for idx, item in enumerate(raw.get("decisions") or [], start=1):
848
+ if not isinstance(item, dict):
849
+ raise ValueError(f"decision #{idx} must be an object")
850
+ status = str(item.get("status") or "").strip().lower()
851
+ if status not in _DECISION_STATUSES:
852
+ raise ValueError(
853
+ f"decision #{idx} status must be one of: {', '.join(sorted(_DECISION_STATUSES))}"
854
+ )
855
+ decisions.append(
856
+ CapturedDecision(
857
+ decision_key=_bounded_text(
858
+ item.get("decision_key"),
859
+ field=f"decision #{idx} decision_key",
860
+ max_chars=_MAX_DECISION_KEY_CHARS,
861
+ ).lower(),
862
+ title=_bounded_text(
863
+ item.get("title"),
864
+ field=f"decision #{idx} title",
865
+ max_chars=_MAX_TITLE_CHARS,
866
+ ),
867
+ summary=_bounded_text(
868
+ item.get("summary"),
869
+ field=f"decision #{idx} summary",
870
+ max_chars=_MAX_SUMMARY_CHARS,
871
+ ),
872
+ status=status,
873
+ paths=_normalize_paths(item.get("paths")),
874
+ tags=_normalize_tags(item.get("tags")),
875
+ )
876
+ )
877
+ return KnowledgeCapturePayload(
878
+ schema_version=int(raw.get("schema_version") or KNOWLEDGE_CAPTURE_SCHEMA_VERSION),
879
+ facts=tuple(facts),
880
+ decisions=tuple(decisions),
881
+ open_questions=_normalize_open_questions(raw.get("open_questions")),
882
+ )
883
+
884
+
885
+ def persist_execution_knowledge_capture(
886
+ *,
887
+ paths: RunPaths,
888
+ task: dict[str, Any],
889
+ source: str,
890
+ assistant_message: str | None,
891
+ artifact_dir: Path,
892
+ report_path: Path | None,
893
+ patch_path: Path | None,
894
+ verify_artifact_path: Path | None,
895
+ budget_artifact_path: Path | None,
896
+ session_artifact_dir: Path | None,
897
+ ) -> PersistedKnowledgeCapture:
898
+ artifact_dir.mkdir(parents=True, exist_ok=True)
899
+ assistant_message_path = _write_text(
900
+ artifact_dir / "assistant_message.md",
901
+ (str(assistant_message or "").rstrip() + "\n")
902
+ if str(assistant_message or "").strip()
903
+ else "(none captured)\n",
904
+ )
905
+ capture_block = extract_knowledge_capture_block(str(assistant_message or ""))
906
+ capture_block_path = (
907
+ _write_text(artifact_dir / "capture_block.txt", capture_block.rstrip() + "\n")
908
+ if capture_block is not None
909
+ else None
910
+ )
911
+
912
+ validation = validate_knowledge_capture(final_text=str(assistant_message or ""))
913
+ parsed_capture_path = (
914
+ _write_json(artifact_dir / "parsed_capture.json", validation.payload.to_payload())
915
+ if validation.payload is not None
916
+ else None
917
+ )
918
+ capture_summary_path = artifact_dir / "summary.md"
919
+ validation_path = _write_json(
920
+ artifact_dir / "validation.json",
921
+ {
922
+ "schema_version": KNOWLEDGE_CAPTURE_SCHEMA_VERSION,
923
+ "task_id": str(task.get("id") or "").strip() or None,
924
+ "source": source,
925
+ "capture_present": capture_block is not None,
926
+ "assistant_message_path": _repo_rel(paths.root, assistant_message_path),
927
+ "capture_block_path": _repo_rel_optional(paths.root, capture_block_path),
928
+ "parsed_capture_path": _repo_rel_optional(paths.root, parsed_capture_path),
929
+ "report_path": _repo_rel_optional(paths.root, report_path),
930
+ "patch_path": _repo_rel_optional(paths.root, patch_path),
931
+ "verify_artifact_path": _repo_rel_optional(paths.root, verify_artifact_path),
932
+ "budget_artifact_path": _repo_rel_optional(paths.root, budget_artifact_path),
933
+ "session_artifact_dir": _repo_rel_optional(paths.root, session_artifact_dir),
934
+ "promotable_fact_count": len(validation.payload.facts) if validation.payload else 0,
935
+ "promotable_decision_count": len(validation.payload.decisions)
936
+ if validation.payload
937
+ else 0,
938
+ **validation.to_payload(),
939
+ },
940
+ )
941
+ promotion_path = artifact_dir / "promotion.json"
942
+ _write_promotion_result(
943
+ artifact_dir=artifact_dir,
944
+ promotion_path=promotion_path,
945
+ capture_valid=validation.valid,
946
+ promotion_attempted=False,
947
+ promotion_succeeded=False,
948
+ promotion_skipped_reason=None,
949
+ promotion_errors=(),
950
+ fact_entry_ids=(),
951
+ decision_entry_ids=(),
952
+ )
953
+
954
+ summary_lines = [
955
+ "# Structured Knowledge Capture",
956
+ "",
957
+ f"- Source: `{source}`",
958
+ f"- Valid: `{'yes' if validation.valid else 'no'}`",
959
+ f"- Promotable Facts: `{len(validation.payload.facts) if validation.payload else 0}`",
960
+ f"- Promotable Decisions: `{len(validation.payload.decisions) if validation.payload else 0}`",
961
+ f"- Assistant Message: `{_repo_rel(paths.root, assistant_message_path)}`",
962
+ (
963
+ f"- Capture Block: `{_repo_rel(paths.root, capture_block_path)}`"
964
+ if capture_block_path is not None
965
+ else "- Capture Block: (none)"
966
+ ),
967
+ (
968
+ f"- Parsed Capture: `{_repo_rel(paths.root, parsed_capture_path)}`"
969
+ if parsed_capture_path is not None
970
+ else "- Parsed Capture: (none)"
971
+ ),
972
+ f"- Validation: `{_repo_rel(paths.root, validation_path)}`",
973
+ f"- Promotion State: `{_repo_rel(paths.root, promotion_path)}`",
974
+ ]
975
+ if validation.errors:
976
+ summary_lines.extend(["", "## Errors", ""])
977
+ summary_lines.extend(f"- {item}" for item in validation.errors)
978
+ if validation.warnings:
979
+ summary_lines.extend(["", "## Warnings", ""])
980
+ summary_lines.extend(f"- {item}" for item in validation.warnings)
981
+ if validation.payload is not None and validation.payload.open_questions:
982
+ summary_lines.extend(["", "## Open Questions", ""])
983
+ summary_lines.extend(f"- {item}" for item in validation.payload.open_questions)
984
+ summary_path = _write_text(capture_summary_path, "\n".join(summary_lines).rstrip() + "\n")
985
+
986
+ return PersistedKnowledgeCapture(
987
+ artifact_dir=artifact_dir,
988
+ assistant_message_path=assistant_message_path,
989
+ capture_block_path=capture_block_path,
990
+ parsed_capture_path=parsed_capture_path,
991
+ validation_path=validation_path,
992
+ promotion_path=promotion_path,
993
+ summary_path=summary_path,
994
+ valid=validation.valid,
995
+ )
996
+
997
+
998
+ def promote_validated_knowledge_capture(
999
+ *,
1000
+ paths: RunPaths,
1001
+ task: dict[str, Any],
1002
+ artifact_dir: Path,
1003
+ ) -> KnowledgeCapturePromotionResult:
1004
+ existing = _load_promotion_result(artifact_dir)
1005
+ if existing is not None and (
1006
+ existing.promotion_succeeded
1007
+ or existing.promotion_attempted
1008
+ or existing.promotion_skipped_reason is not None
1009
+ ):
1010
+ return existing
1011
+
1012
+ promotion_path = artifact_dir / "promotion.json"
1013
+ validation_path = artifact_dir / "validation.json"
1014
+ if not validation_path.exists():
1015
+ return _write_promotion_result(
1016
+ artifact_dir=artifact_dir,
1017
+ promotion_path=promotion_path,
1018
+ capture_valid=False,
1019
+ promotion_attempted=False,
1020
+ promotion_succeeded=False,
1021
+ promotion_skipped_reason="validation artifact missing",
1022
+ promotion_errors=(),
1023
+ fact_entry_ids=(),
1024
+ decision_entry_ids=(),
1025
+ )
1026
+ try:
1027
+ validation_payload = _load_json_payload(validation_path)
1028
+ except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError) as exc:
1029
+ return _write_promotion_result(
1030
+ artifact_dir=artifact_dir,
1031
+ promotion_path=promotion_path,
1032
+ capture_valid=False,
1033
+ promotion_attempted=False,
1034
+ promotion_succeeded=False,
1035
+ promotion_skipped_reason=f"validation artifact unreadable: {exc}",
1036
+ promotion_errors=(),
1037
+ fact_entry_ids=(),
1038
+ decision_entry_ids=(),
1039
+ )
1040
+
1041
+ capture_valid = bool(validation_payload.get("valid"))
1042
+ parsed_raw = validation_payload.get("parsed_capture")
1043
+ if not capture_valid or parsed_raw is None:
1044
+ return _write_promotion_result(
1045
+ artifact_dir=artifact_dir,
1046
+ promotion_path=promotion_path,
1047
+ capture_valid=capture_valid,
1048
+ promotion_attempted=False,
1049
+ promotion_succeeded=False,
1050
+ promotion_skipped_reason="structured capture is not valid for canonical promotion",
1051
+ promotion_errors=(),
1052
+ fact_entry_ids=(),
1053
+ decision_entry_ids=(),
1054
+ )
1055
+ try:
1056
+ payload = _build_capture_payload(parsed_raw)
1057
+ except ValueError as exc:
1058
+ return _write_promotion_result(
1059
+ artifact_dir=artifact_dir,
1060
+ promotion_path=promotion_path,
1061
+ capture_valid=False,
1062
+ promotion_attempted=False,
1063
+ promotion_succeeded=False,
1064
+ promotion_skipped_reason=f"parsed capture artifact invalid: {exc}",
1065
+ promotion_errors=(),
1066
+ fact_entry_ids=(),
1067
+ decision_entry_ids=(),
1068
+ )
1069
+
1070
+ if not payload.facts and not payload.decisions:
1071
+ return _write_promotion_result(
1072
+ artifact_dir=artifact_dir,
1073
+ promotion_path=promotion_path,
1074
+ capture_valid=True,
1075
+ promotion_attempted=False,
1076
+ promotion_succeeded=False,
1077
+ promotion_skipped_reason="capture contained no promotable facts or decisions",
1078
+ promotion_errors=(),
1079
+ fact_entry_ids=(),
1080
+ decision_entry_ids=(),
1081
+ )
1082
+
1083
+ report_path = _rooted_repo_path(paths.root, validation_payload.get("report_path"))
1084
+ patch_path = _rooted_repo_path(paths.root, validation_payload.get("patch_path"))
1085
+ verify_artifact_path = _rooted_repo_path(
1086
+ paths.root, validation_payload.get("verify_artifact_path")
1087
+ )
1088
+ budget_artifact_path = _rooted_repo_path(
1089
+ paths.root, validation_payload.get("budget_artifact_path")
1090
+ )
1091
+ session_artifact_dir = _rooted_repo_path(
1092
+ paths.root, validation_payload.get("session_artifact_dir")
1093
+ )
1094
+ source = str(validation_payload.get("source") or "").strip() or "execution"
1095
+ capture_artifact_path = artifact_dir / "summary.md"
1096
+
1097
+ fact_entry_ids: list[str] = []
1098
+ decision_entry_ids: list[str] = []
1099
+ promotion_errors: list[str] = []
1100
+ for item in payload.facts:
1101
+ try:
1102
+ entry = write_fact_entry(
1103
+ paths=paths,
1104
+ task=task,
1105
+ source=source,
1106
+ title=item.title,
1107
+ summary=item.summary,
1108
+ paths_in_scope=item.paths,
1109
+ report_path=report_path,
1110
+ patch_path=patch_path,
1111
+ verify_artifact_path=verify_artifact_path,
1112
+ budget_artifact_path=budget_artifact_path,
1113
+ session_artifact_dir=session_artifact_dir,
1114
+ capture_artifact_path=capture_artifact_path,
1115
+ tags=["structured_capture", *item.tags],
1116
+ )
1117
+ except Exception as exc: # noqa: BLE001
1118
+ promotion_errors.append(f"failed to publish fact '{item.title}': {exc}")
1119
+ continue
1120
+ fact_entry_ids.append(entry.id)
1121
+ for item in payload.decisions:
1122
+ try:
1123
+ entry = write_decision_entry(
1124
+ paths=paths,
1125
+ task=task,
1126
+ source=source,
1127
+ decision_key=item.decision_key,
1128
+ title=item.title,
1129
+ summary=item.summary,
1130
+ status=item.status,
1131
+ paths_in_scope=item.paths,
1132
+ report_path=report_path,
1133
+ patch_path=patch_path,
1134
+ verify_artifact_path=verify_artifact_path,
1135
+ budget_artifact_path=budget_artifact_path,
1136
+ session_artifact_dir=session_artifact_dir,
1137
+ capture_artifact_path=capture_artifact_path,
1138
+ tags=["structured_capture", *item.tags],
1139
+ )
1140
+ except Exception as exc: # noqa: BLE001
1141
+ promotion_errors.append(f"failed to publish decision '{item.decision_key}': {exc}")
1142
+ continue
1143
+ decision_entry_ids.append(entry.id)
1144
+
1145
+ return _write_promotion_result(
1146
+ artifact_dir=artifact_dir,
1147
+ promotion_path=promotion_path,
1148
+ capture_valid=True,
1149
+ promotion_attempted=True,
1150
+ promotion_succeeded=not promotion_errors,
1151
+ promotion_skipped_reason=None,
1152
+ promotion_errors=promotion_errors,
1153
+ fact_entry_ids=fact_entry_ids,
1154
+ decision_entry_ids=decision_entry_ids,
1155
+ )
1156
+
1157
+
1158
+ def mark_knowledge_capture_promotion_skipped(
1159
+ *,
1160
+ artifact_dir: Path,
1161
+ reason: str,
1162
+ ) -> KnowledgeCapturePromotionResult | None:
1163
+ if not artifact_dir.exists():
1164
+ return None
1165
+ existing = _load_promotion_result(artifact_dir)
1166
+ if existing is not None and (
1167
+ existing.promotion_succeeded
1168
+ or existing.promotion_attempted
1169
+ or existing.promotion_skipped_reason is not None
1170
+ ):
1171
+ return existing
1172
+ validation_path = artifact_dir / "validation.json"
1173
+ capture_valid = False
1174
+ if validation_path.exists():
1175
+ try:
1176
+ payload = _load_json_payload(validation_path)
1177
+ capture_valid = bool(payload.get("valid"))
1178
+ except (OSError, UnicodeDecodeError, ValueError, json.JSONDecodeError):
1179
+ capture_valid = False
1180
+ return _write_promotion_result(
1181
+ artifact_dir=artifact_dir,
1182
+ promotion_path=artifact_dir / "promotion.json",
1183
+ capture_valid=capture_valid,
1184
+ promotion_attempted=False,
1185
+ promotion_succeeded=False,
1186
+ promotion_skipped_reason=reason,
1187
+ promotion_errors=(),
1188
+ fact_entry_ids=(),
1189
+ decision_entry_ids=(),
1190
+ )