mednotes-opencode 0.1.0

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 (430) hide show
  1. package/.opencode/agents/med-chat-triager.md +204 -0
  2. package/.opencode/agents/med-flashcard-maker.md +63 -0
  3. package/.opencode/agents/med-knowledge-architect.md +230 -0
  4. package/.opencode/agents/med-link-graph-curator.md +177 -0
  5. package/.opencode/agents/med-publish-guard.md +62 -0
  6. package/.opencode/commands/flashcards.md +25 -0
  7. package/.opencode/commands/mednotes/create.md +25 -0
  8. package/.opencode/commands/mednotes/enrich.md +27 -0
  9. package/.opencode/commands/mednotes/fix-wiki.md +27 -0
  10. package/.opencode/commands/mednotes/history.md +22 -0
  11. package/.opencode/commands/mednotes/link-body.md +25 -0
  12. package/.opencode/commands/mednotes/link-related.md +27 -0
  13. package/.opencode/commands/mednotes/link.md +27 -0
  14. package/.opencode/commands/mednotes/pdf-library.md +27 -0
  15. package/.opencode/commands/mednotes/process-chats.md +23 -0
  16. package/.opencode/commands/mednotes/setup.md +21 -0
  17. package/.opencode/commands/mednotes/status.md +27 -0
  18. package/.opencode/commands/mednotes/telemetry.md +27 -0
  19. package/.opencode/commands/report.md +26 -0
  20. package/.opencode/mednotes/AGENTS.md +57 -0
  21. package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
  22. package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
  23. package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
  24. package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
  25. package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
  26. package/.opencode/mednotes/contracts/.gitkeep +1 -0
  27. package/.opencode/mednotes/contracts/agents.json +116 -0
  28. package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
  29. package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
  30. package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
  31. package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
  32. package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
  33. package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
  34. package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
  35. package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
  36. package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
  37. package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
  38. package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
  39. package/.opencode/mednotes/docs/extension-docs.md +40 -0
  40. package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
  41. package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
  42. package/.opencode/mednotes/docs/merge-policy.md +110 -0
  43. package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
  44. package/.opencode/mednotes/docs/semantic-linker.md +141 -0
  45. package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
  46. package/.opencode/mednotes/docs/triage-policy.md +187 -0
  47. package/.opencode/mednotes/docs/vault-version-control.md +758 -0
  48. package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
  49. package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
  50. package/.opencode/mednotes/hooks/hooks.json +79 -0
  51. package/.opencode/mednotes/package-lock.json +6361 -0
  52. package/.opencode/mednotes/package.json +15 -0
  53. package/.opencode/mednotes/pyproject.toml +48 -0
  54. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
  55. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
  56. package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
  57. package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
  58. package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
  59. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
  60. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
  61. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  62. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  63. package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
  64. package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
  65. package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
  66. package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  67. package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
  68. package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
  69. package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
  70. package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
  71. package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
  72. package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
  73. package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
  74. package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
  75. package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
  76. package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
  77. package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
  78. package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
  79. package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
  80. package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
  81. package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
  82. package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
  83. package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
  84. package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
  85. package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
  86. package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
  87. package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
  88. package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
  89. package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
  90. package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
  91. package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
  92. package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
  93. package/.opencode/mednotes/scripts/run_python.mjs +109 -0
  94. package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
  95. package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
  96. package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
  97. package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
  98. package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
  99. package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
  100. package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
  101. package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
  102. package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
  103. package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
  104. package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
  105. package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
  106. package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
  107. package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
  108. package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
  109. package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  110. package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  111. package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  112. package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
  113. package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
  114. package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
  115. package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
  116. package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
  117. package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
  118. package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
  119. package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
  120. package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
  121. package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
  122. package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
  123. package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
  124. package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
  125. package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
  126. package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
  127. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
  128. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
  129. package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
  130. package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
  131. package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
  132. package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
  133. package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
  134. package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
  135. package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
  136. package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
  137. package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
  138. package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
  139. package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
  140. package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
  141. package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
  142. package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
  143. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
  144. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
  145. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
  146. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
  147. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
  148. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
  149. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
  150. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
  151. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
  152. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
  153. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
  154. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
  155. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
  156. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
  157. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
  158. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
  159. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
  160. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
  161. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
  162. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
  163. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
  164. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
  165. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
  166. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
  167. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
  168. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
  169. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
  170. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
  171. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
  172. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
  173. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
  174. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
  175. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
  176. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
  177. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
  178. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
  179. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
  180. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
  181. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
  182. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
  183. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
  184. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
  185. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
  186. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
  187. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
  188. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
  189. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
  190. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
  191. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
  192. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
  193. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
  194. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
  195. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
  196. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
  197. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
  198. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
  199. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
  200. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
  201. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
  202. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
  203. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
  204. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
  205. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
  206. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
  207. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
  208. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
  209. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
  210. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
  211. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
  212. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
  213. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
  214. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
  215. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
  216. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
  217. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
  218. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
  219. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
  220. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
  221. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
  222. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
  223. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
  224. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
  225. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
  226. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
  227. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
  228. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
  229. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
  230. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
  231. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
  232. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
  233. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
  234. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
  235. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
  236. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
  237. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
  238. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
  239. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
  240. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
  241. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
  242. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
  243. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
  244. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
  245. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
  246. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
  247. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
  248. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
  249. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
  250. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
  251. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
  252. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
  253. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
  254. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
  255. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
  256. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
  257. package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
  258. package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
  259. package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
  260. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
  261. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
  262. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
  263. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
  264. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
  265. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
  266. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
  267. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
  268. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
  269. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
  270. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
  271. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
  272. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
  273. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
  274. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
  275. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
  276. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
  277. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
  278. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
  279. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
  280. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
  281. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
  282. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
  283. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
  284. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
  285. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
  286. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
  287. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
  288. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
  289. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
  290. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
  291. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
  292. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
  293. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
  294. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
  295. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
  296. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
  297. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
  298. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
  299. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
  300. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
  301. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
  302. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
  303. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
  304. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
  305. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
  306. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
  307. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
  308. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
  309. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
  310. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
  311. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
  312. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
  313. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
  314. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
  315. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
  316. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
  317. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
  318. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
  319. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
  320. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
  321. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
  322. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
  323. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
  324. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
  325. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
  326. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
  327. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
  328. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
  329. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
  330. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
  331. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
  332. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
  333. package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
  334. package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
  335. package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
  336. package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
  337. package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
  338. package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
  339. package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
  340. package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
  341. package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
  342. package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
  343. package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
  344. package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
  345. package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
  346. package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
  347. package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
  348. package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
  349. package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
  350. package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
  351. package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
  352. package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
  353. package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
  354. package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
  355. package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
  356. package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
  357. package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
  358. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
  359. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
  360. package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
  361. package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
  362. package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
  363. package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
  364. package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
  365. package/.opencode/mednotes/uv.lock +932 -0
  366. package/.opencode/mednotes.generated.json +395 -0
  367. package/.opencode/opencode.json +31 -0
  368. package/.opencode/plugins/mednotes-fsm.mjs +7 -0
  369. package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
  370. package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
  371. package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  372. package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  373. package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
  374. package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
  375. package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
  376. package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  377. package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
  378. package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
  379. package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
  380. package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
  381. package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
  382. package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
  383. package/AGENTS.md +57 -0
  384. package/README.md +194 -0
  385. package/adapters/antigravity/agents.json +80 -0
  386. package/adapters/antigravity/templates/med-chat-triager.md +214 -0
  387. package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
  388. package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
  389. package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
  390. package/adapters/antigravity/templates/med-publish-guard.md +71 -0
  391. package/adapters/gemini-cli/gemini-extension.json +14 -0
  392. package/adapters/gemini-cli/package.json +15 -0
  393. package/adapters/gemini-cli/pyproject.toml +48 -0
  394. package/bin/mednotes-opencode.mjs +155 -0
  395. package/contracts/agents.json +116 -0
  396. package/core/agents/med-chat-triager.md +197 -0
  397. package/core/agents/med-flashcard-maker.md +56 -0
  398. package/core/agents/med-knowledge-architect.md +224 -0
  399. package/core/agents/med-link-graph-curator.md +171 -0
  400. package/core/agents/med-publish-guard.md +55 -0
  401. package/core/commands/flashcards.toml +22 -0
  402. package/core/commands/mednotes/create.toml +22 -0
  403. package/core/commands/mednotes/enrich.toml +24 -0
  404. package/core/commands/mednotes/fix-wiki.toml +24 -0
  405. package/core/commands/mednotes/history.toml +19 -0
  406. package/core/commands/mednotes/link-body.toml +22 -0
  407. package/core/commands/mednotes/link-related.toml +24 -0
  408. package/core/commands/mednotes/link.toml +24 -0
  409. package/core/commands/mednotes/pdf-library.toml +24 -0
  410. package/core/commands/mednotes/process-chats.toml +20 -0
  411. package/core/commands/mednotes/setup.toml +18 -0
  412. package/core/commands/mednotes/status.toml +24 -0
  413. package/core/commands/mednotes/telemetry.toml +24 -0
  414. package/core/commands/report.toml +23 -0
  415. package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
  416. package/core/skills/create-medical-flashcards/SKILL.md +113 -0
  417. package/core/skills/create-medical-note/SKILL.md +90 -0
  418. package/core/skills/enrich-medical-note/SKILL.md +120 -0
  419. package/core/skills/fix-medical-wiki/SKILL.md +559 -0
  420. package/core/skills/link-medical-wiki/SKILL.md +224 -0
  421. package/core/skills/obsidian-cli/SKILL.md +118 -0
  422. package/core/skills/obsidian-markdown/SKILL.md +207 -0
  423. package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  424. package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  425. package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  426. package/core/skills/obsidian-ops/SKILL.md +136 -0
  427. package/core/skills/pdf-library/SKILL.md +45 -0
  428. package/core/skills/process-medical-chats/SKILL.md +246 -0
  429. package/core/skills/workflow-report/SKILL.md +100 -0
  430. package/package.json +45 -0
@@ -0,0 +1,4168 @@
1
+ """Structured local feedback records for public workflow executions."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sys
10
+ import time
11
+ from collections import Counter, defaultdict
12
+ from collections.abc import Callable
13
+ from datetime import UTC, datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from mednotes.kernel.base import JsonObject, JsonObjectAdapter, JsonValue
18
+ from mednotes.kernel.guardrails import (
19
+ CONTRACT_GAP_MISSING_NEXT_ACTION,
20
+ )
21
+ from mednotes.kernel.guardrails import (
22
+ default_contract_next_action as _shared_default_contract_next_action,
23
+ )
24
+ from mednotes.kernel.guardrails import (
25
+ needs_next_action_hardening as _shared_needs_next_action_hardening,
26
+ )
27
+ from mednotes.kernel.public_report import FsmFirstPayloadSummary
28
+ from mednotes.platform.paths import user_state_dir
29
+
30
+ RUN_RECORD_SCHEMA = "medical-notes-workbench.workflow-run-record.v1"
31
+ BACKLOG_SCHEMA = "medical-notes-workbench.workflow-improvement-backlog.v1"
32
+ AGENT_HOOK_EVENT_SCHEMA = "medical-notes-workbench.agent-hook-event.v1"
33
+ AGENT_HOOK_ERROR_SCHEMA = "medical-notes-workbench.agent-hook-error.v1"
34
+ PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA = "medical-notes-workbench.pre-update-extension-snapshot.v1"
35
+ TELEMETRY_EVIDENCE_SCHEMA = "medical-notes-workbench.telemetry-evidence.v1"
36
+ ENVIRONMENT_BLOCKER_CODE = "environment_blocker.windows_path_or_venv"
37
+ DEFAULT_ROOT = "~/.mednotes/feedback"
38
+ FSM_FIRST_RUN_RECORD_SCHEMAS = {
39
+ "medical-notes-workbench.fix-wiki-fsm-result.v1",
40
+ "medical-notes-workbench.flashcards-fsm-result.v1",
41
+ "medical-notes-workbench.link-fsm-result.v1",
42
+ "medical-notes-workbench.link-related-fsm-result.v1",
43
+ "medical-notes-workbench.process-chats-fsm-result.v1",
44
+ "medical-notes-workbench.setup-fsm-result.v1",
45
+ "medical-notes-workbench.history-fsm-result.v1",
46
+ }
47
+ MAX_SNIPPET_CHARS = 420
48
+ MAX_RELEVANT_PATHS = 24
49
+ MAX_PATH_HASH_BYTES = 2 * 1024 * 1024
50
+ MAX_DIAGNOSTIC_ITEMS = 3
51
+ MAX_AGENT_EVENT_SAMPLES = 3
52
+ MAX_AGENT_EVENTS = 20
53
+ MAX_HOOK_EVENTS = 50
54
+ MAX_HOOK_ERRORS = 25
55
+ MAX_GENERATED_SCRIPTS = 12
56
+ MAX_COMMAND_EVENTS = 20
57
+ MAX_SCRIPT_CONTENT_CHARS = 48 * 1024
58
+ MAX_CONSOLE_TAIL_CHARS = 16 * 1024
59
+ MAX_HOOK_ERROR_CHARS = 8 * 1024
60
+ MAX_PRE_UPDATE_PATCH_CHARS = 160 * 1024
61
+ DEFAULT_RUN_RECORD_MAX_FILES = 200
62
+ DEFAULT_RUN_RECORD_RETENTION_DAYS = 14
63
+ DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS = 5
64
+ DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS = 7
65
+ DEFAULT_HOOK_EVENT_RETENTION_DAYS = 1
66
+ AGENT_EMPTY_RECORD_INHERIT_SECONDS = 15 * 60
67
+ INHERITABLE_WORKFLOW_STATUSES = {
68
+ "failed": 3,
69
+ "error": 3,
70
+ "blocked": 2,
71
+ "completed_with_warnings": 1,
72
+ }
73
+ PRE_UPDATE_PATCH_NOISE_PARTS = (
74
+ "__pycache__/",
75
+ ".venv/",
76
+ "node_modules/",
77
+ ".pytest_cache/",
78
+ ".mypy_cache/",
79
+ ".egg-info/",
80
+ )
81
+
82
+ ARTIFACT_STATE_KEYS = {
83
+ "batch_id",
84
+ "run_id",
85
+ "note_plan_hash",
86
+ "coverage_hash",
87
+ "manifest_hash",
88
+ "manifest_sha256",
89
+ "source_artifact_hash",
90
+ "dry_run_options_hash",
91
+ }
92
+
93
+ COUNT_KEYS = {
94
+ "count",
95
+ "file_count",
96
+ "changed_count",
97
+ "written_count",
98
+ "error_count",
99
+ "warning_count",
100
+ "write_error_count",
101
+ "requires_llm_rewrite_count",
102
+ "taxonomy_issue_count",
103
+ "taxonomy_applied_move_count",
104
+ "graph_error_count",
105
+ "blocker_count",
106
+ "links_planned",
107
+ "links_rewritten",
108
+ "files_scanned",
109
+ "files_changed",
110
+ "candidate_count",
111
+ "new_count",
112
+ "duplicate_count",
113
+ "changed_source_count",
114
+ "anki_note_count",
115
+ "processed_note_count",
116
+ "created_card_count",
117
+ "duplicate_card_count",
118
+ "skipped_note_count",
119
+ "model_error_count",
120
+ "anki_error_count",
121
+ "inserted_count",
122
+ "enriched_count",
123
+ "skipped_count",
124
+ "no_insert_count",
125
+ "failure_count",
126
+ }
127
+
128
+ PATH_KEY_HINTS = ("path", "file", "dir", "manifest", "receipt", "output", "target")
129
+ TITLE_KEY_HINTS = ("title", "titulo", "título")
130
+ HASH_KEY_HINTS = ("hash", "sha", "sha256", "digest")
131
+ SECRET_KEYS = {"token", "auth_token", "api_key", "apikey", "secret", "password", "authorization", "bearer"}
132
+ LONG_TEXT_KEYS = {"content", "markdown", "html", "raw_chat", "note_text"}
133
+ SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".sh", ".ps1", ".cmd"}
134
+ AGENT_RELEVANT_DRIFT_KINDS = {"launcher", "prompt", "runbook", "documentation", "script"}
135
+ AGENT_RELEVANT_DRIFT_PREFIXES = (
136
+ "commands/",
137
+ "docs/",
138
+ "scripts/",
139
+ "skills/",
140
+ )
141
+ RETRY_BUDGETS = {
142
+ "rewrite": {
143
+ "max_attempts": 2,
144
+ "rule": "Reescrita clínica/determinística deve parar após duas tentativas e preservar error_context.",
145
+ },
146
+ "publish_rollback": {
147
+ "max_attempts": 0,
148
+ "rule": "Falha após início de publish não deve ser repetida automaticamente; rollback e revisão primeiro.",
149
+ },
150
+ "dry_run": {
151
+ "max_attempts": 1,
152
+ "rule": "Dry-run só deve repetir se manifest, blockers ou opções mudaram.",
153
+ },
154
+ "coverage_stage": {
155
+ "max_attempts": 1,
156
+ "rule": "Coverage/stage só deve repetir se coverage, manifest ou nota staged mudaram.",
157
+ },
158
+ "triage_correction": {
159
+ "max_attempts": 1,
160
+ "rule": "Correção de triagem só deve repetir se note_plan mudou.",
161
+ },
162
+ }
163
+ ENVIRONMENT_PATTERN_CODES: tuple[tuple[re.Pattern[str], str], ...] = (
164
+ (re.compile(r"\bwinerror\s*\d+\b", re.IGNORECASE), "windows_error"),
165
+ (re.compile(r"microsoft\\windowsapps", re.IGNORECASE), "windows_store_python_alias"),
166
+ (
167
+ re.compile(r"executionpolicy|running scripts is disabled|cannot be loaded because running scripts", re.IGNORECASE),
168
+ "powershell_execution_policy",
169
+ ),
170
+ (
171
+ re.compile(r"\buv(?:\.exe)?\b.{0,120}(not found|not recognized|could not find|no such file|failed)", re.IGNORECASE),
172
+ "uv_unavailable",
173
+ ),
174
+ (
175
+ re.compile(r"(not found|not recognized|could not find|no such file).{0,120}\buv(?:\.exe)?\b", re.IGNORECASE),
176
+ "uv_unavailable",
177
+ ),
178
+ (re.compile(r"uv_project_environment|persistent_venv|\.venv[\\/](scripts|bin)", re.IGNORECASE), "persistent_venv"),
179
+ (
180
+ re.compile(
181
+ r"\bpython(?:\.exe)?\b.{0,120}(not found|not recognized|could not find|no such file)|no module named",
182
+ re.IGNORECASE,
183
+ ),
184
+ "python_environment",
185
+ ),
186
+ (re.compile(r"max_path|long path|filename too long|file name too long", re.IGNORECASE), "windows_long_path"),
187
+ (re.compile(r"[A-Za-z]:\\[^\r\n]*", re.IGNORECASE), "windows_path"),
188
+ (re.compile(r"\r\n"), "crlf"),
189
+ (re.compile(r"\b(powershell|pwsh|set-content|out-file)\b", re.IGNORECASE), "powershell_command"),
190
+ )
191
+
192
+ STRONG_ENVIRONMENT_CODES = {
193
+ "windows_error",
194
+ "windows_store_python_alias",
195
+ "powershell_execution_policy",
196
+ "uv_unavailable",
197
+ "persistent_venv",
198
+ "python_environment",
199
+ "windows_long_path",
200
+ }
201
+
202
+
203
+ def now_iso() -> str:
204
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
205
+
206
+
207
+ def feedback_root(root: str | Path | None = None) -> Path:
208
+ value = root or os.getenv("MEDNOTES_FEEDBACK_DIR")
209
+ if not value:
210
+ value = user_state_dir() / "feedback"
211
+ return Path(os.path.expandvars(str(value))).expanduser()
212
+
213
+
214
+ def command_string(argv: list[str] | None = None) -> str:
215
+ values = list(sys.argv if argv is None else argv)
216
+ return " ".join(_quote_arg(item) for item in values)
217
+
218
+
219
+ def _quote_arg(value: str) -> str:
220
+ if not value:
221
+ return "''"
222
+ if re.search(r"\s|['\"]", value):
223
+ return json.dumps(value, ensure_ascii=False)
224
+ return value
225
+
226
+
227
+ def redact_snippet(value: Any, *, max_chars: int = MAX_SNIPPET_CHARS) -> str:
228
+ text = str(value)
229
+ text = re.sub(r"```.*?```", "[code omitted]", text, flags=re.DOTALL)
230
+ text = _redact_sensitive_text(text)
231
+ text = re.sub(r"\s+", " ", text).strip()
232
+ if len(text) > max_chars:
233
+ return text[: max_chars - 3].rstrip() + "..."
234
+ return text
235
+
236
+
237
+ def redact_operational_text(value: Any, *, max_chars: int = MAX_SCRIPT_CONTENT_CHARS) -> str:
238
+ text = _redact_sensitive_text(str(value))
239
+ if len(text) > max_chars:
240
+ return text[: max_chars - 3].rstrip() + "..."
241
+ return text
242
+
243
+
244
+ def _redact_operational_identifier(value: Any, *, max_chars: int = 120) -> str:
245
+ text = str(value or "").strip()
246
+ if _looks_like_operational_identifier(text):
247
+ return text[: max_chars - 3].rstrip() + "..." if len(text) > max_chars else text
248
+ return redact_snippet(text, max_chars=max_chars)
249
+
250
+
251
+ def _redact_sensitive_text(text: str) -> str:
252
+ text = re.sub(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[email]", text)
253
+ text = re.sub(
254
+ r"(?i)\b(api[_-]?key|token|secret|password|authorization|bearer)(\s*[:=]\s*)([\"']?)[^\s\"']+",
255
+ r"\1\2[redacted]",
256
+ text,
257
+ )
258
+ text = re.sub(
259
+ r"(?i)(--(?:api-key|auth-token|token|secret|password)\s+)([^\s\"']+)",
260
+ r"\1[redacted]",
261
+ text,
262
+ )
263
+ text = re.sub(
264
+ r"https?://[^\s)>\"]+",
265
+ lambda match: _redact_url(match.group(0)),
266
+ text,
267
+ )
268
+ return re.sub(
269
+ r"\b[A-Za-z0-9_=-]{36,}\b",
270
+ lambda match: match.group(0) if _looks_like_public_slug(match.group(0)) else "[redacted-token]",
271
+ text,
272
+ )
273
+
274
+
275
+ def _redact_url(url: str) -> str:
276
+ if "?" not in url:
277
+ return url
278
+ head, _query = url.split("?", 1)
279
+ return f"{head}?[redacted]"
280
+
281
+
282
+ def _looks_like_public_slug(value: str) -> bool:
283
+ return bool(
284
+ "-" in value
285
+ and value.lower() == value
286
+ and re.fullmatch(r"[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*){2,}", value)
287
+ )
288
+
289
+
290
+ def _looks_like_operational_identifier(value: str) -> bool:
291
+ return bool(
292
+ value
293
+ and value.lower() == value
294
+ and ("-" in value or "_" in value)
295
+ and re.fullmatch(r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)+", value)
296
+ )
297
+
298
+
299
+ def _json_object_view(value: object) -> JsonObject:
300
+ """Validate raw JSON-ish evidence before operational fields can be read."""
301
+
302
+ if not isinstance(value, dict):
303
+ return {}
304
+ return JsonObjectAdapter.validate_python(value)
305
+
306
+
307
+ def _json_value(source: JsonObject, key: str) -> JsonValue:
308
+ if key not in source:
309
+ return None
310
+ return JsonObjectAdapter.validate_python({"value": source[key]})["value"]
311
+
312
+
313
+ def _json_text(source: JsonObject, key: str) -> str:
314
+ value = _json_value(source, key)
315
+ return value.strip() if isinstance(value, str) else ""
316
+
317
+
318
+ def _json_object_field(source: JsonObject, key: str) -> JsonObject:
319
+ return _json_object_view(_json_value(source, key))
320
+
321
+
322
+ def _json_list_field(source: JsonObject, key: str) -> list[JsonValue]:
323
+ value = _json_value(source, key)
324
+ return value if isinstance(value, list) else []
325
+
326
+
327
+ def _json_bool_field(source: JsonObject, key: str) -> bool:
328
+ value = _json_value(source, key)
329
+ return value if isinstance(value, bool) else False
330
+
331
+
332
+ def _json_int_field(source: JsonObject, key: str) -> int | None:
333
+ value = _json_value(source, key)
334
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
335
+
336
+
337
+ def summarize_payload(payload: object) -> JsonObject:
338
+ summary: JsonObject = {
339
+ "counts": {},
340
+ "warnings": [],
341
+ "errors": [],
342
+ "required_inputs": [],
343
+ "relevant_paths": [],
344
+ "path_hashes": {},
345
+ "title_fields": {},
346
+ "artifact_state": {},
347
+ "signals": [],
348
+ }
349
+ if not isinstance(payload, dict):
350
+ return summary
351
+
352
+ payload_view = _json_object_view(payload)
353
+ progress = _json_object_field(payload_view, "progress_view_model")
354
+ decision = _json_object_field(payload_view, "decision")
355
+ error_context = _json_object_field(payload_view, "error_context")
356
+ receipt = _json_object_field(payload_view, "receipt")
357
+
358
+ if _json_text(payload_view, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS:
359
+ fsm_summary = FsmFirstPayloadSummary.from_payload(payload_view).to_payload()
360
+ summary.update(fsm_summary)
361
+ else:
362
+ summary["phase"] = _json_text(payload_view, "phase") or _json_text(progress, "phase") or _json_text(payload_view, "command")
363
+ summary["status"] = (
364
+ _json_text(payload_view, "status")
365
+ or _json_text(progress, "status")
366
+ or _json_text(receipt, "status")
367
+ or _status_from_payload(payload_view)
368
+ )
369
+ summary["blocked_reason"] = (
370
+ _json_text(payload_view, "blocked_reason")
371
+ or _json_text(error_context, "blocked_reason")
372
+ or _json_text(error_context, "root_cause")
373
+ or _json_text(decision, "reason_code")
374
+ or _blocked_reason_from_payload(payload_view)
375
+ )
376
+ summary["next_action"] = (
377
+ _json_text(payload_view, "next_action")
378
+ or _json_text(decision, "next_action")
379
+ or _json_text(error_context, "next_action")
380
+ or _json_text(receipt, "next_action")
381
+ or _json_text(payload_view, "next_command")
382
+ )
383
+ summary["human_decision_required"] = bool(
384
+ _json_bool_field(payload_view, "human_decision_required")
385
+ or bool(_json_object_field(payload_view, "human_decision_packet"))
386
+ or bool(_json_list_field(payload_view, "human_decision_packets"))
387
+ or _json_text(decision, "kind") == "ask_human"
388
+ )
389
+ summary["dry_run"] = _json_bool_field(payload_view, "dry_run") if "dry_run" in payload_view else None
390
+ summary["apply"] = _json_bool_field(payload_view, "apply") if "apply" in payload_view else None
391
+ workflow_exit_code = _json_int_field(payload_view, "workflow_exit_code")
392
+ if workflow_exit_code is not None:
393
+ summary["workflow_exit_code"] = workflow_exit_code
394
+ for key in ("process_chats_terminal_state", "process_chats_backlog_state"):
395
+ value = _json_text(payload_view, key)
396
+ if value:
397
+ summary[key] = _redact_operational_identifier(value, max_chars=120)
398
+
399
+ required = _json_list_field(payload_view, "required_inputs")
400
+ if not required:
401
+ required = _json_list_field(decision, "required_inputs")
402
+ if not required:
403
+ required = _json_list_field(error_context, "required_inputs")
404
+ if not required:
405
+ required = _json_list_field(receipt, "required_inputs")
406
+ if required:
407
+ summary["required_inputs"] = [str(item) for item in required]
408
+
409
+ counts: dict[str, int | float] = {}
410
+ _collect_counts(payload, counts)
411
+ summary["counts"] = counts
412
+
413
+ warnings: list[str] = []
414
+ errors: list[str] = []
415
+ _collect_messages(payload, warnings=warnings, errors=errors)
416
+ summary["warnings"] = warnings[:20]
417
+ summary["errors"] = errors[:20]
418
+
419
+ paths = _collect_paths(payload)
420
+ summary["relevant_paths"] = paths
421
+ summary["path_hashes"] = _hash_paths(paths)
422
+ summary["title_fields"] = _collect_title_fields(payload)
423
+ summary["artifact_state"] = _collect_artifact_state(payload)
424
+ summary["signals"] = _signals_from_payload(payload, summary)
425
+ return summary
426
+
427
+
428
+ def _is_empty_agent_feedback_payload(payload: dict[str, Any]) -> bool:
429
+ meaningful_keys = {
430
+ "status",
431
+ "phase",
432
+ "blocked_reason",
433
+ "next_action",
434
+ "required_inputs",
435
+ "human_decision_required",
436
+ "dry_run",
437
+ "apply",
438
+ "agent_events",
439
+ "error_context",
440
+ "diagnostic_context",
441
+ "warnings",
442
+ "errors",
443
+ }
444
+ for key in meaningful_keys:
445
+ value = payload.get(key)
446
+ if value not in (None, "", [], {}, False):
447
+ return False
448
+ return True
449
+
450
+
451
+ def _recorded_at_datetime(record: dict[str, Any]) -> datetime | None:
452
+ raw = str(record.get("recorded_at") or "")
453
+ if not raw:
454
+ return None
455
+ try:
456
+ parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
457
+ except ValueError:
458
+ return None
459
+ if parsed.tzinfo is None:
460
+ return parsed.replace(tzinfo=UTC)
461
+ return parsed.astimezone(UTC)
462
+
463
+
464
+ def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
465
+ try:
466
+ parsed = int(str(os.environ.get(name, "")).strip())
467
+ except ValueError:
468
+ return default
469
+ return max(minimum, min(maximum, parsed))
470
+
471
+
472
+ def _env_float(name: str, default: float, *, minimum: float, maximum: float) -> float:
473
+ try:
474
+ parsed = float(str(os.environ.get(name, "")).strip())
475
+ except ValueError:
476
+ return default
477
+ return max(minimum, min(maximum, parsed))
478
+
479
+
480
+ def prune_local_feedback(*, root: str | Path | None = None) -> dict[str, object]:
481
+ feedback = feedback_root(root)
482
+ return {
483
+ "schema": "medical-notes-workbench.local-feedback-retention.v1",
484
+ "runs": _prune_json_files(
485
+ feedback / "runs",
486
+ max_files=_env_int("MEDNOTES_FEEDBACK_RUN_MAX_FILES", DEFAULT_RUN_RECORD_MAX_FILES, minimum=0, maximum=5000),
487
+ retention_days=_env_float(
488
+ "MEDNOTES_FEEDBACK_RUN_RETENTION_DAYS",
489
+ DEFAULT_RUN_RECORD_RETENTION_DAYS,
490
+ minimum=0.04,
491
+ maximum=365,
492
+ ),
493
+ ),
494
+ "pre_update_snapshots": _prune_directories(
495
+ feedback / "pre-update-snapshots",
496
+ max_dirs=_env_int("MEDNOTES_PRE_UPDATE_SNAPSHOT_MAX_DIRS", DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS, minimum=0, maximum=200),
497
+ retention_days=_env_float(
498
+ "MEDNOTES_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS",
499
+ DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS,
500
+ minimum=0.04,
501
+ maximum=365,
502
+ ),
503
+ ),
504
+ "hook_events": _prune_json_files(
505
+ feedback / "hook-events",
506
+ max_files=_env_int("MEDNOTES_HOOK_EVENT_MAX_FILES", MAX_HOOK_EVENTS, minimum=0, maximum=1000),
507
+ retention_days=_hook_retention_days("MEDNOTES_HOOK_EVENT_RETENTION_HOURS"),
508
+ ),
509
+ "hook_errors": _prune_json_files(
510
+ feedback / "hook-errors",
511
+ max_files=_env_int("MEDNOTES_HOOK_ERROR_MAX_FILES", MAX_HOOK_ERRORS, minimum=0, maximum=1000),
512
+ retention_days=_hook_retention_days("MEDNOTES_HOOK_ERROR_RETENTION_HOURS"),
513
+ ),
514
+ }
515
+
516
+
517
+ def _hook_retention_days(specific_env: str) -> float:
518
+ hours = _env_float(
519
+ specific_env,
520
+ _env_float("MEDNOTES_HOOK_RETENTION_HOURS", DEFAULT_HOOK_EVENT_RETENTION_DAYS * 24, minimum=1, maximum=24 * 30),
521
+ minimum=1,
522
+ maximum=24 * 30,
523
+ )
524
+ return hours / 24
525
+
526
+
527
+ def _prune_json_files(directory: Path, *, max_files: int, retention_days: float) -> dict[str, object]:
528
+ if not directory.exists():
529
+ return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 0}
530
+ try:
531
+ files = [path for path in directory.glob("*.json") if path.is_file()]
532
+ except OSError:
533
+ return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 1}
534
+ removed, errors = _remove_retention_victims(files, max_items=max_files, retention_days=retention_days, remove=lambda path: path.unlink())
535
+ remaining = len([path for path in directory.glob("*.json") if path.is_file()])
536
+ return {"path": str(directory), "removed_count": removed, "remaining_count": remaining, "error_count": errors}
537
+
538
+
539
+ def _prune_directories(directory: Path, *, max_dirs: int, retention_days: float) -> dict[str, object]:
540
+ if not directory.exists():
541
+ return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 0}
542
+ try:
543
+ dirs = [path for path in directory.iterdir() if path.is_dir()]
544
+ except OSError:
545
+ return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 1}
546
+ removed, errors = _remove_retention_victims(dirs, max_items=max_dirs, retention_days=retention_days, remove=shutil.rmtree)
547
+ remaining = len([path for path in directory.iterdir() if path.is_dir()])
548
+ return {"path": str(directory), "removed_count": removed, "remaining_count": remaining, "error_count": errors}
549
+
550
+
551
+ def _remove_retention_victims(
552
+ paths: list[Path],
553
+ *,
554
+ max_items: int,
555
+ retention_days: float,
556
+ remove: Callable[[Path], None],
557
+ ) -> tuple[int, int]:
558
+ cutoff = time.time() - retention_days * 86400
559
+ ordered = sorted(paths, key=lambda item: (_mtime(item), item.name), reverse=True)
560
+ victims: list[Path] = []
561
+ survivors: list[Path] = []
562
+ for item in ordered:
563
+ if _mtime(item) < cutoff:
564
+ victims.append(item)
565
+ else:
566
+ survivors.append(item)
567
+ victims.extend(survivors[max(0, max_items):])
568
+ removed = 0
569
+ errors = 0
570
+ for item in victims:
571
+ try:
572
+ remove(item)
573
+ removed += 1
574
+ except OSError:
575
+ errors += 1
576
+ return removed, errors
577
+
578
+
579
+ def _mtime(path: Path) -> float:
580
+ try:
581
+ return path.stat().st_mtime
582
+ except OSError:
583
+ return 0.0
584
+
585
+
586
+ def _recent_inheritable_workflow_record(
587
+ *,
588
+ workflow: str,
589
+ root: str | Path | None,
590
+ started: float,
591
+ ) -> JsonObject | None:
592
+ runs_dir = feedback_root(root) / "runs"
593
+ if not runs_dir.exists():
594
+ return None
595
+ cutoff = datetime.fromtimestamp(max(0, started - AGENT_EMPTY_RECORD_INHERIT_SECONDS), UTC)
596
+ best: tuple[int, datetime, int, JsonObject] | None = None
597
+ for path in sorted(runs_dir.glob("*.json"))[-80:]:
598
+ try:
599
+ record = json.loads(path.read_text(encoding="utf-8"))
600
+ except (OSError, json.JSONDecodeError):
601
+ continue
602
+ if not isinstance(record, dict):
603
+ continue
604
+ record_view = _json_object_view(record)
605
+ if _json_text(record_view, "workflow") != workflow:
606
+ continue
607
+ status = _record_observed_text(record_view, "status")
608
+ rank = INHERITABLE_WORKFLOW_STATUSES.get(status, 0)
609
+ if rank <= 0:
610
+ continue
611
+ recorded_at = _recorded_at_datetime(record)
612
+ if recorded_at is None or recorded_at < cutoff:
613
+ continue
614
+ try:
615
+ mtime_ns = path.stat().st_mtime_ns
616
+ except OSError:
617
+ mtime_ns = 0
618
+ if best is None or rank > best[0] or (rank == best[0] and (recorded_at, mtime_ns) > (best[1], best[2])):
619
+ best = (rank, recorded_at, mtime_ns, record_view)
620
+ return best[3] if best else None
621
+
622
+
623
+ def _feedback_summary_command(command: str) -> bool:
624
+ slug = _code_slug(command)
625
+ return "feedback_report_py_record" in slug and "agent" in slug
626
+
627
+
628
+ def _record_observed_text(record: JsonObject, key: str) -> str:
629
+ """Read run-record observations without treating legacy root fields as truth."""
630
+
631
+ observed = _json_object_field(record, "observed")
632
+ summary = _json_object_field(record, "payload_summary")
633
+ return _json_text(observed, key) or _json_text(summary, key) or _json_text(record, key)
634
+
635
+
636
+ def _agent_feedback_payload_needs_inheritance(payload: dict[str, Any], *, command: str) -> bool:
637
+ if _is_empty_agent_feedback_payload(payload):
638
+ return True
639
+ if not _feedback_summary_command(command):
640
+ return False
641
+ evidence_keys = (
642
+ "error_context",
643
+ "agent_events",
644
+ "command_events",
645
+ "diagnosis_path",
646
+ "diagnosis",
647
+ "manifest_path",
648
+ "receipt_path",
649
+ "operational_evidence",
650
+ )
651
+ return not any(payload.get(key) not in (None, "", [], {}) for key in evidence_keys)
652
+
653
+
654
+ def _inherited_workflow_error_context(previous: JsonObject) -> JsonObject:
655
+ error_context = _json_object_field(previous, "error_context")
656
+ if error_context:
657
+ return error_context
658
+ status = _record_observed_text(previous, "status")
659
+ blocked_reason = _record_observed_text(previous, "blocked_reason")
660
+ next_action = _record_observed_text(previous, "next_action")
661
+ if status not in {"blocked", "failed", "error"} or not blocked_reason or not next_action:
662
+ return {}
663
+ required_inputs = [str(item) for item in _json_list_field(previous, "required_inputs") if str(item).strip()]
664
+ affected = ", ".join(required_inputs[:5]) or _json_text(previous, "workflow") or "workflow"
665
+ return _normalized_error_context(
666
+ {
667
+ "phase": _record_observed_text(previous, "phase") or "workflow",
668
+ "blocked_reason": blocked_reason,
669
+ "root_cause": f"Workflow reportou bloqueio acionavel: {blocked_reason}.",
670
+ "affected_artifact": affected,
671
+ "error_summary": f"Workflow terminou como {status} em {blocked_reason}.",
672
+ "suggested_fix": next_action,
673
+ "next_action": next_action,
674
+ "retry_scope": "resolve_required_inputs_then_retry",
675
+ "missing_inputs": required_inputs,
676
+ "human_decision_required": _json_bool_field(previous, "human_decision_required"),
677
+ }
678
+ )
679
+
680
+
681
+ def _inherit_agent_feedback_payload(
682
+ payload: dict[str, Any],
683
+ *,
684
+ workflow: str,
685
+ root: str | Path | None,
686
+ source: str,
687
+ started: float,
688
+ command: str,
689
+ ) -> dict[str, Any]:
690
+ if source != "agent" or not _agent_feedback_payload_needs_inheritance(payload, command=command):
691
+ return payload
692
+ previous = _recent_inheritable_workflow_record(workflow=workflow, root=root, started=started)
693
+ if not previous:
694
+ return payload
695
+ inherited = dict(payload)
696
+ diagnostic = dict(inherited.get("diagnostic_context") or {}) if isinstance(inherited.get("diagnostic_context"), dict) else {}
697
+ previous_error_context = _inherited_workflow_error_context(previous)
698
+ observed: JsonObject = {
699
+ "status": _record_observed_text(previous, "status"),
700
+ "phase": _record_observed_text(previous, "phase"),
701
+ "blocked_reason": _record_observed_text(previous, "blocked_reason"),
702
+ "next_action": _record_observed_text(previous, "next_action"),
703
+ }
704
+ inherited_feedback_context: JsonObject = {
705
+ "run_id": str(previous.get("run_id") or ""),
706
+ "source": str(previous.get("source") or ""),
707
+ "command": str(previous.get("command") or ""),
708
+ "observed": {key: value for key, value in observed.items() if value},
709
+ }
710
+ if previous_error_context:
711
+ inherited_feedback_context["error_context"] = previous_error_context
712
+ diagnostic["inherited_feedback_context"] = inherited_feedback_context
713
+ inherited["diagnostic_context"] = diagnostic
714
+ return inherited
715
+
716
+
717
+ def _default_contract_next_action(*, workflow: str, command: str) -> str:
718
+ return _shared_default_contract_next_action(workflow=workflow, command=command)
719
+
720
+
721
+ def _needs_next_action_hardening(payload: JsonObject) -> bool:
722
+ return _shared_needs_next_action_hardening(payload)
723
+
724
+
725
+ def _harden_payload_missing_next_action(
726
+ payload: JsonObject,
727
+ *,
728
+ workflow: str,
729
+ command: str,
730
+ ) -> JsonObject:
731
+ if not _needs_next_action_hardening(payload):
732
+ return dict(payload)
733
+ hardened = dict(payload)
734
+ summary = summarize_payload(hardened)
735
+ phase = _json_text(hardened, "phase") or str(summary.get("phase") or command or "unknown")
736
+ original_blocked_reason = _json_text(hardened, "blocked_reason") or _json_text(summary, "blocked_reason")
737
+ next_action = _default_contract_next_action(workflow=workflow, command=command)
738
+ hardened = {
739
+ **hardened,
740
+ "status": "blocked",
741
+ "blocked_reason": CONTRACT_GAP_MISSING_NEXT_ACTION,
742
+ "next_action": next_action,
743
+ }
744
+ if "required_inputs" not in hardened or not isinstance(_json_value(hardened, "required_inputs"), list):
745
+ hardened["required_inputs"] = []
746
+ hardened.setdefault("human_decision_required", False)
747
+
748
+ diagnostic: JsonObject = dict(_json_object_field(hardened, "diagnostic_context"))
749
+ diagnostic["root_cause_code"] = CONTRACT_GAP_MISSING_NEXT_ACTION
750
+ diagnostic["contract_gap"] = {
751
+ "missing_fields": ["next_action"],
752
+ "original_blocked_reason": original_blocked_reason,
753
+ "workflow": workflow,
754
+ "command": command,
755
+ }
756
+ hardened["diagnostic_context"] = diagnostic
757
+
758
+ if not _json_object_field(hardened, "error_context"):
759
+ hardened["error_context"] = {
760
+ "phase": phase,
761
+ "blocked_reason": CONTRACT_GAP_MISSING_NEXT_ACTION,
762
+ "root_cause": CONTRACT_GAP_MISSING_NEXT_ACTION,
763
+ "affected_artifact": phase,
764
+ "error_summary": _json_text(hardened, "error")
765
+ or _json_text(hardened, "message")
766
+ or CONTRACT_GAP_MISSING_NEXT_ACTION,
767
+ "suggested_fix": next_action,
768
+ "next_action": next_action,
769
+ "retry_scope": "restore_official_workflow_route",
770
+ "missing_inputs": ["next_action"],
771
+ "human_decision_required": _json_bool_field(hardened, "human_decision_required"),
772
+ }
773
+ return hardened
774
+
775
+
776
+ def _status_from_payload(payload: JsonObject) -> str:
777
+ if _json_bool_field(payload, "blocked"):
778
+ return "blocked"
779
+ if _safe_int(_json_value(payload, "error_count")) > 0:
780
+ return "failed"
781
+ ok_value = _json_value(payload, "ok")
782
+ if ok_value is False or _json_value(payload, "error") or _json_value(payload, "parse_error"):
783
+ return "failed"
784
+ if _json_value(payload, "warnings") or _json_value(payload, "warning_count"):
785
+ return "completed_with_warnings"
786
+ return "completed"
787
+
788
+
789
+ def _blocked_reason_from_payload(payload: JsonObject) -> str:
790
+ if _json_value(payload, "blocker_count"):
791
+ return "graph_blockers"
792
+ if _safe_int(_json_value(payload, "error_count")) > 0:
793
+ return "validation_errors"
794
+ if _json_bool_field(payload, "blocked"):
795
+ return "blocked"
796
+ if _json_value(payload, "parse_error") or _json_value(payload, "error"):
797
+ return "runtime_error"
798
+ model_validation = _json_object_field(payload, "model_validation")
799
+ if _json_value(model_validation, "ok") is False:
800
+ return "anki_model_validation_failed"
801
+ return ""
802
+
803
+
804
+ def _collect_counts(value: object, counts: dict[str, int | float], *, prefix: str = "") -> None:
805
+ if isinstance(value, dict):
806
+ for key, item in value.items():
807
+ name = f"{prefix}.{key}" if prefix else str(key)
808
+ leaf = str(key)
809
+ if isinstance(item, (int, float)) and not isinstance(item, bool):
810
+ if leaf in COUNT_KEYS or leaf.endswith(("_count", "_planned")):
811
+ counts[name] = item
812
+ elif isinstance(item, dict):
813
+ _collect_counts(item, counts, prefix=name)
814
+ elif isinstance(value, list):
815
+ for item in value[:20]:
816
+ if isinstance(item, dict):
817
+ _collect_counts(item, counts, prefix=prefix)
818
+
819
+
820
+ def _collect_messages(value: Any, *, warnings: list[str], errors: list[str]) -> None:
821
+ if isinstance(value, dict):
822
+ for key, item in value.items():
823
+ lower = str(key).lower()
824
+ if lower in {"warning", "warnings"}:
825
+ _append_messages(item, warnings)
826
+ elif lower in {"error", "errors", "write_errors", "anki_errors"}:
827
+ _append_messages(item, errors)
828
+ elif isinstance(item, (dict, list)):
829
+ _collect_messages(item, warnings=warnings, errors=errors)
830
+ elif isinstance(value, list):
831
+ for item in value[:30]:
832
+ _collect_messages(item, warnings=warnings, errors=errors)
833
+
834
+
835
+ def _append_messages(value: Any, target: list[str]) -> None:
836
+ if isinstance(value, str):
837
+ target.append(redact_snippet(value))
838
+ elif isinstance(value, list):
839
+ for item in value[:10]:
840
+ _append_messages(item, target)
841
+ elif isinstance(value, dict):
842
+ message = value.get("message") or value.get("error") or value.get("reason") or value.get("code")
843
+ if message:
844
+ target.append(redact_snippet(message))
845
+
846
+
847
+ def _collect_paths(payload: dict[str, Any]) -> list[str]:
848
+ paths: list[str] = []
849
+
850
+ def visit(value: Any, key: str = "") -> None:
851
+ if len(paths) >= MAX_RELEVANT_PATHS:
852
+ return
853
+ if isinstance(value, dict):
854
+ for child_key, child_value in value.items():
855
+ visit(child_value, str(child_key))
856
+ elif isinstance(value, list):
857
+ for item in value[:20]:
858
+ visit(item, key)
859
+ elif isinstance(value, str) and _looks_like_path_key(key) and _looks_like_path_value(value):
860
+ paths.append(value)
861
+
862
+ visit(payload)
863
+ deduped = []
864
+ seen = set()
865
+ for path in paths:
866
+ if path not in seen:
867
+ seen.add(path)
868
+ deduped.append(path)
869
+ return deduped[:MAX_RELEVANT_PATHS]
870
+
871
+
872
+ def _looks_like_path_key(key: str) -> bool:
873
+ lower = key.lower()
874
+ return any(hint in lower for hint in PATH_KEY_HINTS)
875
+
876
+
877
+ def _looks_like_path_value(value: str) -> bool:
878
+ if value.startswith(("obsidian://", "http://", "https://")):
879
+ return False
880
+ return "/" in value or "\\" in value or value.endswith((".md", ".json", ".toml", ".html"))
881
+
882
+
883
+ def _hash_paths(paths: list[str]) -> dict[str, str]:
884
+ hashes: dict[str, str] = {}
885
+ for raw in paths[:MAX_RELEVANT_PATHS]:
886
+ path = Path(os.path.expandvars(raw)).expanduser()
887
+ try:
888
+ if not path.is_file() or path.stat().st_size > MAX_PATH_HASH_BYTES:
889
+ continue
890
+ hashes[raw] = hashlib.sha256(path.read_bytes()).hexdigest()
891
+ except OSError:
892
+ continue
893
+ return hashes
894
+
895
+
896
+ def _collect_title_fields(payload: dict[str, Any]) -> dict[str, str]:
897
+ titles: dict[str, str] = {}
898
+
899
+ def visit(value: Any, prefix: str = "") -> None:
900
+ if len(titles) >= 80:
901
+ return
902
+ if isinstance(value, dict):
903
+ for child_key, child_value in value.items():
904
+ key = str(child_key)
905
+ name = f"{prefix}.{key}" if prefix else key
906
+ if isinstance(child_value, str) and _looks_like_title_key(key):
907
+ clean = redact_snippet(child_value, max_chars=240)
908
+ if clean:
909
+ titles[name] = clean
910
+ if isinstance(child_value, (dict, list)):
911
+ visit(child_value, name)
912
+ elif isinstance(value, list):
913
+ for index, item in enumerate(value[:20]):
914
+ if isinstance(item, (dict, list)):
915
+ visit(item, f"{prefix}.{index}" if prefix else str(index))
916
+
917
+ visit(payload)
918
+ return titles
919
+
920
+
921
+ def _looks_like_title_key(key: str) -> bool:
922
+ lower = key.lower()
923
+ return any(hint in lower for hint in TITLE_KEY_HINTS)
924
+
925
+
926
+ def _collect_artifact_state(payload: dict[str, Any]) -> dict[str, str]:
927
+ state: dict[str, str] = {}
928
+
929
+ def visit(value: Any, prefix: str = "") -> None:
930
+ if len(state) >= 80:
931
+ return
932
+ if isinstance(value, dict):
933
+ for child_key, child_value in value.items():
934
+ key = str(child_key)
935
+ name = f"{prefix}.{key}" if prefix else key
936
+ is_hash_key = _looks_like_hash_key(key)
937
+ if key in ARTIFACT_STATE_KEYS or is_hash_key:
938
+ clean = str(child_value or "").strip()
939
+ if clean and len(clean) <= 160:
940
+ state[name] = clean if is_hash_key else redact_snippet(clean, max_chars=160)
941
+ if isinstance(child_value, (dict, list)):
942
+ visit(child_value, name)
943
+ elif isinstance(value, list):
944
+ for index, item in enumerate(value[:20]):
945
+ if isinstance(item, (dict, list)):
946
+ visit(item, f"{prefix}.{index}" if prefix else str(index))
947
+
948
+ visit(payload)
949
+ return state
950
+
951
+
952
+ def _looks_like_hash_key(key: str) -> bool:
953
+ lower = key.lower()
954
+ return any(hint in lower for hint in HASH_KEY_HINTS)
955
+
956
+
957
+ def _signals_from_payload(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
958
+ signals: set[str] = set()
959
+ blocked_reason = str(summary.get("blocked_reason") or "")
960
+ status = str(summary.get("status") or "")
961
+ search_text = " ".join(
962
+ [
963
+ blocked_reason,
964
+ str(summary.get("next_action") or ""),
965
+ " ".join(str(item) for item in summary.get("errors", [])),
966
+ " ".join(str(item) for item in summary.get("warnings", [])),
967
+ ]
968
+ ).lower()
969
+ if blocked_reason:
970
+ signals.add(f"blocked:{blocked_reason}")
971
+ raw_blocked_items = payload.get("blocked_items")
972
+ if isinstance(raw_blocked_items, list):
973
+ for item in raw_blocked_items[:20]:
974
+ if isinstance(item, dict):
975
+ code = _code_slug(item.get("blocked_reason") or "")
976
+ if code:
977
+ signals.add(f"blocked:{code}")
978
+ if "coverage_path" in search_text and status in {"blocked", "failed"}:
979
+ signals.add("blocked:coverage_path_missing")
980
+ signals.add("required_input:coverage_path")
981
+ if summary.get("human_decision_required"):
982
+ signals.add("human_decision_required")
983
+ if status in {"blocked", "failed"}:
984
+ for item in summary.get("required_inputs", []):
985
+ signals.add(f"required_input:{item}")
986
+ if status in {"blocked", "failed", "completed_with_warnings"} and not summary.get("next_action"):
987
+ signals.add("missing_next_action")
988
+ if summary.get("warnings"):
989
+ signals.add("warnings")
990
+ if summary.get("errors"):
991
+ signals.add("errors")
992
+ model_validation = payload.get("model_validation")
993
+ if isinstance(model_validation, dict) and model_validation.get("ok") is False:
994
+ signals.add("anki_model_validation_failed")
995
+ if payload.get("requires_reprocess_confirmation"):
996
+ signals.add("flashcards_reprocess_confirmation")
997
+ if payload.get("dry_run") is True:
998
+ signals.add("dry_run")
999
+ if int(summary.get("counts", {}).get("blocker_count", 0) or 0):
1000
+ signals.add("blocked:graph_blockers")
1001
+ for event in _normalized_agent_events(payload):
1002
+ event_type = event.get("type")
1003
+ if event_type:
1004
+ signals.add(f"agent.{event_type}")
1005
+ if _environment_blocker_context(payload, summary):
1006
+ signals.add(ENVIRONMENT_BLOCKER_CODE)
1007
+ return sorted(signals)
1008
+
1009
+
1010
+ def build_diagnostic_context(payload: Any, summary: dict[str, Any] | None = None) -> dict[str, Any]:
1011
+ """Build a compact, redacted explanation of what likely needs attention."""
1012
+ if not isinstance(payload, dict):
1013
+ payload = {}
1014
+ summary = summary or summarize_payload(payload)
1015
+ decision_context = _decision_context(payload, summary)
1016
+ blocker_context = _blocker_context(payload, summary)
1017
+ agent_behavior_context = _agent_behavior_context(payload)
1018
+ environment_blocker_context = _environment_blocker_context(payload, summary)
1019
+ error_ctx = _normalized_error_context(payload.get("error_context"))
1020
+ if environment_blocker_context and not error_ctx:
1021
+ error_ctx = _environment_error_context(payload, summary, environment_blocker_context)
1022
+ retry_governance = _retry_governance_context(payload, summary, error_ctx)
1023
+ missing_inputs = _missing_inputs(payload, summary)
1024
+ contract_gaps = _contract_gaps(summary, decision_context)
1025
+ root_cause_code, root_cause_label = _root_cause(
1026
+ payload,
1027
+ summary,
1028
+ decision_context=decision_context,
1029
+ blocker_context=blocker_context,
1030
+ environment_blocker_context=environment_blocker_context,
1031
+ missing_inputs=missing_inputs,
1032
+ contract_gaps=contract_gaps,
1033
+ )
1034
+ recovery_command = _recovery_command(payload, summary, root_cause_code, decision_context, blocker_context)
1035
+ context = {
1036
+ "root_cause_code": root_cause_code,
1037
+ "root_cause_label": root_cause_label,
1038
+ "recovery_command": recovery_command,
1039
+ "missing_inputs": missing_inputs,
1040
+ "decision_context": decision_context,
1041
+ "blocker_context": blocker_context,
1042
+ "environment_blocker_context": environment_blocker_context,
1043
+ "agent_behavior_context": agent_behavior_context,
1044
+ "retry_governance": retry_governance,
1045
+ "contract_gaps": contract_gaps,
1046
+ }
1047
+ if error_ctx:
1048
+ context["error_context"] = error_ctx
1049
+ return context
1050
+
1051
+
1052
+ def _normalized_public_report(value: Any, *, depth: int = 0) -> Any:
1053
+ if depth > 4:
1054
+ return redact_snippet(value)
1055
+ if isinstance(value, str):
1056
+ return redact_snippet(value, max_chars=700)
1057
+ if isinstance(value, bool) or value is None:
1058
+ return value
1059
+ if isinstance(value, (int, float)):
1060
+ return value
1061
+ if isinstance(value, list):
1062
+ return [_normalized_public_report(item, depth=depth + 1) for item in value[:30]]
1063
+ if isinstance(value, dict):
1064
+ clean: dict[str, Any] = {}
1065
+ for key, item in value.items():
1066
+ clean[str(key)] = _normalized_public_report(item, depth=depth + 1)
1067
+ return clean
1068
+ return redact_snippet(value)
1069
+
1070
+
1071
+ def _materialized_agent_directive(payload: dict[str, Any]) -> dict[str, Any]:
1072
+ directive = payload.get("agent_directive")
1073
+ if not isinstance(directive, dict):
1074
+ return {}
1075
+ normalized = _normalized_public_report(directive)
1076
+ return normalized if isinstance(normalized, dict) else {}
1077
+
1078
+
1079
+ def _normalized_error_context(value: Any) -> dict[str, Any]:
1080
+ if not isinstance(value, dict):
1081
+ return {}
1082
+ required = (
1083
+ "phase",
1084
+ "blocked_reason",
1085
+ "root_cause",
1086
+ "affected_artifact",
1087
+ "error_summary",
1088
+ "suggested_fix",
1089
+ "next_action",
1090
+ "retry_scope",
1091
+ )
1092
+ context: dict[str, Any] = {}
1093
+ for key in required:
1094
+ text = str(value.get(key) or "").strip()
1095
+ if text:
1096
+ context[key] = (
1097
+ _redact_operational_identifier(text, max_chars=120)
1098
+ if key in {"phase", "retry_scope"}
1099
+ else redact_snippet(text, max_chars=500)
1100
+ )
1101
+ for key in ("affected_items", "missing_inputs"):
1102
+ items = value.get(key)
1103
+ if isinstance(items, list):
1104
+ clean = [redact_snippet(item, max_chars=160) for item in items if str(item).strip()]
1105
+ if clean:
1106
+ context[key] = clean[:20]
1107
+ for key in ("max_attempts", "attempt_index"):
1108
+ if key in value:
1109
+ try:
1110
+ context[key] = int(value[key])
1111
+ except (TypeError, ValueError):
1112
+ pass
1113
+ if "human_decision_required" in value:
1114
+ context["human_decision_required"] = bool(value.get("human_decision_required"))
1115
+ if not all(key in context for key in required):
1116
+ return {}
1117
+ return context
1118
+
1119
+
1120
+ def _prompt_hardening_context(
1121
+ payload: dict[str, Any],
1122
+ summary: dict[str, Any],
1123
+ *,
1124
+ workflow: str,
1125
+ command: str,
1126
+ ) -> dict[str, Any]:
1127
+ error_context = _normalized_error_context(payload.get("error_context"))
1128
+ evidence_field_candidates = (
1129
+ "diagnosis_path",
1130
+ "diagnosis",
1131
+ "db_path",
1132
+ "manifest_path",
1133
+ "manifest",
1134
+ "plan_path",
1135
+ "receipt_path",
1136
+ "receipt",
1137
+ "dry_run_receipt_path",
1138
+ "dry_run",
1139
+ "output_path",
1140
+ )
1141
+ evidence_fields = [
1142
+ field
1143
+ for field in evidence_field_candidates
1144
+ if payload.get(field) not in (None, "", [], {})
1145
+ ]
1146
+ field_values = {
1147
+ "app_version": payload.get("app_version") or payload.get("version"),
1148
+ "workflow": payload.get("workflow") or workflow,
1149
+ "phase": summary.get("phase") or payload.get("phase"),
1150
+ "command": payload.get("command") or command,
1151
+ "blocked_reason": summary.get("blocked_reason") or payload.get("blocked_reason"),
1152
+ "next_action": summary.get("next_action") or payload.get("next_action"),
1153
+ "error_context": error_context,
1154
+ "operational_evidence": evidence_fields,
1155
+ }
1156
+ required_fields = [
1157
+ "app_version",
1158
+ "workflow",
1159
+ "phase",
1160
+ "command",
1161
+ "blocked_reason",
1162
+ "next_action",
1163
+ "error_context",
1164
+ "operational_evidence",
1165
+ ]
1166
+ present_fields = [field for field in required_fields if bool(field_values.get(field))]
1167
+ missing_fields = [field for field in required_fields if field not in present_fields]
1168
+ quality_flags = []
1169
+ if missing_fields:
1170
+ quality_flags.append("prompt_context_incomplete")
1171
+ if "error_context" in missing_fields and str(summary.get("status") or "") in {"blocked", "failed", "error"}:
1172
+ quality_flags.append("missing_error_context")
1173
+ if "operational_evidence" in missing_fields:
1174
+ quality_flags.append("missing_operational_evidence")
1175
+ return {
1176
+ "status": "complete" if not missing_fields else "incomplete",
1177
+ "required_fields": required_fields,
1178
+ "present_fields": present_fields,
1179
+ "missing_fields": missing_fields,
1180
+ "evidence_fields": evidence_fields,
1181
+ "quality_flags": quality_flags,
1182
+ }
1183
+
1184
+
1185
+ def _inherited_feedback_summary_context(payload: dict[str, Any], *, workflow: str, command: str) -> dict[str, Any]:
1186
+ diagnostic = payload.get("diagnostic_context") if isinstance(payload.get("diagnostic_context"), dict) else {}
1187
+ inherited = diagnostic.get("inherited_feedback_context") if isinstance(diagnostic, dict) else {}
1188
+ fields = [
1189
+ "workflow",
1190
+ "phase",
1191
+ "command",
1192
+ "blocked_reason",
1193
+ "next_action",
1194
+ "error_context",
1195
+ "operational_evidence",
1196
+ ]
1197
+ present = [
1198
+ field
1199
+ for field, value in {
1200
+ "workflow": workflow,
1201
+ "phase": payload.get("phase"),
1202
+ "command": command,
1203
+ "blocked_reason": payload.get("blocked_reason"),
1204
+ "next_action": payload.get("next_action"),
1205
+ "error_context": payload.get("error_context"),
1206
+ "operational_evidence": inherited,
1207
+ }.items()
1208
+ if value not in (None, "", [], {})
1209
+ ]
1210
+ return {
1211
+ "status": "inherited_summary",
1212
+ "required_fields": fields,
1213
+ "present_fields": present,
1214
+ "missing_fields": [],
1215
+ "evidence_fields": ["inherited_feedback_context"],
1216
+ "quality_flags": [],
1217
+ }
1218
+
1219
+
1220
+ def _retry_governance_context(
1221
+ payload: dict[str, Any],
1222
+ summary: dict[str, Any],
1223
+ error_context: dict[str, Any],
1224
+ ) -> dict[str, Any]:
1225
+ category = _retry_category(payload, summary, error_context)
1226
+ budget = RETRY_BUDGETS.get(category, {})
1227
+ return {
1228
+ "category": category,
1229
+ "max_attempts": int(budget.get("max_attempts", 1)),
1230
+ "rule": str(budget.get("rule") or "Retry deve seguir next_action e mudar input relevante antes de repetir."),
1231
+ "requires_input_change": category in {"dry_run", "coverage_stage", "triage_correction"},
1232
+ }
1233
+
1234
+
1235
+ def _retry_category(payload: dict[str, Any], summary: dict[str, Any], error_context: dict[str, Any]) -> str:
1236
+ phase = _code_slug(summary.get("phase") or payload.get("phase") or "")
1237
+ retry_scope = _code_slug(error_context.get("retry_scope") or payload.get("retry_scope") or "")
1238
+ blocked_reason = _code_slug(summary.get("blocked_reason") or "")
1239
+ if "rollback" in phase or "rollback" in retry_scope:
1240
+ return "publish_rollback"
1241
+ if payload.get("dry_run") is True or "dry_run" in phase or "dry_run" in retry_scope:
1242
+ return "dry_run"
1243
+ if "rewrite" in phase or "rewrite" in retry_scope or "fix_note" in phase:
1244
+ return "rewrite"
1245
+ if "triage" in phase or "note_plan" in retry_scope or blocked_reason == "note_plan_invalid":
1246
+ return "triage_correction"
1247
+ if "stage" in phase or "coverage" in phase or "coverage" in retry_scope or blocked_reason in {
1248
+ "coverage_invalid",
1249
+ "coverage_path_missing",
1250
+ "provenance_gap",
1251
+ }:
1252
+ return "coverage_stage"
1253
+ return "generic"
1254
+
1255
+
1256
+ def _environment_blocker_context(payload: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
1257
+ codes: list[str] = []
1258
+ samples: list[str] = []
1259
+ status = str(summary.get("status") or payload.get("status") or "").lower()
1260
+ blocked_reason = _code_slug(summary.get("blocked_reason") or payload.get("blocked_reason") or "")
1261
+ problem_status = status in {"blocked", "failed", "error"}
1262
+ explicit_environment = False
1263
+
1264
+ preflight = payload.get("environment_preflight")
1265
+ if isinstance(preflight, dict):
1266
+ preflight_blockers = [
1267
+ _code_slug(item)
1268
+ for item in preflight.get("blockers", [])
1269
+ if str(item).strip()
1270
+ ]
1271
+ if preflight_blockers or _code_slug(preflight.get("blocked_reason") or "") == _code_slug(ENVIRONMENT_BLOCKER_CODE):
1272
+ codes.extend(preflight_blockers or [ENVIRONMENT_BLOCKER_CODE])
1273
+ samples.extend(_environment_preflight_samples(preflight))
1274
+ problem_status = True
1275
+ explicit_environment = True
1276
+
1277
+ if blocked_reason in {
1278
+ _code_slug(ENVIRONMENT_BLOCKER_CODE),
1279
+ "windows_path_or_venv",
1280
+ "uv_unavailable",
1281
+ "python_environment",
1282
+ }:
1283
+ codes.append(blocked_reason)
1284
+ problem_status = True
1285
+ explicit_environment = True
1286
+
1287
+ command_failed = _payload_has_failed_command(payload)
1288
+ if explicit_environment or command_failed:
1289
+ for text in _environment_text_candidates(payload, summary):
1290
+ matched = False
1291
+ for pattern, code in ENVIRONMENT_PATTERN_CODES:
1292
+ if pattern.search(text):
1293
+ codes.append(code)
1294
+ matched = True
1295
+ if matched and len(samples) < MAX_DIAGNOSTIC_ITEMS:
1296
+ samples.append(redact_snippet(text, max_chars=260))
1297
+
1298
+ codes = _dedupe(_code_slug(code) for code in codes if code)
1299
+ if not explicit_environment and not any(code in STRONG_ENVIRONMENT_CODES for code in codes):
1300
+ return {}
1301
+ if not codes:
1302
+ return {}
1303
+
1304
+ next_action = _environment_recovery_action(codes, payload, summary)
1305
+ severity = "high" if problem_status or command_failed else "medium"
1306
+ return {
1307
+ "code": ENVIRONMENT_BLOCKER_CODE,
1308
+ "kind": "windows_path_or_venv",
1309
+ "severity": severity,
1310
+ "codes": codes[:8],
1311
+ "samples": _dedupe(samples)[:MAX_DIAGNOSTIC_ITEMS],
1312
+ "setup_command": "/mednotes:setup",
1313
+ "reset_command": (
1314
+ "scripts\\bootstrap_windows_python_uv.ps1; fallback scripts\\reset_windows_python_uv.ps1 -FullReset"
1315
+ ),
1316
+ "next_action": next_action,
1317
+ }
1318
+
1319
+
1320
+ def _environment_preflight_samples(preflight: dict[str, Any]) -> list[str]:
1321
+ samples: list[str] = []
1322
+ for key in ("next_action", "setup_command", "reset_command"):
1323
+ value = str(preflight.get(key) or "").strip()
1324
+ if value:
1325
+ samples.append(redact_snippet(value, max_chars=260))
1326
+ checks = preflight.get("checks")
1327
+ if isinstance(checks, list):
1328
+ for check in checks[:20]:
1329
+ if not isinstance(check, dict) or check.get("ok") is not False:
1330
+ continue
1331
+ name = str(check.get("name") or "")
1332
+ detail = str(check.get("detail") or "")
1333
+ samples.append(redact_snippet(f"{name}: {detail}", max_chars=260))
1334
+ if len(samples) >= MAX_DIAGNOSTIC_ITEMS:
1335
+ break
1336
+ return samples
1337
+
1338
+
1339
+ def _payload_has_failed_command(payload: dict[str, Any]) -> bool:
1340
+ events = payload.get("command_events")
1341
+ if not isinstance(events, list):
1342
+ return False
1343
+ for event in events[:MAX_COMMAND_EVENTS]:
1344
+ if not isinstance(event, dict):
1345
+ continue
1346
+ status = _code_slug(event.get("status") or "")
1347
+ exit_code = event.get("exit_code")
1348
+ if status in {"failed", "error"} or (isinstance(exit_code, int) and exit_code != 0) or event.get("error"):
1349
+ return True
1350
+ return False
1351
+
1352
+
1353
+ def _environment_text_candidates(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
1354
+ values: list[str] = []
1355
+ values.extend(
1356
+ str(item or "")
1357
+ for item in (
1358
+ summary.get("blocked_reason"),
1359
+ summary.get("next_action"),
1360
+ " ".join(str(item) for item in summary.get("errors", [])),
1361
+ " ".join(str(item) for item in summary.get("warnings", [])),
1362
+ )
1363
+ )
1364
+
1365
+ interesting_keys = {
1366
+ "blocked_reason",
1367
+ "next_action",
1368
+ "error",
1369
+ "errors",
1370
+ "warning",
1371
+ "warnings",
1372
+ "message",
1373
+ "detail",
1374
+ "command",
1375
+ "stdout",
1376
+ "stderr",
1377
+ "output",
1378
+ "stdout_tail",
1379
+ "stderr_tail",
1380
+ "output_tail",
1381
+ "path",
1382
+ "python",
1383
+ "uv_path",
1384
+ "persistent_venv",
1385
+ "platform",
1386
+ }
1387
+
1388
+ def visit(value: Any, key: str = "", depth: int = 0) -> None:
1389
+ if depth > 4 or len(values) >= 80:
1390
+ return
1391
+ if isinstance(value, dict):
1392
+ for child_key, child_value in list(value.items())[:40]:
1393
+ visit(child_value, str(child_key), depth + 1)
1394
+ elif isinstance(value, list):
1395
+ for item in value[:20]:
1396
+ visit(item, key, depth + 1)
1397
+ elif isinstance(value, str):
1398
+ lower = key.lower()
1399
+ if lower in LONG_TEXT_KEYS:
1400
+ return
1401
+ if lower in interesting_keys or any(token in lower for token in ("command", "error", "path", "venv")):
1402
+ values.append(value)
1403
+
1404
+ visit(payload)
1405
+ return _dedupe(text for text in values if str(text).strip())[:80]
1406
+
1407
+
1408
+ def _environment_recovery_action(
1409
+ codes: list[str],
1410
+ payload: dict[str, Any],
1411
+ summary: dict[str, Any],
1412
+ ) -> str:
1413
+ preflight = payload.get("environment_preflight")
1414
+ if isinstance(preflight, dict) and preflight.get("next_action"):
1415
+ return redact_snippet(preflight["next_action"], max_chars=300)
1416
+ value = str(summary.get("next_action") or payload.get("next_action") or "").strip()
1417
+ if value and any(token in _code_slug(value) for token in ("setup", "bootstrap", "reset_windows", "uv_project_environment")):
1418
+ return redact_snippet(value, max_chars=300)
1419
+ windowsish = any(code.startswith(("windows", "powershell", "crlf")) for code in codes)
1420
+ if windowsish:
1421
+ return (
1422
+ "Rodar /mednotes:setup. Se persistir no Windows, executar "
1423
+ "scripts\\bootstrap_windows_python_uv.ps1; como fallback, "
1424
+ "scripts\\reset_windows_python_uv.ps1 -FullReset. Nao editar scripts/runbooks como workaround."
1425
+ )
1426
+ return (
1427
+ "Rodar /mednotes:setup, configurar UV_PROJECT_ENVIRONMENT para a venv persistente, "
1428
+ "executar uv sync e repetir o workflow sem editar scripts/runbooks como workaround."
1429
+ )
1430
+
1431
+
1432
+ def _environment_error_context(
1433
+ payload: dict[str, Any],
1434
+ summary: dict[str, Any],
1435
+ environment_blocker_context: dict[str, Any],
1436
+ ) -> dict[str, Any]:
1437
+ codes = environment_blocker_context.get("codes") if isinstance(environment_blocker_context.get("codes"), list) else []
1438
+ samples = environment_blocker_context.get("samples") if isinstance(environment_blocker_context.get("samples"), list) else []
1439
+ error_summary = "Ambiente/path/venv bloqueou a execucao."
1440
+ if codes:
1441
+ error_summary = f"Ambiente/path/venv bloqueou a execucao: {', '.join(str(code) for code in codes[:5])}."
1442
+ if samples:
1443
+ error_summary += f" Amostra: {samples[0]}"
1444
+ context = {
1445
+ "phase": summary.get("phase") or payload.get("phase") or "environment",
1446
+ "blocked_reason": ENVIRONMENT_BLOCKER_CODE,
1447
+ "root_cause": "Preflight ou console indicou problema de Python, uv, venv persistente, PowerShell ou path Windows.",
1448
+ "affected_artifact": ", ".join(str(code) for code in codes[:5]) or "python/uv/persistent_venv/path",
1449
+ "error_summary": error_summary,
1450
+ "suggested_fix": "Corrigir setup/venv/path pelo comando oficial; nao reescrever scripts, prompts ou runbooks para contornar ambiente.",
1451
+ "next_action": environment_blocker_context.get("next_action") or _environment_recovery_action(codes, payload, summary),
1452
+ "retry_scope": "setup_reset_then_retry",
1453
+ "missing_inputs": ["python", "uv", "persistent_venv", "wiki_dir"],
1454
+ "human_decision_required": False,
1455
+ }
1456
+ return _normalized_error_context(context)
1457
+
1458
+
1459
+ def _agent_behavior_context(payload: dict[str, Any]) -> dict[str, Any]:
1460
+ events = _normalized_agent_events(payload)
1461
+ type_counts = Counter(str(event.get("type") or "unknown") for event in events)
1462
+ severity_counts = Counter(str(event.get("severity") or "low") for event in events)
1463
+ codes = _dedupe(str(event.get("code") or "") for event in events if event.get("code"))
1464
+ return {
1465
+ "event_count": len(events),
1466
+ "types": dict(type_counts),
1467
+ "severities": dict(severity_counts),
1468
+ "highest_severity": _highest_agent_event_severity(events),
1469
+ "codes": codes[:8],
1470
+ "samples": events[:MAX_AGENT_EVENT_SAMPLES],
1471
+ }
1472
+
1473
+
1474
+ def _normalized_agent_events(payload: dict[str, Any]) -> list[dict[str, Any]]:
1475
+ raw_events = payload.get("agent_events")
1476
+ if not isinstance(raw_events, list):
1477
+ return []
1478
+ events: list[dict[str, Any]] = []
1479
+ for item in raw_events[:MAX_AGENT_EVENTS]:
1480
+ if not isinstance(item, dict):
1481
+ continue
1482
+ event_type = _code_slug(item.get("type") or "unknown") or "unknown"
1483
+ severity = _normalize_severity(item.get("severity"))
1484
+ event = {
1485
+ "type": event_type,
1486
+ "code": f"agent.{event_type}",
1487
+ "phase": _redact_operational_identifier(item.get("phase") or payload.get("phase") or "", max_chars=80),
1488
+ "severity": severity,
1489
+ "summary": redact_snippet(item.get("summary") or "", max_chars=220),
1490
+ "action": redact_snippet(item.get("action") or "", max_chars=220),
1491
+ "target_kind": _code_slug(item.get("target_kind") or ""),
1492
+ "result": _code_slug(item.get("result") or ""),
1493
+ }
1494
+ optional_text = {
1495
+ "expected_phase": _redact_operational_identifier(item.get("expected_phase") or "", max_chars=80),
1496
+ "actual_phase": _redact_operational_identifier(
1497
+ item.get("actual_phase") or item.get("executed_phase") or "",
1498
+ max_chars=80,
1499
+ ),
1500
+ "executed_action": redact_snippet(item.get("executed_action") or item.get("actual_action") or "", max_chars=220),
1501
+ "command_family": _code_slug(item.get("command_family") or ""),
1502
+ "blocked_reason": _code_slug(item.get("blocked_reason") or ""),
1503
+ "next_action_expected": redact_snippet(
1504
+ item.get("next_action_expected") or item.get("expected_next_action") or "",
1505
+ max_chars=220,
1506
+ ),
1507
+ "snippet": redact_snippet(item.get("snippet") or "", max_chars=220),
1508
+ }
1509
+ for key, value in optional_text.items():
1510
+ if value:
1511
+ event[key] = value
1512
+ if item.get("path"):
1513
+ event["path"] = _compact_path_label(str(item.get("path")))
1514
+ events.append(event)
1515
+ return events
1516
+
1517
+
1518
+ def _normalize_severity(value: Any) -> str:
1519
+ severity = _code_slug(value or "low")
1520
+ if severity in {"high", "medium", "low"}:
1521
+ return severity
1522
+ return "low"
1523
+
1524
+
1525
+ def _highest_agent_event_severity(events: list[dict[str, Any]]) -> str:
1526
+ highest = "low"
1527
+ for event in events:
1528
+ severity = str(event.get("severity") or "low")
1529
+ if _severity_rank(severity) > _severity_rank(highest):
1530
+ highest = severity
1531
+ return highest if events else ""
1532
+
1533
+
1534
+ def _with_derived_agent_events(
1535
+ payload: dict[str, Any],
1536
+ summary: dict[str, Any],
1537
+ error_context: dict[str, Any],
1538
+ *,
1539
+ source: str,
1540
+ ) -> dict[str, Any]:
1541
+ if source != "agent":
1542
+ return dict(payload)
1543
+ derived = _derived_agent_events(payload, summary, error_context)
1544
+ if not derived:
1545
+ return dict(payload)
1546
+ enriched = dict(payload)
1547
+ existing = payload.get("agent_events")
1548
+ events = list(existing) if isinstance(existing, list) else []
1549
+ existing_types = {
1550
+ _code_slug(item.get("type") or "")
1551
+ for item in events
1552
+ if isinstance(item, dict)
1553
+ }
1554
+ for event in derived:
1555
+ if _code_slug(event.get("type") or "") not in existing_types:
1556
+ events.append(event)
1557
+ existing_types.add(_code_slug(event.get("type") or ""))
1558
+ enriched["agent_events"] = events
1559
+ return enriched
1560
+
1561
+
1562
+ def _agent_timeout_or_max_turns_detected(payload: dict[str, Any], *, blocked_reason: str) -> bool:
1563
+ candidates = [
1564
+ blocked_reason,
1565
+ payload.get("blocked_reason"),
1566
+ payload.get("error"),
1567
+ payload.get("error_summary"),
1568
+ payload.get("root_cause"),
1569
+ payload.get("stop_condition"),
1570
+ payload.get("failure_reason"),
1571
+ ]
1572
+ for value in candidates:
1573
+ slug = _code_slug(value or "")
1574
+ if "timeout" in slug or "max_turns" in slug or "turn_budget" in slug:
1575
+ return True
1576
+ metrics = payload.get("agent_metrics")
1577
+ if isinstance(metrics, dict):
1578
+ turns_used = _safe_int(metrics.get("turns_used"))
1579
+ max_turns = _safe_int(metrics.get("max_turns"))
1580
+ if max_turns and turns_used >= max_turns:
1581
+ return True
1582
+ return False
1583
+
1584
+
1585
+ def _derived_agent_events(
1586
+ payload: dict[str, Any],
1587
+ summary: dict[str, Any],
1588
+ error_context: dict[str, Any],
1589
+ ) -> list[dict[str, Any]]:
1590
+ events: list[dict[str, Any]] = []
1591
+ status = str(summary.get("status") or "")
1592
+ phase = str(summary.get("phase") or payload.get("phase") or "")
1593
+ blocked_reason = str(summary.get("blocked_reason") or "")
1594
+ next_action = str(summary.get("next_action") or "")
1595
+ executed_action = str(
1596
+ payload.get("executed_action")
1597
+ or payload.get("actual_action")
1598
+ or payload.get("agent_action")
1599
+ or payload.get("command")
1600
+ or ""
1601
+ )
1602
+ expected_next_action = str(payload.get("expected_next_action") or payload.get("next_action_expected") or next_action)
1603
+ expected_phase = str(payload.get("expected_phase") or payload.get("allowed_phase") or payload.get("expected_next_phase") or "")
1604
+ actual_phase = str(payload.get("actual_phase") or payload.get("executed_phase") or phase)
1605
+ timeout_or_max_turns = status in {"blocked", "failed", "error"} and _agent_timeout_or_max_turns_detected(
1606
+ payload,
1607
+ blocked_reason=blocked_reason,
1608
+ )
1609
+
1610
+ if expected_phase and actual_phase and _code_slug(expected_phase) != _code_slug(actual_phase):
1611
+ events.append(
1612
+ {
1613
+ "type": "wrong_phase",
1614
+ "phase": actual_phase,
1615
+ "expected_phase": expected_phase,
1616
+ "actual_phase": actual_phase,
1617
+ "severity": "high",
1618
+ "summary": f"Agente executou fase {actual_phase} quando a fase esperada era {expected_phase}.",
1619
+ "action": "Voltar para a fase esperada antes de mutar qualquer artefato.",
1620
+ "target_kind": "workflow",
1621
+ "result": "blocked",
1622
+ "expected_next_action": expected_next_action,
1623
+ "executed_action": executed_action,
1624
+ }
1625
+ )
1626
+
1627
+ if expected_next_action and executed_action and not _actions_are_compatible(expected_next_action, executed_action):
1628
+ events.append(
1629
+ {
1630
+ "type": "ignored_next_action",
1631
+ "phase": phase,
1632
+ "severity": "high" if status in {"blocked", "failed", "error"} else "medium",
1633
+ "summary": "Agente executou ação fora da rota indicada por next_action.",
1634
+ "action": "Interromper retry e seguir a next_action esperada.",
1635
+ "target_kind": "workflow",
1636
+ "result": status or "detected",
1637
+ "expected_next_action": expected_next_action,
1638
+ "executed_action": executed_action,
1639
+ "blocked_reason": blocked_reason,
1640
+ }
1641
+ )
1642
+
1643
+ if timeout_or_max_turns:
1644
+ events.append(
1645
+ {
1646
+ "type": "timeout_or_max_turns",
1647
+ "phase": phase,
1648
+ "severity": "high",
1649
+ "summary": "Subagente excedeu timeout ou max_turns antes de entregar output aplicavel.",
1650
+ "action": next_action
1651
+ or "Parar retry cego, registrar blocked packet com error_context e reduzir o escopo do work item.",
1652
+ "target_kind": "subagent",
1653
+ "result": status,
1654
+ "blocked_reason": blocked_reason or "timeout_or_max_turns",
1655
+ "expected_next_action": next_action,
1656
+ }
1657
+ )
1658
+
1659
+ if status in {"blocked", "failed", "error"} and blocked_reason:
1660
+ events.append(
1661
+ {
1662
+ "type": "workflow_blocked",
1663
+ "phase": phase,
1664
+ "severity": "medium" if next_action else "high",
1665
+ "summary": f"Workflow parou em {blocked_reason}.",
1666
+ "action": next_action or "Adicionar next_action/error_context antes de repetir.",
1667
+ "target_kind": "workflow",
1668
+ "result": status,
1669
+ "blocked_reason": blocked_reason,
1670
+ "expected_next_action": next_action,
1671
+ }
1672
+ )
1673
+
1674
+ if timeout_or_max_turns and not isinstance(payload.get("agent_metrics"), dict):
1675
+ events.append(
1676
+ {
1677
+ "type": "missing_agent_metrics",
1678
+ "phase": phase,
1679
+ "severity": "high",
1680
+ "summary": "Subagente bloqueado por timeout/max_turns sem agent_metrics estruturado.",
1681
+ "action": "Exigir agent_metrics no blocked packet antes de reexecutar ou comparar baseline de prompt.",
1682
+ "target_kind": "subagent",
1683
+ "result": status,
1684
+ "blocked_reason": blocked_reason or "timeout_or_max_turns",
1685
+ "expected_next_action": next_action,
1686
+ }
1687
+ )
1688
+
1689
+ if status in {"blocked", "failed", "error"} and not error_context:
1690
+ events.append(
1691
+ {
1692
+ "type": "missing_error_context",
1693
+ "phase": phase,
1694
+ "severity": "high",
1695
+ "summary": "Run agentico bloqueou/falhou sem error_context estruturado.",
1696
+ "action": "Registrar error_context com causa, artefato afetado, retry_scope e next_action antes de tentar novamente.",
1697
+ "target_kind": "workflow",
1698
+ "result": status,
1699
+ "blocked_reason": blocked_reason,
1700
+ "expected_next_action": next_action,
1701
+ }
1702
+ )
1703
+
1704
+ if payload.get("manual_intervention") or payload.get("manual_intervention_required"):
1705
+ events.append(
1706
+ {
1707
+ "type": "manual_intervention",
1708
+ "phase": phase,
1709
+ "severity": "medium",
1710
+ "summary": "Run exigiu intervenção manual.",
1711
+ "action": str(payload.get("manual_intervention_action") or next_action or "Registrar decisão humana estruturada."),
1712
+ "target_kind": "workflow",
1713
+ "result": status or "pending",
1714
+ }
1715
+ )
1716
+
1717
+ return events
1718
+
1719
+
1720
+ def _actions_are_compatible(expected: str, executed: str) -> bool:
1721
+ expected_slug = _code_slug(expected)
1722
+ executed_slug = _code_slug(executed)
1723
+ if not expected_slug or not executed_slug:
1724
+ return True
1725
+ expected_command = _command_hint(expected_slug)
1726
+ executed_command = _command_hint(executed_slug)
1727
+ if expected_command and executed_command:
1728
+ return expected_command == executed_command
1729
+ expected_tokens = {token for token in expected_slug.split("_") if len(token) >= 4}
1730
+ executed_tokens = {token for token in executed_slug.split("_") if len(token) >= 4}
1731
+ if not expected_tokens or not executed_tokens:
1732
+ return True
1733
+ return bool(expected_tokens & executed_tokens)
1734
+
1735
+
1736
+ def _command_hint(slug: str) -> str:
1737
+ commands = (
1738
+ "stage_note",
1739
+ "publish_batch",
1740
+ "triage",
1741
+ "plan_subagents",
1742
+ "validate_note",
1743
+ "fix_note",
1744
+ "run_linker",
1745
+ "taxonomy_resolve",
1746
+ "fix_wiki",
1747
+ "apply_style_rewrite",
1748
+ "apply_note_merge",
1749
+ )
1750
+ for command in commands:
1751
+ if command in slug:
1752
+ return command
1753
+ return ""
1754
+
1755
+
1756
+ def _compact_path_label(value: str) -> str:
1757
+ text = redact_snippet(value, max_chars=220).replace(str(Path.home()), "~")
1758
+ parts = [part for part in re.split(r"[\\/]+", text) if part]
1759
+ if text.startswith("~"):
1760
+ prefix = "~"
1761
+ else:
1762
+ prefix = ""
1763
+ label = "/".join(parts[-3:]) if len(parts) > 3 else "/".join(parts) or text
1764
+ return f"{prefix}/{label}" if prefix and not label.startswith("~") else label
1765
+
1766
+
1767
+ def _decision_context(payload: JsonObject, summary: JsonObject) -> JsonObject:
1768
+ decisions: list[JsonObject] = []
1769
+ raw_packets: list[JsonObject] = []
1770
+ human_decision_packet = _json_object_field(payload, "human_decision_packet")
1771
+ if human_decision_packet:
1772
+ raw_packets.append(human_decision_packet)
1773
+ for packet in _json_list_field(payload, "human_decision_packets"):
1774
+ packet_view = _json_object_view(packet)
1775
+ if packet_view:
1776
+ raw_packets.append(packet_view)
1777
+ decision_summary = _json_object_field(payload, "decision_summary")
1778
+ if decision_summary and not bool(_json_value(summary, "human_decision_required")):
1779
+ reason_code = _json_text(decision_summary, "reason_code")
1780
+ next_action = _json_text(payload, "next_action")
1781
+ decisions.append(
1782
+ {
1783
+ "kind": _code_slug(_json_text(decision_summary, "kind")),
1784
+ "type": _code_slug(_json_text(decision_summary, "kind")),
1785
+ "question": redact_snippet(_json_text(decision_summary, "public_summary"), max_chars=240),
1786
+ "options": [],
1787
+ "next_action": redact_snippet(next_action, max_chars=300),
1788
+ "continue_after_choice": redact_snippet(next_action, max_chars=300),
1789
+ "resume_action": redact_snippet(next_action, max_chars=300),
1790
+ "reason_code": reason_code,
1791
+ }
1792
+ )
1793
+ for item in _json_list_field(payload, "blocked_items")[:MAX_DIAGNOSTIC_ITEMS]:
1794
+ item_view = _json_object_view(item)
1795
+ if not item_view:
1796
+ continue
1797
+ blocked_packet = _json_object_field(item_view, "human_decision_packet")
1798
+ if blocked_packet:
1799
+ raw_packets.append(blocked_packet)
1800
+ for packet in _json_list_field(item_view, "human_decision_packets"):
1801
+ packet_view = _json_object_view(packet)
1802
+ if packet_view:
1803
+ raw_packets.append(packet_view)
1804
+ for packet in raw_packets[:MAX_DIAGNOSTIC_ITEMS]:
1805
+ options = []
1806
+ for option in _json_list_field(packet, "options")[:5]:
1807
+ option_view = _json_object_view(option)
1808
+ if option_view:
1809
+ options.append(
1810
+ {
1811
+ "id": redact_snippet(_json_text(option_view, "id"), max_chars=80),
1812
+ "label": redact_snippet(_json_text(option_view, "label"), max_chars=160),
1813
+ }
1814
+ )
1815
+ decisions.append(
1816
+ {
1817
+ "kind": _code_slug(_json_text(packet, "kind") or _json_text(packet, "type") or "manual_review"),
1818
+ "question": redact_snippet(_json_text(packet, "question"), max_chars=240),
1819
+ "options": options,
1820
+ "next_action": redact_snippet(_json_text(packet, "next_action"), max_chars=300),
1821
+ "continue_after_choice": redact_snippet(_json_text(packet, "resume_action"), max_chars=300),
1822
+ }
1823
+ )
1824
+ kinds = [str(item.get("kind") or "manual_review") for item in decisions]
1825
+ if not kinds and bool(_json_value(summary, "human_decision_required")):
1826
+ blocked_reason = _json_text(summary, "blocked_reason") or "manual_review"
1827
+ kinds.append(_code_slug(blocked_reason))
1828
+ return {
1829
+ "types": _dedupe(kinds)[:MAX_DIAGNOSTIC_ITEMS],
1830
+ "decisions": decisions,
1831
+ }
1832
+
1833
+
1834
+ def _blocker_context(payload: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
1835
+ codes: list[str] = []
1836
+ summaries: list[dict[str, Any]] = []
1837
+ raw_summary = payload.get("blocker_summary")
1838
+ decision_summary = payload.get("decision_summary")
1839
+ if isinstance(decision_summary, dict) and decision_summary.get("reason_code"):
1840
+ codes.append(_code_slug(decision_summary.get("reason_code")))
1841
+ if isinstance(raw_summary, list):
1842
+ for item in raw_summary[:MAX_DIAGNOSTIC_ITEMS]:
1843
+ if not isinstance(item, dict):
1844
+ continue
1845
+ code = _code_slug(item.get("code") or item.get("kind") or "unknown")
1846
+ codes.append(code)
1847
+ summaries.append(
1848
+ {
1849
+ "code": code,
1850
+ "count": _safe_int(item.get("count")),
1851
+ "message": redact_snippet(item.get("message") or item.get("reason") or "", max_chars=220),
1852
+ }
1853
+ )
1854
+
1855
+ samples: list[Any] = []
1856
+ raw_samples = payload.get("blockers_sample")
1857
+ if isinstance(raw_samples, list):
1858
+ for item in raw_samples[:MAX_DIAGNOSTIC_ITEMS]:
1859
+ if isinstance(item, dict) and item.get("code"):
1860
+ codes.append(_code_slug(item.get("code")))
1861
+ samples.append(_compact_diagnostic_value(item))
1862
+
1863
+ routes: list[dict[str, Any]] = []
1864
+ raw_blocked_items = payload.get("blocked_items")
1865
+ if isinstance(raw_blocked_items, list):
1866
+ for item in raw_blocked_items[:MAX_DIAGNOSTIC_ITEMS]:
1867
+ if not isinstance(item, dict):
1868
+ continue
1869
+ code = _code_slug(item.get("blocked_reason") or "")
1870
+ if code:
1871
+ codes.append(code)
1872
+ summaries.append(
1873
+ {
1874
+ "code": code,
1875
+ "count": 1,
1876
+ "message": redact_snippet(item.get("reason") or item.get("next_action") or "", max_chars=220),
1877
+ }
1878
+ )
1879
+ if item.get("next_action"):
1880
+ routes.append(
1881
+ {
1882
+ "route": code,
1883
+ "count": 1,
1884
+ "automatic": False,
1885
+ "reason": redact_snippet(item.get("reason") or "", max_chars=240),
1886
+ "next_action": redact_snippet(item.get("next_action") or "", max_chars=300),
1887
+ }
1888
+ )
1889
+
1890
+ blocker_resolution = payload.get("blocker_resolution")
1891
+ if isinstance(blocker_resolution, dict):
1892
+ raw_groups = blocker_resolution.get("groups")
1893
+ if isinstance(raw_groups, list):
1894
+ for group in raw_groups[:MAX_DIAGNOSTIC_ITEMS]:
1895
+ if not isinstance(group, dict):
1896
+ continue
1897
+ route = _code_slug(group.get("route") or "unknown")
1898
+ codes.append(route)
1899
+ for code in group.get("codes") or []:
1900
+ codes.append(_code_slug(code))
1901
+ routes.append(
1902
+ {
1903
+ "route": route,
1904
+ "count": _safe_int(group.get("count")),
1905
+ "automatic": bool(group.get("automatic", False)),
1906
+ "reason": redact_snippet(group.get("reason") or "", max_chars=240),
1907
+ "next_action": redact_snippet(group.get("next_action") or "", max_chars=300),
1908
+ }
1909
+ )
1910
+
1911
+ counts: dict[str, int | float] = {}
1912
+ summary_counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
1913
+ for key, value in summary_counts.items():
1914
+ leaf = str(key).split(".")[-1]
1915
+ if leaf in {"blocker_count", "graph_error_count", "error_count", "warning_count"}:
1916
+ counts[str(key)] = value
1917
+
1918
+ return {
1919
+ "codes": _dedupe([code for code in codes if code and code != "unknown"])[:8],
1920
+ "counts": counts,
1921
+ "summaries": summaries,
1922
+ "samples": samples,
1923
+ "routes": routes,
1924
+ }
1925
+
1926
+
1927
+ def _missing_inputs(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
1928
+ missing: list[str] = []
1929
+ for key in ("missing_inputs", "required_inputs_missing"):
1930
+ value = payload.get(key)
1931
+ if isinstance(value, list):
1932
+ missing.extend(str(item) for item in value if str(item).strip())
1933
+ text = " ".join(
1934
+ [
1935
+ str(summary.get("blocked_reason") or ""),
1936
+ str(summary.get("next_action") or ""),
1937
+ " ".join(str(item) for item in summary.get("errors", [])),
1938
+ " ".join(str(item) for item in summary.get("warnings", [])),
1939
+ ]
1940
+ ).lower()
1941
+ if "coverage_path" in text:
1942
+ missing.append("coverage_path")
1943
+ required_inputs = summary.get("required_inputs") if isinstance(summary.get("required_inputs"), list) else []
1944
+ if "coverage_path" in required_inputs and str(summary.get("status") or "") in {"blocked", "failed"} and "coverage" in text:
1945
+ missing.append("coverage_path")
1946
+ return _dedupe(_code_slug(item) for item in missing)
1947
+
1948
+
1949
+ def _contract_gaps(summary: JsonObject, decision_context: JsonObject) -> list[str]:
1950
+ gaps: list[str] = []
1951
+ status = str(summary.get("status") or "")
1952
+ statuses_requiring_next_action = {
1953
+ "blocked",
1954
+ "failed",
1955
+ "completed_with_warnings",
1956
+ "preview_ready",
1957
+ "ready_to_publish",
1958
+ "published",
1959
+ "completed_with_link_blockers",
1960
+ }
1961
+ if status in statuses_requiring_next_action and not summary.get("next_action"):
1962
+ gaps.append("missing_next_action")
1963
+ if status in {"blocked", "failed"} and not summary.get("blocked_reason"):
1964
+ gaps.append("empty_blocked_reason")
1965
+ if summary.get("human_decision_required") and not decision_context.get("decisions"):
1966
+ gaps.append("missing_human_decision_packet")
1967
+ return gaps
1968
+
1969
+
1970
+ def _root_cause(
1971
+ payload: JsonObject,
1972
+ summary: JsonObject,
1973
+ *,
1974
+ decision_context: JsonObject,
1975
+ blocker_context: JsonObject,
1976
+ environment_blocker_context: JsonObject,
1977
+ missing_inputs: list[str],
1978
+ contract_gaps: list[str],
1979
+ ) -> tuple[str, str]:
1980
+ blocked_reason = _code_slug(summary.get("blocked_reason") or "")
1981
+ status = str(summary.get("status") or "")
1982
+ if blocked_reason == "contract_gap_missing_next_action":
1983
+ return CONTRACT_GAP_MISSING_NEXT_ACTION, "Workflow bloqueado sem próximo passo"
1984
+ if environment_blocker_context:
1985
+ return ENVIRONMENT_BLOCKER_CODE, "Bloqueio de ambiente Windows/path/venv"
1986
+ if blocked_reason == "batch_state_mismatch":
1987
+ return "batch_state_mismatch", "Artefatos incompatíveis entre fases do processamento de chats"
1988
+ if blocked_reason == "coverage_invalid":
1989
+ return "coverage_invalid", "Coverage inválida no processamento de chats"
1990
+ if blocked_reason == "provenance_gap":
1991
+ return "provenance_gap", "Proveniência multi-fonte incompleta no processamento de chats"
1992
+ if "coverage_path" in missing_inputs or blocked_reason == "coverage_path_missing":
1993
+ return "coverage_path_missing", "Coverage ausente no processamento de chats"
1994
+ if blocked_reason == "note_plan_invalid":
1995
+ return "note_plan_invalid", "Plano de triagem inválido no processamento de chats"
1996
+ if blocked_reason == "manifest_invalid":
1997
+ return "manifest_invalid", "Manifest inválido no processamento de chats"
1998
+ if blocked_reason == "dry_run_receipt_invalid":
1999
+ return "dry_run_receipt_invalid", "Recibo de dry-run ausente ou incompatível"
2000
+ if blocked_reason == "taxonomy_resolution_required":
2001
+ return "taxonomy_resolution_required", "Taxonomia precisa de resolução antes de avançar"
2002
+ blocker_codes = set(blocker_context.get("codes") or [])
2003
+ if "canonical_merge_required" in blocker_codes:
2004
+ return "canonical_merge_required", "Merge canônico necessário antes de publicar"
2005
+ if "human_decision_required_ambiguous_canonical_target" in blocker_codes:
2006
+ return (
2007
+ "human_decision_required.ambiguous_canonical_target",
2008
+ "Decisão humana: escolher alvo canônico",
2009
+ )
2010
+ if blocked_reason == "human_decision_required" or decision_context.get("decisions"):
2011
+ kind = _first_or_default(decision_context.get("types"), "manual_review")
2012
+ code = f"human_decision_required.{_code_slug(kind)}"
2013
+ return code, _human_decision_label(kind)
2014
+ model_validation = _json_object_field(payload, "model_validation")
2015
+ if _json_value(model_validation, "ok") is False:
2016
+ return "anki_model_validation_failed", "Modelo Anki bloqueou criação de cards"
2017
+ counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
2018
+ if blocked_reason == "graph_blockers" or int(counts.get("blocker_count", 0) or 0) or blocker_context.get("codes"):
2019
+ code = _first_or_default(blocker_context.get("codes"), "unknown")
2020
+ if code and code != "unknown":
2021
+ return f"graph_blockers.{_code_slug(code)}", _graph_blocker_label(code)
2022
+ return "graph_blockers", "Blockers de grafo recorrentes"
2023
+ if "missing_next_action" in contract_gaps:
2024
+ return "contract_gap.missing_next_action", "Workflow bloqueado sem próximo passo"
2025
+ if blocked_reason:
2026
+ return f"blocked.{blocked_reason}", f"Bloqueio recorrente: {blocked_reason}"
2027
+ if status in {"blocked", "failed", "error"}:
2028
+ return f"status.{_code_slug(status)}", f"Run terminou como {status}"
2029
+ return "no_issue_detected", "Nenhum padrão de falha detectado"
2030
+
2031
+
2032
+ def _recovery_command(
2033
+ payload: JsonObject,
2034
+ summary: JsonObject,
2035
+ root_cause_code: str,
2036
+ decision_context: JsonObject,
2037
+ blocker_context: JsonObject,
2038
+ ) -> str:
2039
+ for decision in _json_list_field(decision_context, "decisions"):
2040
+ decision_view = _json_object_view(decision)
2041
+ value = _json_text(decision_view, "continue_after_choice") or _json_text(decision_view, "next_action")
2042
+ if value:
2043
+ return redact_snippet(value, max_chars=300)
2044
+ for route in _json_list_field(blocker_context, "routes"):
2045
+ route_view = _json_object_view(route)
2046
+ value = _json_text(route_view, "next_action")
2047
+ if value:
2048
+ return redact_snippet(value, max_chars=300)
2049
+ if root_cause_code == ENVIRONMENT_BLOCKER_CODE:
2050
+ context = _environment_blocker_context(payload, summary)
2051
+ if context.get("next_action"):
2052
+ return redact_snippet(context["next_action"], max_chars=300)
2053
+ value = summary.get("next_action") or payload.get("next_command") or ""
2054
+ if value:
2055
+ return redact_snippet(value, max_chars=300)
2056
+ if root_cause_code == "coverage_path_missing":
2057
+ return "Gerar coverage_path a partir do note_plan, repetir stage-note --coverage <coverage.json> e depois publish-batch --dry-run."
2058
+ if root_cause_code == "note_plan_invalid":
2059
+ return "Corrigir o note_plan conforme triage-note-plan.v2 e repetir somente triage --note-plan antes de architect/stage/publish."
2060
+ if root_cause_code == "coverage_invalid":
2061
+ return "Corrigir ou regenerar coverage a partir do note_plan e repetir stage-note --coverage antes do publish-batch --dry-run."
2062
+ if root_cause_code == "provenance_gap":
2063
+ return "Completar coverage.sources e as Fontes Consolidadas da nota canônica antes de repetir stage-note/publish-batch --dry-run."
2064
+ if root_cause_code == "batch_state_mismatch":
2065
+ return "Regenerar coverage, manifest e dry-run a partir do note_plan atual antes de avançar."
2066
+ if root_cause_code == "manifest_invalid":
2067
+ return "Regenerar manifest via stage-note --coverage e repetir publish-batch --dry-run."
2068
+ if root_cause_code == "dry_run_receipt_invalid":
2069
+ return "Rodar publish-batch --dry-run com o mesmo manifest/opções antes do publish real."
2070
+ if root_cause_code == "taxonomy_resolution_required":
2071
+ return "Resolver taxonomia com categoria existente ou decisão explícita antes de repetir a fase."
2072
+ if root_cause_code == "canonical_merge_required":
2073
+ return "Consolidar informação nova no alvo canônico, preservar referências múltiplas e validar antes de aplicar."
2074
+ if root_cause_code == "human_decision_required.ambiguous_canonical_target":
2075
+ return "Escolher explicitamente o alvo canônico, ajustar o note_plan e reexecutar plan-subagents --phase architect."
2076
+ if root_cause_code.startswith("graph_blockers"):
2077
+ return "Rodar /mednotes:fix-wiki --dry-run para obter a rota segura antes de aplicar o linker."
2078
+ return ""
2079
+
2080
+
2081
+ def _human_decision_label(kind: str) -> str:
2082
+ labels = {
2083
+ "note_merge_required": "Decisão humana: fundir ou separar notas com identidade semântica confirmada",
2084
+ "taxonomy_review_required": "Decisão humana: revisar taxonomia",
2085
+ "io_retry": "Decisão humana: liberar arquivo e tentar novamente",
2086
+ "manual_review": "Decisão humana pendente",
2087
+ }
2088
+ return labels.get(_code_slug(kind), f"Decisão humana: {_code_slug(kind)}")
2089
+
2090
+
2091
+ def _graph_blocker_label(code: str) -> str:
2092
+ labels = {
2093
+ "duplicate_stem": "Blocker de grafo: notas duplicadas",
2094
+ "dangling_link": "Blocker de grafo: link sem alvo",
2095
+ "self_link": "Blocker de grafo: auto-link",
2096
+ "ambiguous_link": "Blocker de grafo: link ambíguo",
2097
+ "catalog_repair": "Blocker de grafo: catálogo precisa de reparo",
2098
+ "unknown_graph_blocker": "Blocker de grafo sem reparo conhecido",
2099
+ }
2100
+ return labels.get(_code_slug(code), f"Blocker de grafo: {_code_slug(code)}")
2101
+
2102
+
2103
+ def _compact_diagnostic_value(value: Any, *, key: str = "", depth: int = 0) -> Any:
2104
+ if depth > 4:
2105
+ return "[max-depth]"
2106
+ if isinstance(value, dict):
2107
+ out: dict[str, Any] = {}
2108
+ for child_key, child_value in list(value.items())[:16]:
2109
+ lower = str(child_key).lower()
2110
+ if lower in SECRET_KEYS:
2111
+ out[str(child_key)] = "[redacted]"
2112
+ else:
2113
+ out[str(child_key)] = _compact_diagnostic_value(child_value, key=str(child_key), depth=depth + 1)
2114
+ return out
2115
+ if isinstance(value, list):
2116
+ return [_compact_diagnostic_value(item, key=key, depth=depth + 1) for item in value[:MAX_DIAGNOSTIC_ITEMS]]
2117
+ if isinstance(value, str):
2118
+ if key.lower() in LONG_TEXT_KEYS:
2119
+ return redact_snippet(value, max_chars=160)
2120
+ if key.lower() in {"phase", "expected_phase", "actual_phase", "retry_scope"}:
2121
+ return _redact_operational_identifier(value, max_chars=120)
2122
+ if _looks_like_path_key(key) and _looks_like_path_value(value):
2123
+ return _path_label(value)
2124
+ return redact_snippet(value, max_chars=240)
2125
+ if isinstance(value, (int, float, bool)) or value is None:
2126
+ return value
2127
+ return redact_snippet(value, max_chars=120)
2128
+
2129
+
2130
+ def _path_label(path: str) -> str:
2131
+ expanded_home = str(Path.home())
2132
+ if path.startswith(expanded_home):
2133
+ path = "~" + path[len(expanded_home):]
2134
+ p = Path(path)
2135
+ parts = p.parts
2136
+ if len(parts) >= 3:
2137
+ return "/".join(parts[-3:])
2138
+ return p.name or path
2139
+
2140
+
2141
+ def _code_slug(value: Any) -> str:
2142
+ text = str(value or "").strip().lower()
2143
+ text = re.sub(r"[^a-z0-9]+", "_", text)
2144
+ return text.strip("_")
2145
+
2146
+
2147
+ def _dedupe(items: Any) -> list[str]:
2148
+ out: list[str] = []
2149
+ seen: set[str] = set()
2150
+ for item in items:
2151
+ value = str(item or "").strip()
2152
+ if not value or value in seen:
2153
+ continue
2154
+ seen.add(value)
2155
+ out.append(value)
2156
+ return out
2157
+
2158
+
2159
+ def _safe_int(value: Any) -> int:
2160
+ try:
2161
+ return int(value or 0)
2162
+ except (TypeError, ValueError):
2163
+ return 0
2164
+
2165
+
2166
+ def _first_or_default(value: Any, default: str) -> str:
2167
+ if isinstance(value, list) and value:
2168
+ return str(value[0] or default)
2169
+ return default
2170
+
2171
+
2172
+ def record_workflow_run(
2173
+ *,
2174
+ workflow: str,
2175
+ command: str | None = None,
2176
+ payload: object = None,
2177
+ exit_code: int = 0,
2178
+ started_at: float | None = None,
2179
+ duration_ms: int | None = None,
2180
+ snippets: list[object] | None = None,
2181
+ root: str | Path | None = None,
2182
+ source: str = "cli",
2183
+ extra: JsonObject | None = None,
2184
+ ) -> JsonObject:
2185
+ started = started_at if started_at is not None else time.time()
2186
+ if duration_ms is None:
2187
+ duration_ms = max(0, int((time.time() - started) * 1000))
2188
+ effective_command = command or command_string()
2189
+ payload_dict = _json_object_view(payload)
2190
+ payload_dict = _inherit_agent_feedback_payload(
2191
+ payload_dict,
2192
+ workflow=workflow,
2193
+ root=root,
2194
+ source=source,
2195
+ started=started,
2196
+ command=effective_command,
2197
+ )
2198
+ if isinstance(payload_dict, dict):
2199
+ initial_summary = summarize_payload(payload_dict)
2200
+ initial_error_context = _normalized_error_context(payload_dict.get("error_context"))
2201
+ initial_environment_context = _environment_blocker_context(payload_dict, initial_summary)
2202
+ if _needs_next_action_hardening(payload_dict) and initial_environment_context.get("next_action"):
2203
+ payload_dict = {
2204
+ **payload_dict,
2205
+ "status": "blocked",
2206
+ "blocked_reason": ENVIRONMENT_BLOCKER_CODE,
2207
+ "next_action": _json_text(initial_environment_context, "next_action"),
2208
+ }
2209
+ if not initial_error_context:
2210
+ payload_dict["error_context"] = _environment_error_context(
2211
+ payload_dict,
2212
+ summarize_payload(payload_dict),
2213
+ initial_environment_context,
2214
+ )
2215
+ else:
2216
+ payload_dict = _harden_payload_missing_next_action(
2217
+ payload_dict,
2218
+ workflow=workflow,
2219
+ command=effective_command,
2220
+ )
2221
+ provisional_summary = summarize_payload(payload_dict)
2222
+ provisional_error_context = _normalized_error_context(payload_dict.get("error_context"))
2223
+ provisional_environment_context = _environment_blocker_context(payload_dict, provisional_summary)
2224
+ if provisional_environment_context and not provisional_error_context:
2225
+ provisional_error_context = _environment_error_context(
2226
+ payload_dict,
2227
+ provisional_summary,
2228
+ provisional_environment_context,
2229
+ )
2230
+ payload_dict = _with_derived_agent_events(
2231
+ payload_dict,
2232
+ provisional_summary,
2233
+ provisional_error_context,
2234
+ source=source,
2235
+ )
2236
+ payload_for_context = payload_dict if payload_dict else payload
2237
+ payload_summary = summarize_payload(payload_for_context)
2238
+ agent_events = _normalized_agent_events(payload_dict)
2239
+ error_context = _normalized_error_context(payload_dict.get("error_context"))
2240
+ diagnostic_context = build_diagnostic_context(payload_for_context, payload_summary)
2241
+ payload_diagnostic = payload_dict.get("diagnostic_context") if isinstance(payload_dict, dict) else {}
2242
+ if isinstance(payload_diagnostic, dict) and isinstance(payload_diagnostic.get("contract_gap"), dict):
2243
+ diagnostic_context["contract_gap"] = payload_diagnostic["contract_gap"]
2244
+ if isinstance(payload_diagnostic, dict) and isinstance(payload_diagnostic.get("inherited_feedback_context"), dict):
2245
+ diagnostic_context["inherited_feedback_context"] = payload_diagnostic["inherited_feedback_context"]
2246
+ public_report = _json_object_field(_json_object_view(payload_diagnostic), "public_report")
2247
+ if public_report:
2248
+ diagnostic_context = {
2249
+ **diagnostic_context,
2250
+ "public_report": _normalized_public_report(public_report),
2251
+ }
2252
+ if source == "agent":
2253
+ if _feedback_summary_command(effective_command) and isinstance(
2254
+ diagnostic_context.get("inherited_feedback_context"),
2255
+ dict,
2256
+ ):
2257
+ diagnostic_context["prompt_hardening_context"] = _inherited_feedback_summary_context(
2258
+ payload_dict,
2259
+ workflow=workflow,
2260
+ command=effective_command,
2261
+ )
2262
+ else:
2263
+ diagnostic_context["prompt_hardening_context"] = _prompt_hardening_context(
2264
+ payload_dict,
2265
+ payload_summary,
2266
+ workflow=workflow,
2267
+ command=effective_command,
2268
+ )
2269
+ if not error_context and isinstance(diagnostic_context.get("error_context"), dict):
2270
+ error_context = diagnostic_context["error_context"]
2271
+ environment_context = _environment_context(root=root)
2272
+ integrity = environment_context.get("extension_integrity") if isinstance(environment_context, dict) else None
2273
+ if isinstance(integrity, dict) and integrity.get("drift_detected"):
2274
+ diagnostic_context.setdefault("environment_warnings", []).append("extension_integrity_drift")
2275
+ summary = integrity.get("summary") if isinstance(integrity.get("summary"), dict) else {}
2276
+ if int(summary.get("encoding_corruption_count", 0) or 0):
2277
+ diagnostic_context.setdefault("environment_warnings", []).append("extension.prompt_encoding_corruption")
2278
+ elif isinstance(integrity, dict) and integrity.get("skipped_reason") == "integrity_check_skipped_timeout":
2279
+ diagnostic_context.setdefault("environment_warnings", []).append("integrity_check_skipped_timeout")
2280
+ if source == "agent" and isinstance(integrity, dict):
2281
+ drift_event = _append_agent_integrity_drift_event(diagnostic_context, payload_summary, integrity)
2282
+ drift_code = _json_text(drift_event, "code") if isinstance(drift_event, dict) else ""
2283
+ if drift_event and not any(_json_text(_json_object_view(event), "code") == drift_code for event in agent_events):
2284
+ agent_events.append(drift_event)
2285
+ hook_since = datetime.fromtimestamp(max(0, started - 300), UTC).isoformat()
2286
+ hook_debug = _debug_from_hook_events(
2287
+ load_hook_events(since=hook_since, root=root),
2288
+ errors=load_hook_errors(since=hook_since, root=root),
2289
+ )
2290
+ generated_scripts = _merge_generated_scripts(
2291
+ _normalized_generated_scripts(payload_dict.get("generated_scripts", []), source="payload"),
2292
+ hook_debug["generated_scripts"],
2293
+ )
2294
+ command_events = _merge_command_events(
2295
+ _normalized_command_events(payload_dict.get("command_events", []), source="payload"),
2296
+ hook_debug["command_events"],
2297
+ )
2298
+ hook_errors = _merge_hook_errors(
2299
+ _normalized_hook_errors(payload_dict.get("hook_errors", []), source="payload"),
2300
+ hook_debug["hook_errors"],
2301
+ )
2302
+ hook_failure_event = _telemetry_hook_failed_agent_event(
2303
+ hook_errors,
2304
+ workflow=workflow,
2305
+ phase=str(payload_summary.get("phase") or payload_dict.get("phase") or "telemetry_capture"),
2306
+ )
2307
+ hook_failure_code = _json_text(hook_failure_event, "code") if hook_failure_event else ""
2308
+ if hook_failure_event and not any(_json_text(_json_object_view(event), "code") == hook_failure_code for event in agent_events):
2309
+ agent_events.append(hook_failure_event)
2310
+ status = str(payload_summary.get("status") or ("completed" if exit_code == 0 else "failed"))
2311
+ if exit_code != 0 and status == "completed":
2312
+ status = "failed"
2313
+ workflow_exit_code = (
2314
+ payload_dict.get("workflow_exit_code")
2315
+ if isinstance(payload_dict.get("workflow_exit_code"), int)
2316
+ else int(exit_code)
2317
+ if _json_text(payload_dict, "schema").endswith("-fsm-result.v1") and int(exit_code) != 0
2318
+ else None
2319
+ )
2320
+ record = {
2321
+ "schema": RUN_RECORD_SCHEMA,
2322
+ "run_id": _run_id(workflow),
2323
+ "recorded_at": now_iso(),
2324
+ "workflow": workflow,
2325
+ "source": source,
2326
+ "command": redact_snippet(effective_command, max_chars=700),
2327
+ "exit_code": int(exit_code),
2328
+ "workflow_exit_code": workflow_exit_code,
2329
+ "duration_ms": int(duration_ms),
2330
+ "status": status,
2331
+ "phase": payload_summary.get("phase") or "",
2332
+ "blocked_reason": payload_summary.get("blocked_reason") or "",
2333
+ "next_action": payload_summary.get("next_action") or "",
2334
+ "next_command": payload_dict.get("next_command") if isinstance(payload_dict, dict) else None,
2335
+ "resume_command": payload_dict.get("resume_command") if isinstance(payload_dict, dict) else None,
2336
+ "rollback_command": payload_dict.get("rollback_command") if isinstance(payload_dict, dict) else None,
2337
+ "execution_gate": payload_dict.get("execution_gate") if isinstance(payload_dict, dict) else None,
2338
+ "required_inputs": payload_summary.get("required_inputs") or [],
2339
+ "human_decision_required": bool(payload_summary.get("human_decision_required")),
2340
+ "process_chats_terminal_state": payload_summary.get("process_chats_terminal_state") or "",
2341
+ "process_chats_backlog_state": payload_summary.get("process_chats_backlog_state") or "",
2342
+ "dry_run": payload_summary.get("dry_run"),
2343
+ "apply": payload_summary.get("apply"),
2344
+ "payload_summary": payload_summary,
2345
+ "diagnostic_context": diagnostic_context,
2346
+ "environment_context": environment_context,
2347
+ "diagnostic_snippets": [
2348
+ redact_snippet(item) for item in (snippets or []) if str(item).strip()
2349
+ ][:10],
2350
+ "extra": extra or {},
2351
+ }
2352
+ if _run_record_root_truth_is_observational(
2353
+ payload_dict,
2354
+ command=effective_command,
2355
+ ):
2356
+ _move_legacy_root_truth_to_observed(record, source_payload=payload_dict)
2357
+ if agent_events:
2358
+ record["agent_events"] = agent_events[:MAX_AGENT_EVENTS]
2359
+ if error_context:
2360
+ record["error_context"] = error_context
2361
+ if isinstance(payload_dict.get("human_decision_packet"), dict):
2362
+ record["human_decision_packet"] = payload_dict["human_decision_packet"]
2363
+ if isinstance(payload_dict.get("human_decision_packets"), list):
2364
+ record["human_decision_packets"] = [
2365
+ packet for packet in payload_dict["human_decision_packets"][:MAX_DIAGNOSTIC_ITEMS]
2366
+ if isinstance(packet, dict)
2367
+ ]
2368
+ materialized_directive = _materialized_agent_directive(payload_dict)
2369
+ if materialized_directive:
2370
+ record = {**record, "agent_directive": materialized_directive}
2371
+ if generated_scripts:
2372
+ record["generated_scripts"] = generated_scripts[:MAX_GENERATED_SCRIPTS]
2373
+ if command_events:
2374
+ record["command_events"] = command_events[:MAX_COMMAND_EVENTS]
2375
+ if hook_debug["hook_event_ids"]:
2376
+ record["hook_event_ids"] = hook_debug["hook_event_ids"][:MAX_HOOK_EVENTS]
2377
+ if hook_errors:
2378
+ record["hook_errors"] = hook_errors[:MAX_HOOK_ERRORS]
2379
+ if hook_debug["hook_error_ids"]:
2380
+ record["hook_error_ids"] = hook_debug["hook_error_ids"][:MAX_HOOK_ERRORS]
2381
+ _apply_generated_script_risk_signals(record)
2382
+ _apply_process_chats_retry_loop_guard(record, root=root)
2383
+ attach_telemetry_evidence(record)
2384
+ runs_dir = feedback_root(root) / "runs"
2385
+ runs_dir.mkdir(parents=True, exist_ok=True)
2386
+ path = runs_dir / f"{record['run_id']}.json"
2387
+ record["record_path"] = str(path)
2388
+ _atomic_write_json(path, record)
2389
+ try:
2390
+ from mednotes.platform.feedback.telemetry import safe_auto_send_record
2391
+
2392
+ safe_auto_send_record(record, raw_payload=payload, root=root)
2393
+ except Exception:
2394
+ pass
2395
+ try:
2396
+ prune_local_feedback(root=root)
2397
+ except Exception:
2398
+ pass
2399
+ return record
2400
+
2401
+
2402
+ def _run_record_root_truth_is_observational(payload: JsonObject, *, command: str) -> bool:
2403
+ """Return true when legacy workflow fields must be demoted from record root.
2404
+
2405
+ FSM-first payloads and agent summary records are observations about a run.
2406
+ Keeping `status`/`phase`/`next_action` at root makes them look executable to
2407
+ hooks and reports, so those values move under `observed`/`payload_summary`.
2408
+ """
2409
+
2410
+ return _json_text(payload, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS or _feedback_summary_command(command)
2411
+
2412
+
2413
+ def _move_legacy_root_truth_to_observed(record: dict[str, Any], *, source_payload: JsonObject | None = None) -> None:
2414
+ """Demote record roots and preserve stale payload roots as legacy evidence."""
2415
+
2416
+ observed: dict[str, Any] = {}
2417
+ for key in ("status", "phase", "blocked_reason", "next_action", "workflow_exit_code"):
2418
+ value = record.pop(key, None)
2419
+ if value not in (None, "", [], {}):
2420
+ observed[key] = value
2421
+ legacy_root_fields: dict[str, Any] = {}
2422
+ source = source_payload or {}
2423
+ if _json_text(source, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS:
2424
+ for key in ("status", "phase", "blocked_reason", "next_action", "workflow_exit_code"):
2425
+ value = source[key] if key in source else None
2426
+ if value not in (None, "", [], {}):
2427
+ legacy_root_fields[key] = value
2428
+ if legacy_root_fields:
2429
+ observed["legacy_root_fields"] = legacy_root_fields
2430
+ if observed:
2431
+ record["observed"] = observed
2432
+
2433
+
2434
+ def _append_agent_integrity_drift_event(
2435
+ diagnostic_context: dict[str, Any],
2436
+ payload_summary: dict[str, Any],
2437
+ integrity: dict[str, Any],
2438
+ ) -> dict[str, Any] | None:
2439
+ drift_paths = _agent_relevant_integrity_paths(integrity)
2440
+ if not drift_paths:
2441
+ return None
2442
+ event = {
2443
+ "type": "script_or_prompt_drift",
2444
+ "code": "agent.script_or_prompt_drift",
2445
+ "phase": str(payload_summary.get("phase") or ""),
2446
+ "severity": "high",
2447
+ "summary": f"Instalacao com drift em {len(drift_paths)} arquivo(s) de comando, prompt, runbook ou script.",
2448
+ "action": "Rodar /mednotes:status para revisar integrity drift; reinstalar/publicar update se a mudanca nao foi intencional.",
2449
+ "target_kind": str(drift_paths[0].get("kind") or ""),
2450
+ "result": "detected",
2451
+ "path": str(drift_paths[0].get("path") or ""),
2452
+ }
2453
+ context = diagnostic_context.setdefault(
2454
+ "agent_behavior_context",
2455
+ {"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
2456
+ )
2457
+ context["event_count"] = int(context.get("event_count") or 0) + 1
2458
+ _increment_dict(context.setdefault("types", {}), event["type"])
2459
+ _increment_dict(context.setdefault("severities", {}), event["severity"])
2460
+ context["highest_severity"] = _higher_severity(str(context.get("highest_severity") or ""), event["severity"])
2461
+ codes = context.setdefault("codes", [])
2462
+ if event["code"] not in codes:
2463
+ codes.append(event["code"])
2464
+ samples = context.setdefault("samples", [])
2465
+ if len(samples) < MAX_AGENT_EVENT_SAMPLES:
2466
+ samples.append(event)
2467
+ signals = payload_summary.setdefault("signals", [])
2468
+ if "agent.script_or_prompt_drift" not in signals:
2469
+ signals.append("agent.script_or_prompt_drift")
2470
+ return event
2471
+
2472
+
2473
+ def _agent_relevant_integrity_paths(integrity: dict[str, Any]) -> list[dict[str, Any]]:
2474
+ if not integrity.get("drift_detected"):
2475
+ return []
2476
+ items: list[dict[str, Any]] = []
2477
+ for key in ("modified_files", "missing_files", "unexpected_files"):
2478
+ for item in integrity.get(key) or []:
2479
+ if not isinstance(item, dict):
2480
+ continue
2481
+ path = str(item.get("path") or "")
2482
+ kind = str(item.get("kind") or "")
2483
+ if _is_agent_relevant_drift(path, kind):
2484
+ items.append({"path": path, "kind": kind or "unknown", "change": key.removesuffix("_files")})
2485
+ return items
2486
+
2487
+
2488
+ def _is_agent_relevant_drift(path: str, kind: str) -> bool:
2489
+ if path == "GEMINI.md":
2490
+ return True
2491
+ if kind in AGENT_RELEVANT_DRIFT_KINDS:
2492
+ return True
2493
+ return path.startswith(AGENT_RELEVANT_DRIFT_PREFIXES)
2494
+
2495
+
2496
+ def _increment_dict(counts: JsonObject, key: str) -> None:
2497
+ counts[key] = int(counts.get(key) or 0) + 1
2498
+
2499
+
2500
+ def _higher_severity(left: str, right: str) -> str:
2501
+ if not left:
2502
+ return right
2503
+ return right if _severity_rank(right) > _severity_rank(left) else left
2504
+
2505
+
2506
+ def safe_record_workflow_run(
2507
+ *,
2508
+ workflow: str,
2509
+ command: str | None = None,
2510
+ payload: object = None,
2511
+ exit_code: int = 0,
2512
+ started_at: float | None = None,
2513
+ duration_ms: int | None = None,
2514
+ snippets: list[object] | None = None,
2515
+ root: str | Path | None = None,
2516
+ source: str = "cli",
2517
+ extra: JsonObject | None = None,
2518
+ ) -> JsonObject | None:
2519
+ """Fail-open wrapper around the typed feedback recorder.
2520
+
2521
+ Feedback persistence must never alter public workflow exit behavior, but the
2522
+ boundary still keeps the same typed contract as `record_workflow_run`; a
2523
+ catch-all `**kwargs` here would reintroduce an untyped operational API.
2524
+ """
2525
+
2526
+ try:
2527
+ return record_workflow_run(
2528
+ workflow=workflow,
2529
+ command=command,
2530
+ payload=payload,
2531
+ exit_code=exit_code,
2532
+ started_at=started_at,
2533
+ duration_ms=duration_ms,
2534
+ snippets=snippets,
2535
+ root=root,
2536
+ source=source,
2537
+ extra=extra,
2538
+ )
2539
+ except Exception:
2540
+ return None
2541
+
2542
+
2543
+ def _apply_process_chats_retry_loop_guard(record: dict[str, Any], *, root: str | Path | None = None) -> None:
2544
+ if str(record.get("workflow") or "") != "/mednotes:process-chats":
2545
+ return
2546
+ if str(record.get("status") or "") not in {"blocked", "failed", "error"}:
2547
+ return
2548
+ grouping = _record_grouping_dimensions(record, "agent.retry_loop")
2549
+ if not grouping.get("phase") or not grouping.get("root_cause"):
2550
+ return
2551
+ if not grouping.get("input_hash"):
2552
+ return
2553
+ retry_governance = record.get("diagnostic_context", {}).get("retry_governance", {})
2554
+ if not isinstance(retry_governance, dict):
2555
+ retry_governance = {}
2556
+ max_attempts = max(1, _safe_int(retry_governance.get("max_attempts") or 1))
2557
+ previous = [
2558
+ item
2559
+ for item in load_records(since="24h", root=root)
2560
+ if _same_retry_loop_signature(grouping, _record_grouping_dimensions(item, "agent.retry_loop"))
2561
+ ]
2562
+ if len(previous) < max_attempts:
2563
+ return
2564
+
2565
+ previous_next_action = str(record.get("next_action") or "")
2566
+ phase = str(grouping.get("phase") or "unknown")
2567
+ root_cause = str(grouping.get("root_cause") or "unknown")
2568
+ attempt_count = len(previous) + 1
2569
+ next_action = (
2570
+ f"Parar retries automáticos: o mesmo bloqueio em {phase} ({root_cause}) já ocorreu "
2571
+ f"{attempt_count} vez(es) sem mudança relevante. Preserve os artefatos atuais, revise o "
2572
+ "error_context e só repita depois de alterar o input indicado ou pedir decisão humana."
2573
+ )
2574
+ if previous_next_action:
2575
+ next_action += f" Última rota esperada: {previous_next_action}"
2576
+
2577
+ record["next_action"] = next_action
2578
+ summary: dict[str, Any] = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
2579
+ summary["next_action"] = next_action
2580
+ signals = summary.setdefault("signals", [])
2581
+ if "agent.retry_loop" not in signals:
2582
+ signals.append("agent.retry_loop")
2583
+
2584
+ error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
2585
+ target_kind = str(error_context.get("affected_artifact") or "workflow")
2586
+ if error_context:
2587
+ error_context["next_action"] = next_action
2588
+
2589
+ diagnostic: dict[str, Any] = (
2590
+ record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
2591
+ )
2592
+ diagnostic["recovery_command"] = next_action
2593
+ agent_context = diagnostic.setdefault(
2594
+ "agent_behavior_context",
2595
+ {"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
2596
+ )
2597
+ event = {
2598
+ "type": "retry_loop",
2599
+ "code": "agent.retry_loop",
2600
+ "phase": phase,
2601
+ "severity": "high",
2602
+ "summary": f"Mesmo bloqueio repetido {attempt_count} vez(es) sem mudança relevante.",
2603
+ "action": next_action,
2604
+ "target_kind": _code_slug(target_kind),
2605
+ "result": "blocked",
2606
+ "blocked_reason": _code_slug(record.get("blocked_reason") or root_cause),
2607
+ "next_action_expected": previous_next_action,
2608
+ }
2609
+ _append_agent_context_event(agent_context, event)
2610
+ events = record.setdefault("agent_events", [])
2611
+ if isinstance(events, list) and not any(isinstance(item, dict) and item.get("code") == "agent.retry_loop" for item in events):
2612
+ events.append(event)
2613
+
2614
+
2615
+ def _same_retry_loop_signature(current: dict[str, str], previous: dict[str, str]) -> bool:
2616
+ for key in ("phase", "root_cause"):
2617
+ if str(current.get(key) or "") != str(previous.get(key) or ""):
2618
+ return False
2619
+ compared = False
2620
+ for key in ("target_canonical", "input_hash", "error_hash"):
2621
+ current_value = str(current.get(key) or "")
2622
+ previous_value = str(previous.get(key) or "")
2623
+ if not current_value:
2624
+ continue
2625
+ compared = True
2626
+ if current_value != previous_value:
2627
+ return False
2628
+ return compared
2629
+
2630
+
2631
+ def _append_agent_context_event(context: dict[str, Any], event: dict[str, Any]) -> None:
2632
+ context["event_count"] = int(context.get("event_count") or 0) + 1
2633
+ _increment_dict(context.setdefault("types", {}), str(event.get("type") or "unknown"))
2634
+ severity = str(event.get("severity") or "low")
2635
+ _increment_dict(context.setdefault("severities", {}), severity)
2636
+ context["highest_severity"] = _higher_severity(str(context.get("highest_severity") or ""), severity)
2637
+ codes = context.setdefault("codes", [])
2638
+ code = str(event.get("code") or "")
2639
+ if code and code not in codes:
2640
+ codes.append(code)
2641
+ samples = context.setdefault("samples", [])
2642
+ if len(samples) < MAX_AGENT_EVENT_SAMPLES:
2643
+ samples.append(event)
2644
+
2645
+
2646
+ def attach_telemetry_evidence(record: dict[str, Any], *, send_path: str = "workflow_record") -> dict[str, Any]:
2647
+ record["telemetry_evidence"] = build_telemetry_evidence(record, send_path=send_path)
2648
+ return record
2649
+
2650
+
2651
+ def build_telemetry_evidence(record: dict[str, Any], *, send_path: str = "workflow_record") -> dict[str, Any]:
2652
+ extension_diffs = _record_extension_diffs(record)
2653
+ generated_scripts = record.get("generated_scripts") if isinstance(record.get("generated_scripts"), list) else []
2654
+ command_events = record.get("command_events") if isinstance(record.get("command_events"), list) else []
2655
+ hook_errors = record.get("hook_errors") if isinstance(record.get("hook_errors"), list) else []
2656
+ hook_event_ids = record.get("hook_event_ids") if isinstance(record.get("hook_event_ids"), list) else []
2657
+ hook_error_ids = record.get("hook_error_ids") if isinstance(record.get("hook_error_ids"), list) else []
2658
+ counts = {
2659
+ "extension_diff_count": len(extension_diffs),
2660
+ "generated_script_count": len(generated_scripts),
2661
+ "command_event_count": len(command_events),
2662
+ "hook_error_count": len(hook_errors),
2663
+ "hook_event_id_count": len(hook_event_ids),
2664
+ "hook_error_id_count": len(hook_error_ids),
2665
+ }
2666
+ sources = _evidence_sources(record, extension_diffs, generated_scripts, command_events, hook_errors)
2667
+ quality_flags = _evidence_quality_flags(record, extension_diffs, generated_scripts, command_events, hook_errors)
2668
+ timeline = _evidence_timeline(record, extension_diffs, generated_scripts, command_events, hook_errors)
2669
+ seed = {
2670
+ "run_id": record.get("run_id"),
2671
+ "recorded_at": record.get("recorded_at"),
2672
+ "sources": sources,
2673
+ "counts": counts,
2674
+ "timeline": timeline[:8],
2675
+ }
2676
+ return {
2677
+ "schema": TELEMETRY_EVIDENCE_SCHEMA,
2678
+ "bundle_id": f"telem-{hashlib.sha256(json.dumps(seed, sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()[:16]}",
2679
+ "sources": sources,
2680
+ "artifact_counts": counts,
2681
+ "timeline": timeline[:12],
2682
+ "quality_flags": quality_flags,
2683
+ "redaction_summary": {
2684
+ "applied": True,
2685
+ "blocked_fields": ["content", "markdown", "html", "raw_chat", "note_text", ".env", "tokens", "keys"],
2686
+ "operational_debug_fields": ["extension_diffs", "generated_scripts", "command_events", "hook_errors"],
2687
+ },
2688
+ "truncation_summary": _evidence_truncation_summary(extension_diffs, generated_scripts, command_events, hook_errors),
2689
+ "send_path": send_path,
2690
+ }
2691
+
2692
+
2693
+ def _record_extension_diffs(record: dict[str, Any]) -> list[dict[str, Any]]:
2694
+ direct = record.get("extension_diffs") if isinstance(record.get("extension_diffs"), list) else []
2695
+ if direct:
2696
+ return [item for item in direct if isinstance(item, dict)]
2697
+ environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
2698
+ integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
2699
+ diffs = integrity.get("extension_diffs") if isinstance(integrity.get("extension_diffs"), list) else []
2700
+ return [item for item in diffs if isinstance(item, dict)]
2701
+
2702
+
2703
+ def _evidence_sources(
2704
+ record: dict[str, Any],
2705
+ extension_diffs: list[dict[str, Any]],
2706
+ generated_scripts: list[Any],
2707
+ command_events: list[Any],
2708
+ hook_errors: list[Any],
2709
+ ) -> list[str]:
2710
+ sources: list[str] = []
2711
+ if extension_diffs:
2712
+ environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
2713
+ integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
2714
+ if integrity.get("snapshot_id"):
2715
+ sources.append("pre_update_snapshot:extension_diffs")
2716
+ else:
2717
+ sources.append("workflow_record:extension_diffs")
2718
+ if generated_scripts:
2719
+ sources.append(f"{_dominant_item_source(generated_scripts, 'payload')}:generated_scripts")
2720
+ if command_events:
2721
+ sources.append(f"{_dominant_item_source(command_events, 'payload')}:command_events")
2722
+ if hook_errors:
2723
+ sources.append(f"{_dominant_item_source(hook_errors, 'hook')}:hook_errors")
2724
+ if record.get("hook_event_ids"):
2725
+ sources.append("hook:hook_event_ids")
2726
+ if record.get("hook_error_ids"):
2727
+ sources.append("hook:hook_error_ids")
2728
+ return _dedupe(sources)
2729
+
2730
+
2731
+ def _dominant_item_source(items: list[Any], default: str) -> str:
2732
+ for item in items:
2733
+ if isinstance(item, dict) and item.get("source"):
2734
+ return str(item.get("source"))
2735
+ return default
2736
+
2737
+
2738
+ def _evidence_quality_flags(
2739
+ record: dict[str, Any],
2740
+ extension_diffs: list[dict[str, Any]],
2741
+ generated_scripts: list[Any],
2742
+ command_events: list[Any],
2743
+ hook_errors: list[Any],
2744
+ ) -> list[str]:
2745
+ flags: list[str] = []
2746
+ if generated_scripts and not command_events:
2747
+ flags.append("telemetry.command_events_missing")
2748
+ if hook_errors:
2749
+ flags.append("telemetry.hook_capture_failed")
2750
+ environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
2751
+ integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
2752
+ summary = integrity.get("summary") if isinstance(integrity.get("summary"), dict) else {}
2753
+ if summary.get("snapshot_changed_path_count_mismatch") or (extension_diffs and not _safe_int(summary.get("changed_count")) and integrity.get("drift_detected") is False):
2754
+ flags.append("telemetry.snapshot_counts_mismatch")
2755
+ if any(_safe_int(item.get("noise_filtered_count")) for item in extension_diffs if isinstance(item, dict)):
2756
+ flags.append("telemetry.noisy_diff_filtered")
2757
+ return _dedupe(flags)
2758
+
2759
+
2760
+ def _telemetry_hook_failed_agent_event(
2761
+ hook_errors: list[Any],
2762
+ *,
2763
+ workflow: str,
2764
+ phase: str,
2765
+ ) -> dict[str, Any] | None:
2766
+ normalized = [item for item in hook_errors if isinstance(item, dict)]
2767
+ if not normalized:
2768
+ return None
2769
+ sample = normalized[0]
2770
+ return {
2771
+ "schema": "medical-notes-workbench.agent-event.v1",
2772
+ "type": "telemetry_hook_failed",
2773
+ "code": "agent.telemetry_hook_failed",
2774
+ "severity": "medium",
2775
+ "root_cause_code": "telemetry_capture_failed",
2776
+ "workflow": workflow,
2777
+ "phase": phase or "telemetry_capture",
2778
+ "summary": "Falha de hook de telemetria reduziu a evidencia capturada durante o workflow.",
2779
+ "action": "Rodar /report ou capture_extension_diff antes de continuar mutacao arriscada.",
2780
+ "target_kind": "telemetry",
2781
+ "result": "evidence_degraded",
2782
+ "recovery_command": "Run /report or capture_extension_diff before continuing risky mutation.",
2783
+ "artifact_path": str(sample.get("error_path") or ""),
2784
+ "redacted_sample": {
2785
+ "hook_error_count": len(normalized),
2786
+ "hook_event_name": str(sample.get("hook_event_name") or ""),
2787
+ "type": str(sample.get("type") or ""),
2788
+ "mode": str(sample.get("mode") or ""),
2789
+ },
2790
+ "next_action": "Run /report or capture_extension_diff before continuing risky mutation.",
2791
+ }
2792
+
2793
+
2794
+ def _evidence_timeline(
2795
+ record: dict[str, Any],
2796
+ extension_diffs: list[dict[str, Any]],
2797
+ generated_scripts: list[Any],
2798
+ command_events: list[Any],
2799
+ hook_errors: list[Any],
2800
+ ) -> list[dict[str, Any]]:
2801
+ at = str(record.get("recorded_at") or now_iso())
2802
+ timeline: list[dict[str, Any]] = [
2803
+ {"at": at, "kind": "run_record", "label": str(record.get("workflow") or "unknown"), "phase": str(record.get("phase") or "")}
2804
+ ]
2805
+ for diff in extension_diffs[:4]:
2806
+ timeline.append({"at": at, "kind": "extension_diff", "label": str(diff.get("path") or ""), "change": str(diff.get("change") or "")})
2807
+ for script in generated_scripts[:4]:
2808
+ if isinstance(script, dict):
2809
+ timeline.append({"at": at, "kind": "generated_script", "label": str(script.get("path") or ""), "source": str(script.get("source") or "")})
2810
+ for event in command_events[:4]:
2811
+ if isinstance(event, dict):
2812
+ timeline.append({"at": at, "kind": "command_event", "label": str(event.get("command_family") or "shell"), "status": str(event.get("status") or "")})
2813
+ for error in hook_errors[:4]:
2814
+ if isinstance(error, dict):
2815
+ timeline.append({"at": str(error.get("recorded_at") or at), "kind": "hook_error", "label": str(error.get("type") or "hook_error")})
2816
+ return timeline
2817
+
2818
+
2819
+ def _evidence_truncation_summary(*groups: list[Any]) -> dict[str, Any]:
2820
+ truncated = 0
2821
+ omitted = 0
2822
+ for group in groups:
2823
+ for item in group:
2824
+ if not isinstance(item, dict):
2825
+ continue
2826
+ if item.get("truncated"):
2827
+ truncated += 1
2828
+ if item.get("content_omitted_reason") or item.get("full_diff_unavailable_reason"):
2829
+ omitted += 1
2830
+ return {"truncated_artifacts": truncated, "omitted_artifacts": omitted}
2831
+
2832
+
2833
+ def _apply_generated_script_risk_signals(record: dict[str, Any]) -> None:
2834
+ scripts = record.get("generated_scripts") if isinstance(record.get("generated_scripts"), list) else []
2835
+ if not scripts:
2836
+ return
2837
+ all_codes = {
2838
+ str(code)
2839
+ for script in scripts
2840
+ if isinstance(script, dict)
2841
+ for code in (script.get("risk_codes") or [])
2842
+ if str(code).strip()
2843
+ }
2844
+ events: list[dict[str, Any]] = []
2845
+ phase = str(record.get("phase") or "")
2846
+ first_path = next((str(script.get("path") or "") for script in scripts if isinstance(script, dict) and script.get("path")), "")
2847
+ events.append(
2848
+ {
2849
+ "type": "generated_script_workaround",
2850
+ "code": "agent.generated_script_workaround",
2851
+ "phase": phase,
2852
+ "severity": "medium",
2853
+ "summary": f"Agente criou ou editou {len(scripts)} script(s) operacional(is) durante o workflow.",
2854
+ "action": "Revisar o script gerado e transformar qualquer logica util em implementacao testada da extensao.",
2855
+ "target_kind": "script",
2856
+ "result": "detected",
2857
+ "path": first_path,
2858
+ }
2859
+ )
2860
+ if {"reads_obsidian_plugin_data", "writes_related_notes_section"} & all_codes:
2861
+ events.append(
2862
+ {
2863
+ "type": "related_notes_wrong_strategy",
2864
+ "code": "agent.related_notes_wrong_strategy",
2865
+ "phase": phase,
2866
+ "severity": "high",
2867
+ "summary": "Agente tentou reconstruir Notas Relacionadas por script improvisado em vez de usar o produto validado do plugin.",
2868
+ "action": "Reimplementar a integracao Related Notes dentro da extensao, com contrato de entrada/saida e dry-run.",
2869
+ "target_kind": "related_notes",
2870
+ "result": "detected",
2871
+ "path": first_path,
2872
+ }
2873
+ )
2874
+ if "mass_markdown_mutation" in all_codes and "no_dry_run" in all_codes:
2875
+ events.append(
2876
+ {
2877
+ "type": "mass_mutation_without_dry_run",
2878
+ "code": "agent.mass_mutation_without_dry_run",
2879
+ "phase": phase,
2880
+ "severity": "high",
2881
+ "summary": "Script gerado parece mutar muitas notas Markdown sem dry-run detectavel.",
2882
+ "action": "Bloquear aplicacao direta; exigir preview, backup e limite de escopo antes de mutar o vault.",
2883
+ "target_kind": "vault",
2884
+ "result": "detected",
2885
+ "path": first_path,
2886
+ }
2887
+ )
2888
+ if {"direct_sql_mutation", "queue_truth_bypass", "unsafe_mass_wikilink_rewrite"} & all_codes:
2889
+ root_cause = (
2890
+ "queue_truth_bypass"
2891
+ if "queue_truth_bypass" in all_codes
2892
+ else "direct_sql_mutation"
2893
+ if "direct_sql_mutation" in all_codes
2894
+ else "unsafe_mass_wikilink_rewrite"
2895
+ )
2896
+ events.append(
2897
+ {
2898
+ "type": "generated_script_risk",
2899
+ "code": "agent.unsafe_generated_script_recovery_bypass",
2900
+ "phase": phase,
2901
+ "severity": "high",
2902
+ "summary": "Script gerado tenta contornar o workflow oficial de recovery com mutação direta.",
2903
+ "action": "Descartar o workaround e usar os comandos oficiais com dry-run, plano e recibo.",
2904
+ "target_kind": "workflow_state",
2905
+ "result": "detected",
2906
+ "path": first_path,
2907
+ "root_cause_code": root_cause,
2908
+ "recovery_command": _generated_script_recovery_command(all_codes),
2909
+ }
2910
+ )
2911
+ if "extension_prompt_or_script_drift" in all_codes:
2912
+ events.append(
2913
+ {
2914
+ "type": "script_or_prompt_drift",
2915
+ "code": "agent.script_or_prompt_drift",
2916
+ "phase": phase,
2917
+ "severity": "high",
2918
+ "summary": "Script gerado toca area allowlisted da extensao.",
2919
+ "action": "Comparar o diff e decidir se vira update publicado ou rollback.",
2920
+ "target_kind": "extension",
2921
+ "result": "detected",
2922
+ "path": first_path,
2923
+ }
2924
+ )
2925
+ if not events:
2926
+ return
2927
+ diagnostic = record.setdefault("diagnostic_context", {})
2928
+ if not isinstance(diagnostic, dict):
2929
+ return
2930
+ context = diagnostic.setdefault(
2931
+ "agent_behavior_context",
2932
+ {"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
2933
+ )
2934
+ existing_events = record.setdefault("agent_events", [])
2935
+ if not isinstance(existing_events, list):
2936
+ existing_events = []
2937
+ record["agent_events"] = existing_events
2938
+ summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
2939
+ signals = summary.setdefault("signals", [])
2940
+ for event in events:
2941
+ event.setdefault("schema", "medical-notes-workbench.agent-event.v1")
2942
+ event.setdefault("root_cause_code", str(event.get("code") or "").replace("agent.", ""))
2943
+ event.setdefault("workflow", str(record.get("workflow") or ""))
2944
+ event.setdefault("recovery_command", _generated_script_recovery_command(all_codes))
2945
+ event.setdefault("artifact_path", first_path)
2946
+ event.setdefault("redacted_sample", {"path": first_path, "risk_codes": sorted(all_codes)[:12]})
2947
+ event.setdefault("next_action", str(event.get("action") or ""))
2948
+ if not any(isinstance(item, dict) and item.get("code") == event["code"] for item in existing_events):
2949
+ existing_events.append(event)
2950
+ _append_agent_context_event(context, event)
2951
+ if event["code"] not in signals:
2952
+ signals.append(event["code"])
2953
+
2954
+
2955
+ def _generated_script_recovery_command(risk_codes: set[str]) -> str:
2956
+ if "queue_truth_bypass" in risk_codes or "direct_sql_mutation" in risk_codes:
2957
+ return "uv run python scripts/mednotes/wiki/cli.py vocabulary-recover --mode reconcile-queue --dry-run --json"
2958
+ if "unsafe_mass_wikilink_rewrite" in risk_codes or "mass_markdown_mutation" in risk_codes:
2959
+ return "uv run python scripts/mednotes/wiki/cli.py run-linker --diagnose --json"
2960
+ if {"reads_obsidian_plugin_data", "writes_related_notes_section"} & risk_codes:
2961
+ return "uv run python scripts/mednotes/wiki/cli.py related-notes-sync --recover-export --mode auto --json"
2962
+ return "uv run python scripts/mednotes/wiki/cli.py environment-preflight --json"
2963
+
2964
+
2965
+ def load_hook_events(*, since: str = "2h", root: str | Path | None = None, limit: int = MAX_HOOK_EVENTS) -> list[dict[str, Any]]:
2966
+ cutoff = _parse_since(since)
2967
+ events_dir = feedback_root(root) / "hook-events"
2968
+ events: list[dict[str, Any]] = []
2969
+ for path in sorted(events_dir.glob("*.json"), reverse=True):
2970
+ try:
2971
+ data = json.loads(path.read_text(encoding="utf-8"))
2972
+ except (OSError, json.JSONDecodeError):
2973
+ continue
2974
+ if data.get("schema") != AGENT_HOOK_EVENT_SCHEMA:
2975
+ continue
2976
+ recorded_at = _parse_datetime(str(data.get("recorded_at") or data.get("timestamp") or ""))
2977
+ if recorded_at and recorded_at < cutoff:
2978
+ continue
2979
+ data.setdefault("event_path", str(path))
2980
+ events.append(data)
2981
+ if len(events) >= limit:
2982
+ break
2983
+ return list(reversed(events))
2984
+
2985
+
2986
+ def load_hook_errors(*, since: str = "2h", root: str | Path | None = None, limit: int = MAX_HOOK_ERRORS) -> list[dict[str, Any]]:
2987
+ cutoff = _parse_since(since)
2988
+ errors_dir = feedback_root(root) / "hook-errors"
2989
+ errors: list[dict[str, Any]] = []
2990
+ for path in sorted(errors_dir.glob("*.json"), reverse=True):
2991
+ try:
2992
+ data = json.loads(path.read_text(encoding="utf-8"))
2993
+ except (OSError, json.JSONDecodeError):
2994
+ continue
2995
+ if data.get("schema") != AGENT_HOOK_ERROR_SCHEMA:
2996
+ continue
2997
+ recorded_at = _parse_datetime(str(data.get("recorded_at") or data.get("timestamp") or ""))
2998
+ if recorded_at and recorded_at < cutoff:
2999
+ continue
3000
+ data.setdefault("error_path", str(path))
3001
+ errors.append(data)
3002
+ if len(errors) >= limit:
3003
+ break
3004
+ return list(reversed(errors))
3005
+
3006
+
3007
+ def hook_debug_record(
3008
+ *,
3009
+ events: list[dict[str, Any]],
3010
+ errors: list[dict[str, Any]] | None = None,
3011
+ since: str = "2h",
3012
+ ) -> dict[str, Any] | None:
3013
+ debug = _debug_from_hook_events(events, errors=errors or [])
3014
+ if not debug["generated_scripts"] and not debug["command_events"] and not debug["hook_errors"]:
3015
+ return None
3016
+ failed_commands = any(_command_event_failed(event) for event in debug["command_events"])
3017
+ hook_error_detected = bool(debug["hook_errors"])
3018
+ workflow_hints = _workflow_hints_from_command_events(debug["command_events"])
3019
+ digest = hashlib.sha256(
3020
+ json.dumps(
3021
+ {"hook_event_ids": debug["hook_event_ids"], "hook_error_ids": debug["hook_error_ids"]},
3022
+ sort_keys=True,
3023
+ ensure_ascii=False,
3024
+ ).encode("utf-8")
3025
+ ).hexdigest()[:12]
3026
+ record = {
3027
+ "schema": RUN_RECORD_SCHEMA,
3028
+ "run_id": f"hook-events-{digest}",
3029
+ "recorded_at": now_iso(),
3030
+ "workflow": "/mednotes:agent-session",
3031
+ "source": "agent",
3032
+ "command": "Gemini CLI hooks",
3033
+ "exit_code": 0,
3034
+ "duration_ms": 0,
3035
+ "status": "completed_with_warnings",
3036
+ "phase": "hook-events",
3037
+ "workflow_hints": workflow_hints,
3038
+ "blocked_reason": "",
3039
+ "next_action": "Revisar scripts gerados, erros de console e falhas internas dos hooks capturados pela telemetria.",
3040
+ "required_inputs": [],
3041
+ "human_decision_required": False,
3042
+ "dry_run": None,
3043
+ "apply": None,
3044
+ "payload_summary": {
3045
+ "counts": {
3046
+ "generated_script_count": len(debug["generated_scripts"]),
3047
+ "command_event_count": len(debug["command_events"]),
3048
+ "hook_error_count": len(debug["hook_errors"]),
3049
+ },
3050
+ "warnings": [],
3051
+ "errors": [],
3052
+ "required_inputs": [],
3053
+ "relevant_paths": [item.get("path", "") for item in debug["generated_scripts"] if item.get("path")],
3054
+ "path_hashes": {},
3055
+ "signals": _dedupe((["agent.command_failed"] if failed_commands else []) + (["telemetry.hook_error"] if hook_error_detected else [])),
3056
+ "status": "completed_with_warnings",
3057
+ "phase": "hook-events",
3058
+ "workflow_hints": workflow_hints,
3059
+ },
3060
+ "diagnostic_context": {
3061
+ "root_cause_code": "agent.hook_debug",
3062
+ "root_cause_label": "Eventos tecnicos capturados por hooks",
3063
+ "recovery_command": "Revisar os scripts gerados e erros de console no email de telemetria.",
3064
+ "missing_inputs": [],
3065
+ "decision_context": {"types": [], "decisions": []},
3066
+ "blocker_context": {"codes": [], "counts": {}, "summaries": [], "samples": [], "routes": []},
3067
+ "contract_gaps": [],
3068
+ },
3069
+ "environment_context": {},
3070
+ "diagnostic_snippets": [],
3071
+ "generated_scripts": debug["generated_scripts"],
3072
+ "command_events": debug["command_events"],
3073
+ "hook_errors": debug["hook_errors"],
3074
+ "hook_event_ids": debug["hook_event_ids"],
3075
+ "hook_error_ids": debug["hook_error_ids"],
3076
+ "hook_debug_since": since,
3077
+ }
3078
+ hook_failure_event = _telemetry_hook_failed_agent_event(
3079
+ debug["hook_errors"],
3080
+ workflow="/mednotes:agent-session",
3081
+ phase="hook-events",
3082
+ )
3083
+ if hook_failure_event:
3084
+ record["agent_events"] = [hook_failure_event]
3085
+ _apply_generated_script_risk_signals(record)
3086
+ return attach_telemetry_evidence(record, send_path="hook_debug_record")
3087
+
3088
+
3089
+ def _command_event_failed(event: JsonObject) -> bool:
3090
+ status = _json_text(event, "status").lower()
3091
+ exit_code = _json_value(event, "exit_code")
3092
+ return bool(status in {"failed", "error"} or (isinstance(exit_code, int) and exit_code != 0) or _json_value(event, "error"))
3093
+
3094
+
3095
+ def _workflow_hints_from_command_events(events: list[JsonObject]) -> list[JsonObject]:
3096
+ hints: list[JsonObject] = []
3097
+ seen: set[tuple[str, str, str, str]] = set()
3098
+ for event in events[:MAX_COMMAND_EVENTS]:
3099
+ event_view = _json_object_view(event)
3100
+ workflow = _json_text(event_view, "workflow")
3101
+ phase = _json_text(event_view, "phase")
3102
+ status = _json_text(event_view, "workflow_status")
3103
+ blocked_reason = _json_text(event_view, "blocked_reason")
3104
+ if not (workflow or phase or status or blocked_reason):
3105
+ continue
3106
+ key = (workflow, phase, status, blocked_reason)
3107
+ if key in seen:
3108
+ continue
3109
+ seen.add(key)
3110
+ hints.append(
3111
+ {
3112
+ "workflow": workflow,
3113
+ "phase": phase,
3114
+ "status": status,
3115
+ "blocked_reason": blocked_reason,
3116
+ "exit_code": _json_int_field(event_view, "workflow_exit_code")
3117
+ if _json_int_field(event_view, "workflow_exit_code") is not None
3118
+ else _json_int_field(event_view, "exit_code"),
3119
+ }
3120
+ )
3121
+ return hints[:8]
3122
+
3123
+
3124
+ def _debug_from_hook_events(events: list[dict[str, Any]], *, errors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
3125
+ generated_scripts: list[dict[str, Any]] = []
3126
+ command_events: list[dict[str, Any]] = []
3127
+ hook_errors: list[dict[str, Any]] = []
3128
+ event_ids: list[str] = []
3129
+ error_ids: list[str] = []
3130
+ for event in events[:MAX_HOOK_EVENTS]:
3131
+ event_id = str(event.get("event_id") or "")
3132
+ if event_id:
3133
+ event_ids.append(event_id)
3134
+ generated_scripts.extend(_normalized_generated_scripts(event.get("generated_scripts", []), source="hook"))
3135
+ command_events.extend(_normalized_command_events(event.get("command_events", []), source="hook"))
3136
+ for error in (errors or [])[:MAX_HOOK_ERRORS]:
3137
+ error_id = str(error.get("error_id") or "")
3138
+ if error_id:
3139
+ error_ids.append(error_id)
3140
+ hook_errors.extend(_normalized_hook_errors([error], source="hook"))
3141
+ return {
3142
+ "generated_scripts": _merge_generated_scripts(generated_scripts)[:MAX_GENERATED_SCRIPTS],
3143
+ "command_events": _merge_command_events(command_events)[:MAX_COMMAND_EVENTS],
3144
+ "hook_errors": _merge_hook_errors(hook_errors)[:MAX_HOOK_ERRORS],
3145
+ "hook_event_ids": _dedupe(event_ids)[:MAX_HOOK_EVENTS],
3146
+ "hook_error_ids": _dedupe(error_ids)[:MAX_HOOK_ERRORS],
3147
+ }
3148
+
3149
+
3150
+ def _normalized_generated_scripts(value: Any, *, source: str) -> list[dict[str, Any]]:
3151
+ if not isinstance(value, list):
3152
+ return []
3153
+ scripts: list[dict[str, Any]] = []
3154
+ for item in value[:MAX_GENERATED_SCRIPTS]:
3155
+ if not isinstance(item, dict):
3156
+ continue
3157
+ path = str(item.get("path") or "")
3158
+ suffix = Path(path).suffix.lower()
3159
+ if suffix not in SCRIPT_SUFFIXES:
3160
+ continue
3161
+ content = str(item.get("content") or "")
3162
+ script: dict[str, Any] = {
3163
+ "path": _compact_path_label(path),
3164
+ "language": _language_for_suffix(suffix),
3165
+ "sha256": str(item.get("sha256") or _hash_text(content)),
3166
+ "size_bytes": _safe_int(item.get("size_bytes") if item.get("size_bytes") is not None else len(content.encode("utf-8"))),
3167
+ "source": str(item.get("source") or source),
3168
+ "capture_method": str(item.get("capture_method") or ""),
3169
+ }
3170
+ if content:
3171
+ script["content"] = redact_operational_text(content, max_chars=MAX_SCRIPT_CONTENT_CHARS)
3172
+ script["truncated"] = len(str(script["content"])) < len(content)
3173
+ risk_codes = _generated_script_risk_codes(path=path, content=content)
3174
+ if risk_codes:
3175
+ script["risk_codes"] = risk_codes
3176
+ if item.get("content_omitted_reason"):
3177
+ script["content_omitted_reason"] = redact_snippet(item.get("content_omitted_reason"), max_chars=160)
3178
+ scripts.append(script)
3179
+ return scripts
3180
+
3181
+
3182
+ def _generated_script_risk_codes(*, path: str, content: str) -> list[str]:
3183
+ text = str(content or "")
3184
+ lowered = text.lower()
3185
+ path_lower = str(path or "").replace("\\", "/").lower()
3186
+ codes: list[str] = []
3187
+
3188
+ def add(code: str, condition: bool) -> None:
3189
+ if condition and code not in codes:
3190
+ codes.append(code)
3191
+
3192
+ markdown_scan = (
3193
+ "rglob(\"*.md\")" in lowered
3194
+ or "rglob('*.md')" in lowered
3195
+ or "glob(\"**/*.md\")" in lowered
3196
+ or "glob('**/*.md')" in lowered
3197
+ or ("os.walk" in lowered and ".md" in lowered)
3198
+ )
3199
+ writes_files = bool(
3200
+ re.search(r"\bwrite_text\s*\(", lowered)
3201
+ or re.search(r"\bopen\s*\([^)]*['\"]w", lowered)
3202
+ or "fs.writefile" in lowered
3203
+ or "set-content" in lowered
3204
+ or "out-file" in lowered
3205
+ or ".unlink(" in lowered
3206
+ or "shutil.move" in lowered
3207
+ )
3208
+ add("mass_markdown_mutation", markdown_scan and writes_files)
3209
+ add(
3210
+ "unsafe_mass_wikilink_rewrite",
3211
+ markdown_scan and writes_files and bool(re.search(r"\[\[|wikilink|wiki\s*link", lowered)),
3212
+ )
3213
+ add(
3214
+ "direct_sql_mutation",
3215
+ bool(
3216
+ ("sqlite3.connect" in lowered or ".sqlite" in lowered or "vocabulary.sqlite" in lowered)
3217
+ and re.search(r"\b(update|insert|delete|drop|alter|replace)\b", lowered)
3218
+ ),
3219
+ )
3220
+ add(
3221
+ "queue_truth_bypass",
3222
+ "note_semantic_ingestion_queue" in lowered
3223
+ and "status" in lowered
3224
+ and bool(re.search(r"\b(applied|completed|done)\b", lowered)),
3225
+ )
3226
+ add("hardcoded_user_path", bool(re.search(r"(?i)([a-z]:\\\\|/users/|/home/|~[/\\])", text)))
3227
+ add("reads_obsidian_plugin_data", ".obsidian/plugins" in lowered or "related-notes" in lowered or "related notes" in lowered)
3228
+ add("writes_related_notes_section", "notas relacionadas" in lowered or "related notes" in lowered)
3229
+ add(
3230
+ "external_api_or_embedding_call",
3231
+ bool(
3232
+ re.search(r"\b(openai|anthropic|gemini|embedding|embeddings)\b", lowered)
3233
+ or re.search(r"\b(requests|httpx)\.(post|get|request)\s*\(", lowered)
3234
+ or re.search(r"\bfetch\s*\(", lowered)
3235
+ ),
3236
+ )
3237
+ add("no_dry_run", writes_files and "dry_run" not in lowered and "--dry-run" not in lowered and "dry-run" not in lowered)
3238
+ add("encoding_corruption", "\ufffd" in text or bool(re.search(r"(?m)^##\s+\?+\s+(notas relacionadas|fontes consolidadas|fechamento)\b", lowered)))
3239
+ add(
3240
+ "extension_prompt_or_script_drift",
3241
+ path_lower == "gemini.md"
3242
+ or path_lower.startswith(("commands/", "skills/", "docs/", "hooks/", "scripts/", "src/"))
3243
+ or "/extensions/medical-notes-workbench/" in path_lower,
3244
+ )
3245
+ return codes
3246
+
3247
+
3248
+ def _normalized_command_events(value: Any, *, source: str) -> list[dict[str, Any]]:
3249
+ if not isinstance(value, list):
3250
+ return []
3251
+ events: list[dict[str, Any]] = []
3252
+ for item in value[:MAX_COMMAND_EVENTS]:
3253
+ if not isinstance(item, dict):
3254
+ continue
3255
+ event: dict[str, Any] = {
3256
+ "command_family": _code_slug(item.get("command_family") or "shell"),
3257
+ "command": redact_operational_text(item.get("command") or "", max_chars=2000),
3258
+ "exit_code": item.get("exit_code") if isinstance(item.get("exit_code"), int) else None,
3259
+ "status": _code_slug(item.get("status") or "unknown"),
3260
+ "source": str(item.get("source") or source),
3261
+ "capture_method": str(item.get("capture_method") or ""),
3262
+ }
3263
+ for key in ("workflow", "phase", "workflow_status", "blocked_reason"):
3264
+ if item.get(key):
3265
+ event[key] = str(item.get(key)) if key == "workflow" else _code_slug(item.get(key))
3266
+ if isinstance(item.get("workflow_exit_code"), int):
3267
+ event["workflow_exit_code"] = item.get("workflow_exit_code")
3268
+ for key in ("stdout_tail", "stderr_tail", "output_tail", "error"):
3269
+ if item.get(key):
3270
+ event[key] = redact_operational_text(item.get(key), max_chars=MAX_CONSOLE_TAIL_CHARS)
3271
+ events.append(event)
3272
+ return events
3273
+
3274
+
3275
+ def _normalized_hook_errors(value: Any, *, source: str) -> list[dict[str, Any]]:
3276
+ if not isinstance(value, list):
3277
+ return []
3278
+ errors: list[dict[str, Any]] = []
3279
+ for item in value[:MAX_HOOK_ERRORS]:
3280
+ if not isinstance(item, dict):
3281
+ continue
3282
+ error: dict[str, Any] = {
3283
+ "error_id": str(item.get("error_id") or ""),
3284
+ "recorded_at": str(item.get("recorded_at") or ""),
3285
+ "type": _code_slug(item.get("type") or "hook_internal_error"),
3286
+ "severity": _code_slug(item.get("severity") or "warning"),
3287
+ "mode": _code_slug(item.get("mode") or ""),
3288
+ "hook_event_name": _code_slug(item.get("hook_event_name") or ""),
3289
+ "tool_name": _code_slug(item.get("tool_name") or ""),
3290
+ "source": str(item.get("source") or source),
3291
+ }
3292
+ for key in ("message", "stack_tail", "stdout_tail", "stderr_tail", "error"):
3293
+ if item.get(key):
3294
+ error[key] = redact_operational_text(item.get(key), max_chars=MAX_HOOK_ERROR_CHARS)
3295
+ if isinstance(item.get("exit_code"), int):
3296
+ error["exit_code"] = item.get("exit_code")
3297
+ details = item.get("details")
3298
+ if isinstance(details, dict):
3299
+ error["details"] = _redact_hook_error_details(details)
3300
+ errors.append(error)
3301
+ return errors
3302
+
3303
+
3304
+ def _redact_hook_error_details(value: Any, *, depth: int = 0) -> Any:
3305
+ if depth > 4:
3306
+ return "[max-depth]"
3307
+ if isinstance(value, dict):
3308
+ out: dict[str, Any] = {}
3309
+ for key, item in list(value.items())[:40]:
3310
+ lower = str(key).lower()
3311
+ if lower in SECRET_KEYS:
3312
+ out[str(key)] = "[redacted]"
3313
+ elif lower in LONG_TEXT_KEYS:
3314
+ out[str(key)] = f"[{lower} omitted]"
3315
+ else:
3316
+ out[str(key)] = _redact_hook_error_details(item, depth=depth + 1)
3317
+ return out
3318
+ if isinstance(value, list):
3319
+ return [_redact_hook_error_details(item, depth=depth + 1) for item in value[:20]]
3320
+ if isinstance(value, str):
3321
+ return redact_operational_text(value, max_chars=600)
3322
+ if isinstance(value, (int, float, bool)) or value is None:
3323
+ return value
3324
+ return redact_operational_text(str(value), max_chars=300)
3325
+
3326
+
3327
+ def _merge_generated_scripts(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
3328
+ merged: list[dict[str, Any]] = []
3329
+ seen: set[tuple[str, str]] = set()
3330
+ for group in groups:
3331
+ for item in group:
3332
+ key = (str(item.get("path") or ""), str(item.get("sha256") or ""))
3333
+ if key in seen:
3334
+ continue
3335
+ seen.add(key)
3336
+ merged.append(item)
3337
+ return merged
3338
+
3339
+
3340
+ def _merge_command_events(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
3341
+ merged: list[dict[str, Any]] = []
3342
+ seen: set[tuple[str, str, Any]] = set()
3343
+ for group in groups:
3344
+ for item in group:
3345
+ key = (str(item.get("command") or ""), str(item.get("output_tail") or item.get("stderr_tail") or ""), item.get("exit_code"))
3346
+ if key in seen:
3347
+ continue
3348
+ seen.add(key)
3349
+ merged.append(item)
3350
+ return merged
3351
+
3352
+
3353
+ def _merge_hook_errors(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
3354
+ merged: list[dict[str, Any]] = []
3355
+ seen: set[tuple[str, str, str, str]] = set()
3356
+ for group in groups:
3357
+ for item in group:
3358
+ key = (
3359
+ str(item.get("type") or ""),
3360
+ str(item.get("mode") or ""),
3361
+ str(item.get("tool_name") or ""),
3362
+ str(item.get("message") or item.get("error") or ""),
3363
+ )
3364
+ if key in seen:
3365
+ continue
3366
+ seen.add(key)
3367
+ merged.append(item)
3368
+ return merged
3369
+
3370
+
3371
+ def _language_for_suffix(suffix: str) -> str:
3372
+ return {
3373
+ ".py": "python",
3374
+ ".js": "javascript",
3375
+ ".mjs": "javascript",
3376
+ ".cjs": "javascript",
3377
+ ".sh": "shell",
3378
+ ".ps1": "powershell",
3379
+ ".cmd": "batch",
3380
+ }.get(suffix, suffix.lstrip(".") or "text")
3381
+
3382
+
3383
+ def load_pre_update_snapshot_records(
3384
+ *,
3385
+ since: str = "30d",
3386
+ root: str | Path | None = None,
3387
+ limit: int = 5,
3388
+ ) -> list[dict[str, Any]]:
3389
+ cutoff = _parse_since(since)
3390
+ snapshots_dir = feedback_root(root) / "pre-update-snapshots"
3391
+ records: list[dict[str, Any]] = []
3392
+ for metadata_path in sorted(snapshots_dir.glob("*/snapshot.json"), reverse=True):
3393
+ try:
3394
+ snapshot = json.loads(metadata_path.read_text(encoding="utf-8"))
3395
+ except (OSError, json.JSONDecodeError):
3396
+ continue
3397
+ if snapshot.get("schema") != PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA:
3398
+ continue
3399
+ recorded_at = _parse_datetime(str(snapshot.get("recorded_at") or ""))
3400
+ if recorded_at and recorded_at < cutoff:
3401
+ continue
3402
+ record = pre_update_snapshot_record(snapshot)
3403
+ if record:
3404
+ records.append(record)
3405
+ if len(records) >= limit:
3406
+ break
3407
+ return list(reversed(records))
3408
+
3409
+
3410
+ def pre_update_snapshot_record(snapshot: dict[str, Any]) -> dict[str, Any] | None:
3411
+ snapshot_id = str(snapshot.get("snapshot_id") or "")
3412
+ snapshot_path = Path(str(snapshot.get("snapshot_path") or ""))
3413
+ if not snapshot_id or not snapshot_path.exists():
3414
+ return None
3415
+ patch_id = str(snapshot.get("patch_id") or "")
3416
+ patch_phase = str(snapshot.get("phase") or "")
3417
+ snapshot_reason = str(snapshot.get("reason") or patch_id or "pre_update_extension_snapshot")
3418
+ extension_diffs = _snapshot_extension_diffs(snapshot_path)
3419
+ generated_scripts = _normalized_generated_scripts(
3420
+ snapshot.get("generated_scripts", []),
3421
+ source="pre_update_snapshot",
3422
+ )
3423
+ if not extension_diffs and not generated_scripts:
3424
+ return None
3425
+ snapshot_changed_path_count = _safe_int(snapshot.get("changed_path_count"))
3426
+ snapshot_untracked_path_count = _safe_int(snapshot.get("untracked_path_count"))
3427
+ snapshot_changed_count = snapshot_changed_path_count + snapshot_untracked_path_count
3428
+ evidence_changed_count = len(extension_diffs) + len(generated_scripts)
3429
+ changed_count = max(snapshot_changed_count, evidence_changed_count)
3430
+ record = {
3431
+ "schema": RUN_RECORD_SCHEMA,
3432
+ "run_id": f"pre-update-extension-snapshot-{snapshot_id}",
3433
+ "recorded_at": str(snapshot.get("recorded_at") or now_iso()),
3434
+ "workflow": "/mednotes:telemetry",
3435
+ "source": "agent",
3436
+ "command": snapshot_reason,
3437
+ "exit_code": 0,
3438
+ "duration_ms": 0,
3439
+ "status": "completed_with_warnings" if changed_count else "completed",
3440
+ "phase": "pre-update-snapshot",
3441
+ "blocked_reason": "",
3442
+ "next_action": "Atualizar a extensao somente depois de confirmar que o snapshot pre-update foi recebido ou preservado.",
3443
+ "required_inputs": [],
3444
+ "human_decision_required": False,
3445
+ "dry_run": None,
3446
+ "apply": None,
3447
+ "payload_summary": {
3448
+ "counts": {
3449
+ "changed_path_count": _safe_int(snapshot.get("changed_path_count")),
3450
+ "untracked_path_count": _safe_int(snapshot.get("untracked_path_count")),
3451
+ "snapshot_changed_path_count": snapshot_changed_path_count,
3452
+ "snapshot_untracked_path_count": snapshot_untracked_path_count,
3453
+ "generated_script_count": len(generated_scripts),
3454
+ },
3455
+ "warnings": ["pre_update_extension_snapshot_captured"],
3456
+ "errors": [],
3457
+ "required_inputs": [],
3458
+ "relevant_paths": snapshot.get("changed_paths", [])[:24],
3459
+ "path_hashes": {},
3460
+ "signals": ["extension.pre_update_snapshot"],
3461
+ "status": "completed_with_warnings" if changed_count else "completed",
3462
+ "phase": "pre-update-snapshot",
3463
+ },
3464
+ "diagnostic_context": {
3465
+ "root_cause_code": "extension.pre_update_snapshot",
3466
+ "root_cause_label": "Snapshot pre-update da extensao",
3467
+ "recovery_command": "Preservar estes patches antes de rodar gemini extensions update medical-notes-workbench.",
3468
+ "missing_inputs": [],
3469
+ "decision_context": {"types": [], "decisions": []},
3470
+ "blocker_context": {"codes": [], "counts": {}, "summaries": [], "samples": [], "routes": []},
3471
+ "contract_gaps": [],
3472
+ },
3473
+ "environment_context": {
3474
+ "extension_integrity": {
3475
+ "schema": PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA,
3476
+ "drift_detected": bool(changed_count),
3477
+ "snapshot_id": snapshot_id,
3478
+ "snapshot_path": str(snapshot_path),
3479
+ "patch_id": patch_id,
3480
+ "phase": patch_phase,
3481
+ "reason": snapshot_reason,
3482
+ "extension_name": str(snapshot.get("extension_name") or ""),
3483
+ "current_version": str(snapshot.get("current_version") or ""),
3484
+ "target_version": str(snapshot.get("target_version") or ""),
3485
+ "git_head": str(snapshot.get("git_head") or ""),
3486
+ "git_available": bool(snapshot.get("git_available")),
3487
+ "extension_path": str(snapshot.get("extension_path") or ""),
3488
+ "summary": {
3489
+ "changed_count": changed_count,
3490
+ "modified_count": max(snapshot_changed_path_count, len(extension_diffs)),
3491
+ "unexpected_count": snapshot_untracked_path_count,
3492
+ "snapshot_changed_path_count": snapshot_changed_path_count,
3493
+ "snapshot_untracked_path_count": snapshot_untracked_path_count,
3494
+ "snapshot_changed_count": snapshot_changed_count,
3495
+ "snapshot_changed_path_count_mismatch": bool(extension_diffs and not snapshot_changed_count),
3496
+ },
3497
+ "extension_diffs": extension_diffs,
3498
+ }
3499
+ },
3500
+ "diagnostic_snippets": [],
3501
+ "extension_diffs": extension_diffs,
3502
+ "generated_scripts": generated_scripts,
3503
+ }
3504
+ _apply_generated_script_risk_signals(record)
3505
+ return attach_telemetry_evidence(record, send_path="pre_update_snapshot")
3506
+
3507
+
3508
+ def _snapshot_extension_diffs(snapshot_path: Path) -> list[dict[str, Any]]:
3509
+ diffs: list[dict[str, Any]] = []
3510
+ for filename, change in (
3511
+ ("tracked.diff", "pre_update_tracked"),
3512
+ ("staged.diff", "pre_update_staged"),
3513
+ ("untracked.diff", "pre_update_untracked"),
3514
+ ):
3515
+ path = snapshot_path / filename
3516
+ try:
3517
+ patch = path.read_text(encoding="utf-8")
3518
+ except OSError:
3519
+ continue
3520
+ patch = _filter_pre_update_patch_noise(patch)
3521
+ if not patch.strip():
3522
+ continue
3523
+ sanitized = redact_operational_text(patch, max_chars=MAX_PRE_UPDATE_PATCH_CHARS)
3524
+ diffs.append(
3525
+ {
3526
+ "path": f"pre-update/{filename}",
3527
+ "kind": "pre_update_snapshot",
3528
+ "change": change,
3529
+ "patch": sanitized,
3530
+ "truncated": len(sanitized) < len(patch),
3531
+ }
3532
+ )
3533
+ return diffs
3534
+
3535
+
3536
+ def _filter_pre_update_patch_noise(patch: str) -> str:
3537
+ blocks = re.split(r"(?m)(?=^diff --git )", patch)
3538
+ kept: list[str] = []
3539
+ for block in blocks:
3540
+ if not block.strip():
3541
+ continue
3542
+ normalized = block.replace("\\", "/").lower()
3543
+ if "git binary patch" in normalized:
3544
+ continue
3545
+ if ".pyc" in normalized or any(part in normalized for part in PRE_UPDATE_PATCH_NOISE_PARTS):
3546
+ continue
3547
+ kept.append(block)
3548
+ return "\n".join(item.rstrip("\n") for item in kept if item.strip()) + ("\n" if kept else "")
3549
+
3550
+
3551
+ def _hash_text(value: str) -> str:
3552
+ return hashlib.sha256(value.encode("utf-8")).hexdigest() if value else ""
3553
+
3554
+
3555
+ def _environment_context(*, root: str | Path | None = None) -> dict[str, Any]:
3556
+ try:
3557
+ from mednotes.platform.feedback.integrity import safe_check_extension_integrity
3558
+
3559
+ return {
3560
+ "extension_integrity": safe_check_extension_integrity(
3561
+ cache_dir=feedback_root(root) / "integrity",
3562
+ include_diff=True,
3563
+ )
3564
+ }
3565
+ except Exception:
3566
+ return {}
3567
+
3568
+
3569
+ def _run_id(workflow: str) -> str:
3570
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
3571
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", workflow).strip("-").lower() or "workflow"
3572
+ suffix = hashlib.sha256(f"{workflow}:{time.time_ns()}".encode()).hexdigest()[:8]
3573
+ return f"{stamp}-{slug}-{suffix}"
3574
+
3575
+
3576
+ def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
3577
+ tmp = path.with_suffix(path.suffix + ".tmp")
3578
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
3579
+ tmp.replace(path)
3580
+
3581
+
3582
+ def build_backlog(*, since: str = "30d", root: str | Path | None = None) -> dict[str, Any]:
3583
+ records = load_records(since=since, root=root)
3584
+ grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
3585
+ for record in records:
3586
+ summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
3587
+ raw_signals = summary.get("signals") if isinstance(summary.get("signals"), list) else []
3588
+ signals = {str(signal) for signal in raw_signals if str(signal).strip()}
3589
+ diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
3590
+ root_signal = str(diagnostic.get("root_cause_code") or "")
3591
+ if root_signal and root_signal != "no_issue_detected":
3592
+ signals.add(root_signal)
3593
+ for signal in signals:
3594
+ if signal == "dry_run":
3595
+ continue
3596
+ _append_backlog_group(grouped, record, signal)
3597
+ for record in _dry_runs_without_apply(records):
3598
+ signal = "agent.dry_run_without_apply" if record.get("source") == "agent" else "dry_run_without_apply"
3599
+ _append_backlog_group(grouped, record, signal)
3600
+ for workflow, group in _retry_loop_groups(records):
3601
+ for record in group:
3602
+ _append_backlog_group(grouped, record, "agent.retry_loop", workflow=workflow)
3603
+ for workflow, signal, group in _retry_without_input_change_groups(records):
3604
+ for record in group:
3605
+ _append_backlog_group(grouped, record, signal, workflow=workflow)
3606
+
3607
+ items = [_backlog_item(workflow, signal, group) for (workflow, signal, _group_key), group in grouped.items()]
3608
+ items.sort(key=lambda item: (-_severity_rank(item["severity"]), -item["occurrence_count"], item["workflow"], item["signal"]))
3609
+ return {
3610
+ "schema": BACKLOG_SCHEMA,
3611
+ "generated_at": now_iso(),
3612
+ "since": since,
3613
+ "run_count": len(records),
3614
+ "items": items,
3615
+ }
3616
+
3617
+
3618
+ def _append_backlog_group(
3619
+ grouped: dict[tuple[str, str, str], list[dict[str, Any]]],
3620
+ record: dict[str, Any],
3621
+ signal: str,
3622
+ *,
3623
+ workflow: str | None = None,
3624
+ ) -> None:
3625
+ workflow_value = workflow or str(record.get("workflow") or "unknown")
3626
+ grouping = _record_grouping_dimensions(record, signal)
3627
+ group_key = "|".join(
3628
+ str(grouping.get(key) or "")
3629
+ for key in ("phase", "root_cause", "target_canonical", "input_hash", "error_hash")
3630
+ )
3631
+ grouped[(workflow_value, signal, group_key)].append(record)
3632
+
3633
+
3634
+ def load_records(*, since: str = "30d", root: str | Path | None = None) -> list[dict[str, Any]]:
3635
+ cutoff = _parse_since(since)
3636
+ runs_dir = feedback_root(root) / "runs"
3637
+ records: list[dict[str, Any]] = []
3638
+ for path in sorted(runs_dir.glob("*.json")):
3639
+ try:
3640
+ data = json.loads(path.read_text(encoding="utf-8"))
3641
+ except (OSError, json.JSONDecodeError):
3642
+ continue
3643
+ if data.get("schema") != RUN_RECORD_SCHEMA:
3644
+ continue
3645
+ recorded_at = _parse_datetime(str(data.get("recorded_at") or ""))
3646
+ if recorded_at and recorded_at < cutoff:
3647
+ continue
3648
+ data.setdefault("record_path", str(path))
3649
+ records.append(data)
3650
+ return records
3651
+
3652
+
3653
+ def _parse_since(value: str) -> datetime:
3654
+ value = str(value or "30d").strip()
3655
+ match = re.fullmatch(r"(\d+)([dhm])", value)
3656
+ now = datetime.now(UTC)
3657
+ if match:
3658
+ amount = int(match.group(1))
3659
+ unit = match.group(2)
3660
+ if unit == "d":
3661
+ return now - timedelta(days=amount)
3662
+ if unit == "h":
3663
+ return now - timedelta(hours=amount)
3664
+ return now - timedelta(minutes=amount)
3665
+ parsed = _parse_datetime(value)
3666
+ return parsed or (now - timedelta(days=30))
3667
+
3668
+
3669
+ def _parse_datetime(value: str) -> datetime | None:
3670
+ if not value:
3671
+ return None
3672
+ try:
3673
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
3674
+ except ValueError:
3675
+ return None
3676
+ if parsed.tzinfo is None:
3677
+ return parsed.replace(tzinfo=UTC)
3678
+ return parsed.astimezone(UTC)
3679
+
3680
+
3681
+ def _dry_runs_without_apply(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
3682
+ apply_seen: set[str] = set()
3683
+ dry_runs: list[dict[str, Any]] = []
3684
+ for record in records:
3685
+ workflow = str(record.get("workflow") or "")
3686
+ if record.get("apply") is True or (record.get("dry_run") is False and record.get("exit_code") == 0):
3687
+ apply_seen.add(workflow)
3688
+ if record.get("dry_run") is True and record.get("exit_code") == 0:
3689
+ dry_runs.append(record)
3690
+ return [record for record in dry_runs if str(record.get("workflow") or "") not in apply_seen]
3691
+
3692
+
3693
+ def _retry_loop_groups(records: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
3694
+ grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
3695
+ for record in records:
3696
+ status = str(record.get("status") or "")
3697
+ if status not in {"blocked", "failed", "error"}:
3698
+ continue
3699
+ workflow = str(record.get("workflow") or "unknown")
3700
+ phase = str(record.get("phase") or "unknown")
3701
+ diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
3702
+ cause = str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or status)
3703
+ grouped[(workflow, phase, cause)].append(record)
3704
+ loops: list[tuple[str, list[dict[str, Any]]]] = []
3705
+ for (workflow, _phase, _cause), group in grouped.items():
3706
+ if len(group) >= 3:
3707
+ loops.append((workflow, group))
3708
+ return loops
3709
+
3710
+
3711
+ def _retry_without_input_change_groups(records: list[dict[str, Any]]) -> list[tuple[str, str, list[dict[str, Any]]]]:
3712
+ grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = defaultdict(list)
3713
+ for record in records:
3714
+ status = str(record.get("status") or "")
3715
+ if status not in {"blocked", "failed", "error"}:
3716
+ continue
3717
+ fingerprint = _record_input_fingerprint(record)
3718
+ if not fingerprint:
3719
+ continue
3720
+ workflow = str(record.get("workflow") or "unknown")
3721
+ phase = str(record.get("phase") or "unknown")
3722
+ diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
3723
+ cause = str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or status)
3724
+ grouped[(workflow, phase, cause, fingerprint)].append(record)
3725
+
3726
+ loops: list[tuple[str, str, list[dict[str, Any]]]] = []
3727
+ for (workflow, _phase, _cause, _fingerprint), group in grouped.items():
3728
+ if len(group) >= 2:
3729
+ signal = "agent.retry_without_input_change" if any(record.get("source") == "agent" for record in group) else "retry_without_input_change"
3730
+ loops.append((workflow, signal, group))
3731
+ return loops
3732
+
3733
+
3734
+ def _record_input_fingerprint(record: dict[str, Any]) -> str:
3735
+ summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
3736
+ path_hashes = summary.get("path_hashes") if isinstance(summary.get("path_hashes"), dict) else {}
3737
+ artifact_state = summary.get("artifact_state") if isinstance(summary.get("artifact_state"), dict) else {}
3738
+ components: dict[str, Any] = {}
3739
+ if path_hashes:
3740
+ components["path_hashes"] = sorted((str(key), str(value)) for key, value in path_hashes.items())
3741
+ if artifact_state:
3742
+ components["artifact_state"] = sorted((str(key), str(value)) for key, value in artifact_state.items())
3743
+ if not components:
3744
+ return ""
3745
+ return hashlib.sha256(json.dumps(components, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
3746
+
3747
+
3748
+ def _record_grouping_dimensions(record: dict[str, Any], signal: str) -> dict[str, str]:
3749
+ summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
3750
+ diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
3751
+ return {
3752
+ "phase": str(record.get("phase") or summary.get("phase") or "unknown"),
3753
+ "root_cause": str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or signal or "unknown"),
3754
+ "target_canonical": _record_canonical_target(record, diagnostic, summary),
3755
+ "input_hash": _record_input_fingerprint(record)[:12],
3756
+ "error_hash": _record_error_fingerprint(record, diagnostic, summary)[:12],
3757
+ }
3758
+
3759
+
3760
+ def _record_canonical_target(
3761
+ record: dict[str, Any],
3762
+ diagnostic: dict[str, Any],
3763
+ summary: dict[str, Any],
3764
+ ) -> str:
3765
+ candidates: list[Any] = [
3766
+ record.get("canonical_target"),
3767
+ record.get("target_canonical"),
3768
+ record.get("target_key"),
3769
+ summary.get("canonical_target"),
3770
+ summary.get("target_canonical"),
3771
+ summary.get("target_key"),
3772
+ ]
3773
+ error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
3774
+ candidates.append(error_context.get("affected_artifact"))
3775
+ decision_context = _json_object_field(_json_object_view(diagnostic), "decision_context")
3776
+ for decision in _json_list_field(decision_context, "decisions"):
3777
+ decision_view = _json_object_view(decision)
3778
+ if decision_view:
3779
+ candidates.extend(
3780
+ [
3781
+ _json_text(decision_view, "target_key"),
3782
+ _json_text(decision_view, "canonical_target"),
3783
+ _json_text(decision_view, "affected_artifact"),
3784
+ ]
3785
+ )
3786
+ blocker_context = diagnostic.get("blocker_context") if isinstance(diagnostic.get("blocker_context"), dict) else {}
3787
+ for key in ("samples", "summaries", "routes"):
3788
+ values_candidate = blocker_context.get(key)
3789
+ values = values_candidate if isinstance(values_candidate, list) else []
3790
+ for item in values[:MAX_DIAGNOSTIC_ITEMS]:
3791
+ if isinstance(item, dict):
3792
+ candidates.extend(
3793
+ [
3794
+ item.get("target_key"),
3795
+ item.get("canonical_target"),
3796
+ item.get("target_canonical"),
3797
+ item.get("keep_path"),
3798
+ item.get("path"),
3799
+ ]
3800
+ )
3801
+ for value in candidates:
3802
+ text = str(value or "").strip()
3803
+ if text:
3804
+ return redact_snippet(text, max_chars=160)
3805
+ return ""
3806
+
3807
+
3808
+ def _record_error_fingerprint(
3809
+ record: dict[str, Any],
3810
+ diagnostic: dict[str, Any],
3811
+ summary: dict[str, Any],
3812
+ ) -> str:
3813
+ error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
3814
+ values: list[Any] = [
3815
+ diagnostic.get("root_cause_code"),
3816
+ record.get("blocked_reason"),
3817
+ error_context.get("error_summary"),
3818
+ error_context.get("root_cause"),
3819
+ ]
3820
+ if isinstance(summary.get("errors"), list):
3821
+ values.extend(summary["errors"][:5])
3822
+ if isinstance(summary.get("warnings"), list):
3823
+ values.extend(summary["warnings"][:5])
3824
+ command_events = record.get("command_events") if isinstance(record.get("command_events"), list) else []
3825
+ for event in command_events[:3]:
3826
+ if isinstance(event, dict):
3827
+ values.extend([event.get("error"), event.get("stderr_tail"), event.get("output_tail")])
3828
+ text = "\n".join(redact_snippet(value, max_chars=600) for value in values if str(value or "").strip())
3829
+ return hashlib.sha256(text.encode("utf-8")).hexdigest() if text else ""
3830
+
3831
+
3832
+ def _backlog_item(workflow: str, signal: str, records: list[dict[str, Any]]) -> dict[str, Any]:
3833
+ count = len(records)
3834
+ statuses = Counter(str(record.get("status") or "unknown") for record in records)
3835
+ sample_run_ids = [str(record.get("run_id")) for record in records[:5]]
3836
+ title, improvement_type, recommendation, suggested_test = _recommendation(signal)
3837
+ severity = _severity_for(signal, count, statuses)
3838
+ grouping = _merged_grouping_dimensions(records, signal)
3839
+ evidence_bits = []
3840
+ grouping_evidence = _format_grouping_evidence(grouping)
3841
+ if grouping_evidence:
3842
+ evidence_bits.append(grouping_evidence)
3843
+ blocked = Counter(str(record.get("blocked_reason") or "") for record in records if record.get("blocked_reason"))
3844
+ if blocked:
3845
+ evidence_bits.append("blocked_reason: " + ", ".join(f"{key}={value}" for key, value in blocked.most_common(3)))
3846
+ evidence_bits.append("status: " + ", ".join(f"{key}={value}" for key, value in statuses.most_common(4)))
3847
+ item = {
3848
+ "id": hashlib.sha256(
3849
+ json.dumps({"workflow": workflow, "signal": signal, "grouping": grouping}, sort_keys=True).encode("utf-8")
3850
+ ).hexdigest()[:12],
3851
+ "title": title,
3852
+ "workflow": workflow,
3853
+ "signal": signal,
3854
+ "grouping": grouping,
3855
+ "occurrence_count": count,
3856
+ "severity": severity,
3857
+ "improvement_type": improvement_type,
3858
+ "evidence": "; ".join(evidence_bits),
3859
+ "recommendation": recommendation,
3860
+ "suggested_test": suggested_test,
3861
+ "sample_run_ids": sample_run_ids,
3862
+ }
3863
+ retry_governance = _group_retry_governance(records, signal)
3864
+ if retry_governance:
3865
+ item["retry_governance"] = retry_governance
3866
+ item["evidence"] += (
3867
+ f"; retry_budget: {retry_governance['category']}<={retry_governance['max_attempts']} "
3868
+ f"attempt(s)"
3869
+ )
3870
+ return item
3871
+
3872
+
3873
+ def _merged_grouping_dimensions(records: list[dict[str, Any]], signal: str) -> dict[str, str]:
3874
+ values = [_record_grouping_dimensions(record, signal) for record in records]
3875
+ grouping: dict[str, str] = {}
3876
+ for key in ("phase", "root_cause", "target_canonical", "input_hash", "error_hash"):
3877
+ counter = Counter(str(item.get(key) or "") for item in values if str(item.get(key) or ""))
3878
+ if not counter:
3879
+ grouping[key] = ""
3880
+ elif len(counter) == 1:
3881
+ grouping[key] = next(iter(counter))
3882
+ else:
3883
+ grouping[key] = "mixed:" + ",".join(f"{value}={count}" for value, count in counter.most_common(3))
3884
+ return grouping
3885
+
3886
+
3887
+ def _format_grouping_evidence(grouping: dict[str, str]) -> str:
3888
+ parts = [
3889
+ f"phase={grouping.get('phase')}" if grouping.get("phase") else "",
3890
+ f"root_cause={grouping.get('root_cause')}" if grouping.get("root_cause") else "",
3891
+ f"target={grouping.get('target_canonical')}" if grouping.get("target_canonical") else "",
3892
+ f"input_hash={grouping.get('input_hash')}" if grouping.get("input_hash") else "",
3893
+ f"error_hash={grouping.get('error_hash')}" if grouping.get("error_hash") else "",
3894
+ ]
3895
+ return "group: " + "; ".join(item for item in parts if item) if any(parts) else ""
3896
+
3897
+
3898
+ def _group_retry_governance(records: list[dict[str, Any]], signal: str) -> dict[str, Any]:
3899
+ if "retry" not in signal and "dry_run_without_apply" not in signal:
3900
+ return {}
3901
+ categories = Counter()
3902
+ selected: dict[str, Any] = {}
3903
+ for record in records:
3904
+ diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
3905
+ governance = diagnostic.get("retry_governance") if isinstance(diagnostic.get("retry_governance"), dict) else {}
3906
+ category = str(governance.get("category") or "")
3907
+ if category:
3908
+ categories[category] += 1
3909
+ if not selected:
3910
+ selected = governance
3911
+ if not selected:
3912
+ selected = RETRY_BUDGETS.get("dry_run" if "dry_run" in signal else "generic", {})
3913
+ category = "dry_run" if "dry_run" in signal else "generic"
3914
+ else:
3915
+ category = categories.most_common(1)[0][0]
3916
+ if selected.get("category") != category:
3917
+ selected = {"category": category, **RETRY_BUDGETS.get(category, {})}
3918
+ return {
3919
+ "category": category,
3920
+ "max_attempts": int(selected.get("max_attempts", 1)),
3921
+ "rule": str(selected.get("rule") or "Retry deve seguir next_action e mudar input relevante antes de repetir."),
3922
+ }
3923
+
3924
+
3925
+ def _recommendation(signal: str) -> tuple[str, str, str, str]:
3926
+ if signal.startswith("agent."):
3927
+ return _agent_recommendation(signal)
3928
+ if signal == ENVIRONMENT_BLOCKER_CODE:
3929
+ return (
3930
+ "Ambiente Windows/path/venv bloqueando workflow",
3931
+ "setup/preflight",
3932
+ "Guiar o agente para /mednotes:setup, bootstrap/reset oficial e retry apenas apos ambiente corrigido, sem editar scripts/runbooks como workaround.",
3933
+ "Fixture com erro de uv/PowerShell/path Windows deve gerar environment_blocker.windows_path_or_venv e error_context estruturado.",
3934
+ )
3935
+ if signal == "canonical_merge_required":
3936
+ return (
3937
+ "Merge canônico necessário",
3938
+ "workflow/canonical-merge",
3939
+ "Fundir informação nova no alvo canônico, preservar múltiplas referências e validar coverage/proveniência antes de publicar.",
3940
+ "Payload com canonical_merge_required deve agrupar por alvo canônico e sugerir merge antes do publish.",
3941
+ )
3942
+ if signal == "human_decision_required.ambiguous_canonical_target":
3943
+ return (
3944
+ "Escolha de alvo canônico pendente",
3945
+ "workflow/canonical-merge",
3946
+ "Coletar escolha explícita do alvo canônico, ajustar note_plan e seguir a rota indicada sem lançar architects antes da decisão.",
3947
+ "Payload com alvo canônico ambíguo deve manter human_decision_packet e continue_after_choice.",
3948
+ )
3949
+ if signal == "provenance_gap":
3950
+ return (
3951
+ "Lacuna de proveniência multi-fonte",
3952
+ "contract/provenance",
3953
+ "Bloquear publish até completar coverage.sources e Fontes Consolidadas para todas as fontes novas.",
3954
+ "Payload com provenance_gap deve preservar error_context e não marcar raw como processado.",
3955
+ )
3956
+ if signal == "batch_state_mismatch":
3957
+ return (
3958
+ "Artefatos de lote incompatíveis",
3959
+ "contract/batch-state",
3960
+ "Regenerar coverage, manifest e dry-run a partir do note_plan atual antes de avançar.",
3961
+ "Artefatos com hashes divergentes devem bloquear e agrupar por input_hash.",
3962
+ )
3963
+ if signal == "missing_next_action":
3964
+ return (
3965
+ "Adicionar next_action acionavel quando houver warning, bloqueio ou falha",
3966
+ "mensagem/contrato",
3967
+ "Atualizar o payload do workflow para sempre explicar o comando seguro seguinte ou a decisao humana pendente.",
3968
+ "Fixture com status nao-concluido deve conter next_action nao vazio.",
3969
+ )
3970
+ if signal == "human_decision_required":
3971
+ return (
3972
+ "Reduzir ou explicitar decisoes humanas recorrentes",
3973
+ "UX/guardrail",
3974
+ "Agrupar decisoes repetidas, melhorar opcoes visiveis e identificar casos que podem virar regra deterministica segura.",
3975
+ "Fixture com human_decision_required deve conter human_decision_packet e resume_action.",
3976
+ )
3977
+ if signal == "dry_run_without_apply":
3978
+ return (
3979
+ "Dry-run sem continuidade detectado",
3980
+ "UX",
3981
+ "Melhorar o resumo de preview para deixar a confirmacao seguinte mais obvia e registrar quando o usuario descarta o plano.",
3982
+ "Fixture de dry-run deve sugerir apply/confirmacao ou descarte explicito.",
3983
+ )
3984
+ if signal == "retry_without_input_change":
3985
+ return (
3986
+ "Retry repetido sem mudanca de input",
3987
+ "guardrail/observabilidade",
3988
+ "Comparar hashes do artefato consumido e interromper repeticao quando a fase falha de novo sem coverage/manifest/note_plan alterado.",
3989
+ "Dois bloqueios iguais com os mesmos hashes de input devem gerar retry_without_input_change.",
3990
+ )
3991
+ if signal == "anki_model_validation_failed":
3992
+ return (
3993
+ "Modelo Anki bloqueou criacao de cards",
3994
+ "setup/preflight",
3995
+ "Antecipar validacao/provisionamento de modelos antes da etapa de formulacao ou tornar a recuperacao mais direta.",
3996
+ "Fixture com modelo incompleto deve bloquear antes de montar notas Anki.",
3997
+ )
3998
+ if signal.startswith("required_input:coverage_path"):
3999
+ return (
4000
+ "Coverage ausente ou incompleto bloqueou publicacao",
4001
+ "guardrail/docs",
4002
+ "Melhorar preflight e mensagem da fase anterior para garantir coverage derivado do note_plan antes do stage/publish.",
4003
+ "Fixture de publish sem coverage_path deve falhar antes de mutar e recomendar stage-note --coverage.",
4004
+ )
4005
+ if signal.startswith("blocked:graph_blockers"):
4006
+ return (
4007
+ "Blockers de grafo recorrentes",
4008
+ "guardrail",
4009
+ "Priorizar uma regra deterministica em fix-wiki ou um resumo melhor com amostras e rota de resolucao.",
4010
+ "Fixture com blocker de grafo deve gerar rota em blocker_resolution e pular linker real.",
4011
+ )
4012
+ if signal.startswith("blocked:"):
4013
+ reason = signal.split(":", 1)[1]
4014
+ return (
4015
+ f"Bloqueio recorrente: {reason}",
4016
+ "guardrail",
4017
+ "Verificar se o bloqueio pode ser antecipado por preflight, explicado melhor ou coberto por teste patologico.",
4018
+ f"Fixture deve reproduzir {reason} e confirmar status/blocked_reason/next_action.",
4019
+ )
4020
+ if signal == "warnings":
4021
+ return (
4022
+ "Warnings recorrentes nos workflows",
4023
+ "qualidade",
4024
+ "Separar warning aceitavel de warning que merece correcao automatica, doc ou teste de regressao.",
4025
+ "Fixture deve preservar warning esperado e evitar regressao silenciosa.",
4026
+ )
4027
+ if signal == "errors":
4028
+ return (
4029
+ "Erros recorrentes nos workflows",
4030
+ "bug/preflight",
4031
+ "Agrupar mensagens de erro e mover a falha para uma validacao mais cedo quando possivel.",
4032
+ "Fixture com erro conhecido deve retornar JSON/exit code contratual sem traceback.",
4033
+ )
4034
+ return (
4035
+ f"Padrao recorrente: {signal}",
4036
+ "investigacao",
4037
+ "Revisar os runs amostrados e transformar o padrao em ajuste de contrato, mensagem, guardrail ou teste.",
4038
+ "Adicionar fixture cobrindo o padrao recorrente.",
4039
+ )
4040
+
4041
+
4042
+ def _severity_for(signal: str, count: int, statuses: Counter[str]) -> str:
4043
+ if signal.startswith("agent."):
4044
+ if signal in {
4045
+ "agent.retry_loop",
4046
+ "agent.script_or_prompt_drift",
4047
+ "agent.unexpected_mutation",
4048
+ "agent.missing_error_context",
4049
+ "agent.missing_agent_metrics",
4050
+ "agent.timeout_or_max_turns",
4051
+ }:
4052
+ return "high"
4053
+ if statuses.get("failed") or statuses.get("error") or statuses.get("blocked"):
4054
+ return "high" if count >= 2 else "medium"
4055
+ return "medium" if count >= 2 else "low"
4056
+ if signal == ENVIRONMENT_BLOCKER_CODE:
4057
+ return "high" if statuses.get("failed") or statuses.get("blocked") or count >= 2 else "medium"
4058
+ if signal.startswith("blocked:") or signal == "errors" or statuses.get("failed"):
4059
+ return "high" if count >= 2 else "medium"
4060
+ if signal in {"human_decision_required", "anki_model_validation_failed"}:
4061
+ return "high" if count >= 3 else "medium"
4062
+ if signal == "retry_without_input_change":
4063
+ return "high" if count >= 2 else "medium"
4064
+ if signal == "missing_next_action":
4065
+ return "medium"
4066
+ return "medium" if count >= 3 else "low"
4067
+
4068
+
4069
+ def _severity_rank(severity: str) -> int:
4070
+ return {"high": 3, "medium": 2, "low": 1}.get(severity, 0)
4071
+
4072
+
4073
+ def _agent_recommendation(signal: str) -> tuple[str, str, str, str]:
4074
+ labels = {
4075
+ "agent.retry_loop": (
4076
+ "Loop ou retry improdutivo do agente",
4077
+ "agent-behavior/loop",
4078
+ "Identificar a fase repetida, antecipar o bloqueio e instruir o agente a parar com next_action claro.",
4079
+ "Fixture com 3 falhas iguais no mesmo workflow/fase deve gerar agent.retry_loop.",
4080
+ ),
4081
+ "agent.script_or_prompt_drift": (
4082
+ "Agente alterou prompt/runbook/script local",
4083
+ "agent-behavior/integrity",
4084
+ "Comparar o drift, decidir se vira update publicado ou rollback/reinstalação da extensão.",
4085
+ "Fixture com source=agent e integrity drift em script/command/skill deve gerar agent.script_or_prompt_drift.",
4086
+ ),
4087
+ "agent.ignored_next_action": (
4088
+ "Agente ignorou next_action",
4089
+ "agent-behavior/contract",
4090
+ "Reforçar o contrato de resposta para executar apenas a rota segura indicada pelo workflow.",
4091
+ "Payload com agent_events ignored_next_action deve aparecer no backlog e no email.",
4092
+ ),
4093
+ "agent.wrong_phase": (
4094
+ "Agente executou fase errada",
4095
+ "agent-behavior/phase",
4096
+ "Deixar a fase permitida explícita no resumo e bloquear mutações fora da fase esperada quando possível.",
4097
+ "Payload com wrong_phase deve preservar fase esperada e ação de recuperação redigidas.",
4098
+ ),
4099
+ "agent.unexpected_mutation": (
4100
+ "Agente fez mutação inesperada",
4101
+ "agent-behavior/safety",
4102
+ "Adicionar preflight/guardrail para impedir escrita fora do workflow ou da confirmação esperada.",
4103
+ "Payload com unexpected_mutation deve virar severidade alta.",
4104
+ ),
4105
+ "agent.command_failed": (
4106
+ "Comando conduzido pelo agente falhou",
4107
+ "agent-behavior/command",
4108
+ "Agrupar família de comando, erro e próxima ação para transformar falha repetida em preflight ou teste.",
4109
+ "Payload com command_failed deve incluir command_family e snippet redigido.",
4110
+ ),
4111
+ "agent.workflow_blocked": (
4112
+ "Agente encontrou workflow bloqueado",
4113
+ "agent-behavior/blocker",
4114
+ "Transformar bloqueios recorrentes em mensagem de parada, rota de recuperação ou correção determinística.",
4115
+ "Payload com workflow_blocked deve preservar blocked_reason e next_action esperado.",
4116
+ ),
4117
+ "agent.missing_error_context": (
4118
+ "Agente bloqueou sem error_context",
4119
+ "agent-behavior/contract",
4120
+ "Exigir error_context em todo bloqueio/falha agent-driven para que o próximo retry tenha causa, artefato e escopo claros.",
4121
+ "Run source=agent bloqueado sem error_context deve gerar agent.missing_error_context.",
4122
+ ),
4123
+ "agent.missing_agent_metrics": (
4124
+ "Subagente bloqueou sem agent_metrics",
4125
+ "agent-behavior/contract",
4126
+ "Exigir agent_metrics mesmo em blocked packet para separar prompt ruim, timeout real e escopo excessivo.",
4127
+ "Timeout/max_turns sem agent_metrics deve gerar agent.missing_agent_metrics.",
4128
+ ),
4129
+ "agent.timeout_or_max_turns": (
4130
+ "Subagente estourou timeout ou max_turns",
4131
+ "agent-behavior/timeout",
4132
+ "Parar retry cego, reduzir o work item ou criar recuperação oficial antes de rodar outro subagente.",
4133
+ "Payload agentico bloqueado por timeout_or_max_turns deve gerar agent.timeout_or_max_turns.",
4134
+ ),
4135
+ "agent.manual_intervention": (
4136
+ "Agente precisou de intervenção manual",
4137
+ "agent-behavior/manual",
4138
+ "Avaliar se a decisão pode virar default seguro, checklist preflight ou pergunta estruturada.",
4139
+ "Payload com manual_intervention deve registrar ação e resultado sem conteúdo sensível.",
4140
+ ),
4141
+ "agent.dry_run_without_apply": (
4142
+ "Dry-run limpo sem apply posterior",
4143
+ "agent-behavior/follow-through",
4144
+ "Garantir que o agente execute o apply indicado ou registre explicitamente por que parou após o dry-run.",
4145
+ "Sequência com dry-run agentico limpo sem apply posterior deve gerar agent.dry_run_without_apply.",
4146
+ ),
4147
+ "agent.retry": (
4148
+ "Retry do agente registrado",
4149
+ "agent-behavior/retry",
4150
+ "Distinguir retry útil de loop improdutivo e limitar repetição antes de pedir decisão humana.",
4151
+ "Payload com retry deve ser redigido e agrupável por fase.",
4152
+ ),
4153
+ "agent.retry_without_input_change": (
4154
+ "Agente repetiu sem mudar input",
4155
+ "agent-behavior/loop",
4156
+ "Fazer o agente parar após a segunda falha com os mesmos hashes e seguir o error_context em vez de tentar de novo.",
4157
+ "Dois bloqueios agenticos iguais com mesmos hashes devem gerar agent.retry_without_input_change.",
4158
+ ),
4159
+ }
4160
+ if signal in labels:
4161
+ return labels[signal]
4162
+ event = signal.split(".", 1)[1] if "." in signal else signal
4163
+ return (
4164
+ f"Comportamento do agente: {event}",
4165
+ "agent-behavior/investigacao",
4166
+ "Revisar os eventos do agente e transformar o padrão em contrato, guardrail ou teste.",
4167
+ "Adicionar fixture cobrindo agent_events para esse padrão.",
4168
+ )