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,2293 @@
1
+ """Shared operational API contract for workflow outputs and feedback records."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import unquote
9
+
10
+ from pydantic import ConfigDict, Field, StrictStr
11
+
12
+ from mednotes.kernel.agent_directive import AgentDirective, AgentDirectiveControl
13
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
14
+
15
+ FSM_FIRST_SCHEMAS = {
16
+ "medical-notes-workbench.fix-wiki-fsm-result.v1",
17
+ "medical-notes-workbench.flashcards-fsm-result.v1",
18
+ "medical-notes-workbench.link-fsm-result.v1",
19
+ "medical-notes-workbench.link-related-fsm-result.v1",
20
+ "medical-notes-workbench.process-chats-fsm-result.v1",
21
+ "medical-notes-workbench.setup-fsm-result.v1",
22
+ "medical-notes-workbench.history-fsm-result.v1",
23
+ }
24
+
25
+ _AGENT_DIRECTIVE_SCHEMA = "medical-notes-workbench.agent-directive.v1"
26
+
27
+ _AGENT_DIRECTIVE_STATUSES = {
28
+ "running",
29
+ "waiting_agent",
30
+ "waiting_external",
31
+ "waiting_human",
32
+ "blocked",
33
+ "failed",
34
+ "completed",
35
+ "completed_with_warnings",
36
+ }
37
+
38
+ _AGENT_PREAMBLE_FIELD_PREFIXES = (
39
+ "Status:",
40
+ "phase:",
41
+ "workflow_exit_code:",
42
+ "workflow_result_label:",
43
+ "blocked_reason:",
44
+ "continuation_reason:",
45
+ "blocking_reasons:",
46
+ "next_action:",
47
+ "next_command:",
48
+ "execution_gate:",
49
+ "resume_after_resolution:",
50
+ "progress_view_model.status:",
51
+ "progress_view_model.phase:",
52
+ "progress_view_model.state:",
53
+ "progress_view_model.can_continue_now:",
54
+ "progress_view_model.resume_action:",
55
+ "state_machine_snapshot.current_category:",
56
+ "state_machine_snapshot.current_state:",
57
+ "receipt.status:",
58
+ "receipt.next_action:",
59
+ "required_inputs:",
60
+ "human_decision_required:",
61
+ "decision.kind:",
62
+ "decision.reason_code:",
63
+ "decision.next_action:",
64
+ "human_decision_packet:",
65
+ )
66
+
67
+ TOOL_PARAMETER_CONTRACT_VIOLATION = "agent.tool_param_contract_violation"
68
+ TOOL_CALL_ERROR = "agent.tool_call_error"
69
+ PUBLIC_TOOL_TEXT_CONTRACT_VIOLATION = "agent.public_tool_text_contract_violation"
70
+ PUBLIC_DEV_ESCAPE_CONTRACT_VIOLATION = "agent.public_dev_escape_contract_violation"
71
+ SUBAGENT_BATCH_CONTRACT_VIOLATION = "agent.subagent_batch_contract_violation"
72
+ SUBAGENT_RAW_CONTENT_CONTRACT_VIOLATION = "agent.subagent_raw_content_contract_violation"
73
+ SUBAGENT_INVOCATION_PACKET_CONTRACT_VIOLATION = "agent.subagent_invocation_packet_contract_violation"
74
+ SPECIALIST_PARALLEL_INVOCATION_CONTRACT_VIOLATION = "agent.specialist_parallel_invocation_contract_violation"
75
+ SPECIALIST_DUPLICATE_INVOCATION_CONTRACT_VIOLATION = "agent.specialist_duplicate_invocation_contract_violation"
76
+ WORKFLOW_CONTINUED_AFTER_BLOCKED_PAYLOAD = "agent.workflow_continued_after_blocked_payload"
77
+ MANUAL_SUBAGENT_CONTRACT_VIOLATION = "agent.manual_subagent_contract_violation"
78
+ WORKSPACE_ADD_DIR_HIDDEN_IGNORED = "agent.workspace_add_dir_hidden_ignored"
79
+ STYLE_REWRITE_WORKSPACE_PERMISSION_TIMEOUT = "agent.style_rewrite_workspace_permission_timeout"
80
+ PARALLEL_STYLE_REWRITE_CONTRACT_VIOLATION = "agent.parallel_style_rewrite_contract_violation"
81
+ DEPENDENT_STYLE_REWRITE_BATCH_CONTRACT_VIOLATION = "agent.dependent_style_rewrite_batch_contract_violation"
82
+ INVALID_EXTENSION_COMMAND_PATH = "agent.invalid_extension_command_path"
83
+ SHELL_CHAIN_CONTRACT_VIOLATION = "agent.shell_chain_contract_violation"
84
+ STYLE_REWRITE_DIRECT_CONTENT_APPLY = "agent.style_rewrite_direct_content_apply_contract_violation"
85
+ STYLE_REWRITE_UNVERIFIED_MODEL_CLAIM = "agent.style_rewrite_unverified_model_claim_contract_violation"
86
+ STYLE_REWRITE_PARENT_OUTPUT_WRITE = "agent.style_rewrite_parent_output_write_contract_violation"
87
+ SPECIALIST_UNVERIFIED_MODEL_ESCAPE = "agent.specialist_unverified_model_escape_contract_violation"
88
+ PROCESS_CHATS_RAW_WRITE = "agent.process_chats_raw_write_contract_violation"
89
+ PROCESS_CHATS_PARENT_ARTIFACT_WRITE_WITHOUT_SUBAGENT = (
90
+ "agent.process_chats_parent_artifact_write_without_subagent"
91
+ )
92
+ WORKFLOW_SOURCE_DISCOVERY_AFTER_BLOCK = "agent.workflow_source_discovery_after_block"
93
+ STALE_EXTENSION_SCRIPT_PATH = "agent.stale_extension_script_path"
94
+ STALE_EXTENSION_SKILL_PATH = "agent.stale_extension_skill_path"
95
+ STALE_SUPERPOWERS_SKILL_PATH = "agent.stale_superpowers_skill_path"
96
+ WORKFLOW_ARTIFACT_DIRECT_WRITE = "agent.workflow_artifact_direct_write"
97
+ WORKFLOW_ARTIFACT_SHELL_COPY = "agent.workflow_artifact_shell_copy"
98
+ WORKFLOW_ARTIFACT_SHELL_REDIRECT = "agent.workflow_artifact_shell_redirect"
99
+ DUPLICATE_WORKFLOW_COMMAND = "agent.duplicate_workflow_command"
100
+ PREPARATORY_PERMISSION_PROBE = "agent.preparatory_permission_probe"
101
+ NONCANONICAL_PYTHON_ENVIRONMENT_PROBE = "agent.noncanonical_python_environment_probe"
102
+ FINAL_ARTIFACT_PATH_INVALID = "agent.final_artifact_path_invalid"
103
+ PACKAGED_AGENT_TEMPLATE_CONTRACT = "medical-notes-workbench.packaged-agent-template.v1"
104
+
105
+ _TRANSCRIPT_CHILD_CONTAINER_KEYS = (
106
+ "$set",
107
+ "content",
108
+ "events",
109
+ "items",
110
+ "messages",
111
+ "records",
112
+ "response",
113
+ "responses",
114
+ "result",
115
+ "tool_calls",
116
+ "toolCalls",
117
+ "transcript",
118
+ )
119
+
120
+ _RETRYABLE_SPECIALIST_BLOCKED_REASONS = {
121
+ "specialist_model_metadata_missing",
122
+ "style_rewrite_agent_contract_violation",
123
+ "style_rewrite_output_missing",
124
+ "style_rewrite_still_requires_rewrite",
125
+ }
126
+
127
+ _RUN_SHELL_TOOL_ALIASES = {
128
+ "bash",
129
+ "powershell",
130
+ "pwsh",
131
+ "run_shell",
132
+ "run_shell_command",
133
+ "run_command",
134
+ "shell",
135
+ "shelltool",
136
+ }
137
+
138
+ _RUN_SHELL_ALLOWED_PARAMETERS = {
139
+ "CommandLine",
140
+ "Cwd",
141
+ "TimeoutMs",
142
+ "WaitMsBeforeAsync",
143
+ "cmd",
144
+ "command",
145
+ "cwd",
146
+ "description",
147
+ "delay_ms",
148
+ "dirPath",
149
+ "dir_path",
150
+ "directory",
151
+ "max_output_chars",
152
+ "max_output_tokens",
153
+ "script",
154
+ "timeout_ms",
155
+ "toolAction",
156
+ "toolSummary",
157
+ "workingDirectory",
158
+ "working_directory",
159
+ "yield_time_ms",
160
+ }
161
+
162
+ _SHELL_COMMAND_PARAMETER_FIELDS = ("command", "cmd", "script", "CommandLine", "commandLine")
163
+
164
+ _UPDATE_TOPIC_ALLOWED_PARAMETERS = {
165
+ "strategic_intent",
166
+ "summary",
167
+ "title",
168
+ }
169
+
170
+ _INVOKE_AGENT_ALLOWED_PARAMETERS = {
171
+ "agent_name",
172
+ "prompt",
173
+ }
174
+
175
+ _TOOL_ALLOWED_PARAMETERS = {
176
+ "run_shell_command": _RUN_SHELL_ALLOWED_PARAMETERS,
177
+ "update_topic": _UPDATE_TOPIC_ALLOWED_PARAMETERS,
178
+ "invoke_agent": _INVOKE_AGENT_ALLOWED_PARAMETERS,
179
+ }
180
+
181
+ _UPDATE_TOPIC_PUBLIC_TEXT_FIELDS = ("title", "summary", "strategic_intent")
182
+ _PUBLIC_TOOL_TEXT_FORBIDDEN_TERMS = (
183
+ "--apply",
184
+ "--dry-run",
185
+ "--json",
186
+ "apply-note-merge",
187
+ "apply-style-rewrite",
188
+ "apply-specialist-style-rewrite",
189
+ "fix-wiki --",
190
+ "guard_lease",
191
+ "plan-subagents",
192
+ "receipt",
193
+ "run-finish",
194
+ "run-linker",
195
+ "run-start",
196
+ "run_id",
197
+ "schema",
198
+ "scripts/",
199
+ "uv run",
200
+ )
201
+ _PUBLIC_FINAL_RESPONSE_AGENT_INSTRUCTIONS = (
202
+ "agent_instruction: nao anexe bloco diagnostico, JSON, XML, YAML ou campos tecnicos na resposta publica final; use logs/JSON para detalhes tecnicos.",
203
+ "agent_instruction: em mensagens publicas de progresso e resposta final, nao cite subcomandos internos; diga correcao da Wiki e modelo medico especialista.",
204
+ )
205
+ _WORKFLOW_ARTIFACT_WRITE_TOOLS = {"write_file", "write_to_file", "write", "replace", "edit", "multiedit"}
206
+ _WORKFLOW_ARTIFACT_PATH_FIELDS = (
207
+ "AbsolutePath",
208
+ "TargetFile",
209
+ "absolutePath",
210
+ "absolute_path",
211
+ "filePath",
212
+ "file_path",
213
+ "path",
214
+ "targetFile",
215
+ "target_file",
216
+ )
217
+ _WORKFLOW_ARTIFACT_NAME_RE = re.compile(
218
+ r"(^|[-_])("
219
+ r"plan|manifest|receipt|report|diagnosis|trigger-context|trigger_context|run_state"
220
+ r")([-_.]|$)"
221
+ )
222
+ _WORKFLOW_ARTIFACT_SCRATCH_NAMES = (
223
+ "compact-report.json",
224
+ "dry_run_output.json",
225
+ "fix-wiki-plan.json",
226
+ "fix-wiki-user-report.md",
227
+ "full-report.json",
228
+ "link-diagnosis.json",
229
+ "run_state.json",
230
+ )
231
+ _UNVERIFIED_SPECIALIST_MODEL_ENV = "MEDNOTES_ALLOW_UNVERIFIED_SPECIALIST_MODEL"
232
+ _PROCESS_CHATS_CONTEXT_MARKERS = (
233
+ "/mednotes:process-chats",
234
+ "process-medical-chats",
235
+ "mednotes-process-chats",
236
+ )
237
+ _PROCESS_CHATS_ARTIFACT_SUFFIXES = (
238
+ ".md",
239
+ "coverage.json",
240
+ "manifest.json",
241
+ "raw-coverage.v1.json",
242
+ "medical-notes-workbench.raw-coverage.v1.json",
243
+ "note-plan.json",
244
+ "triager-output.json",
245
+ )
246
+ _PACKAGED_SPECIALIST_AGENTS = frozenset({"med-knowledge-architect"})
247
+ _PACKAGED_SPECIALIST_AGENT_TEMPLATE_MARKERS = {
248
+ "med-knowledge-architect": (
249
+ "packaged_agent_template_contract: medical-notes-workbench.packaged-agent-template.v1",
250
+ 'You = "A Mente"',
251
+ "Parent packet contract:",
252
+ "parent_raw_content_bypass",
253
+ )
254
+ }
255
+ _INTER_AGENT_MESSAGE_TOOLS = frozenset({"send_message", "invoke_subagent"})
256
+ _SUBAGENT_DEFINITION_TOOLS = frozenset({"define_subagent", "define_agent", "create_subagent"})
257
+ _MESSAGE_PARAMETER_FIELDS = ("Message", "message", "prompt", "Prompt", "content", "Content")
258
+ _AGENT_NAME_PARAMETER_FIELDS = ("agent_name", "agentName", "name", "Name", "agent", "Agent", "TypeName", "typeName")
259
+ _AGY_SUBAGENT_LIST_FIELDS = ("Subagents", "subagents")
260
+ _SUBAGENT_SYSTEM_PROMPT_PARAMETER_FIELDS = (
261
+ "system_prompt",
262
+ "SystemPrompt",
263
+ "instructions",
264
+ "Instructions",
265
+ "prompt",
266
+ "Prompt",
267
+ )
268
+ _STYLE_REWRITE_SUBAGENT_PROMPT_MARKERS = (
269
+ "style-rewrite-",
270
+ "wiki_note_style_rewrite",
271
+ "rewrite prompt",
272
+ "style-rewrite job",
273
+ )
274
+ _STYLE_REWRITE_TYPED_WORK_ITEM_TOKENS = (
275
+ '"work_id"',
276
+ '"item_type"',
277
+ '"target_path"',
278
+ '"target_hash_before"',
279
+ '"temp_output"',
280
+ '"subagent_output_contract"',
281
+ )
282
+ _HANDWRITTEN_SUBAGENT_PROMPT_MARKERS = (
283
+ "CRITICAL MANDATORY INSTRUCTIONS",
284
+ "You are assigned the style-rewrite job for:",
285
+ "- Work ID:",
286
+ "- Target Path:",
287
+ "- Temp Output:",
288
+ )
289
+ _AGY_HIDDEN_WORKSPACE_RE = re.compile(
290
+ r"failed\s+to\s+add\s+workspace\s+folder\b[\s\S]{0,500}\bis\s+hidden\s*:\s*ignore\s+uri",
291
+ re.IGNORECASE,
292
+ )
293
+
294
+
295
+ class AgentPreambleProgressView(ContractModel):
296
+ """Typed fallback lens used only to fail closed on malformed FSM payloads."""
297
+
298
+ model_config = ConfigDict(extra="ignore")
299
+
300
+ status: StrictStr = ""
301
+
302
+
303
+ class AgentPreambleSnapshot(ContractModel):
304
+ """Typed fallback lens for the current FSM category in invalid payloads."""
305
+
306
+ model_config = ConfigDict(extra="ignore")
307
+
308
+ current_category: StrictStr = ""
309
+
310
+
311
+ class AgentPreamblePayload(ContractModel):
312
+ """Typed preamble input; valid directives remain the only executable route."""
313
+
314
+ model_config = ConfigDict(extra="ignore")
315
+
316
+ schema_id: StrictStr = Field(default="", alias="schema")
317
+ agent_directive: JsonObject | None = None
318
+ progress_view_model: AgentPreambleProgressView = Field(default_factory=AgentPreambleProgressView)
319
+ state_machine_snapshot: AgentPreambleSnapshot = Field(default_factory=AgentPreambleSnapshot)
320
+ human_decision_required: bool = False
321
+
322
+
323
+ AgentPreambleProgressView.model_rebuild(_types_namespace=globals())
324
+ AgentPreambleSnapshot.model_rebuild(_types_namespace=globals())
325
+ AgentPreamblePayload.model_rebuild(_types_namespace=globals())
326
+
327
+
328
+ def agent_preamble_lines(payload: object) -> list[str]:
329
+ """Return an agent-facing preamble projected from the operational contract."""
330
+ preamble = AgentPreamblePayload.model_validate(payload)
331
+ directive = _agent_directive(preamble)
332
+ if _is_fsm_first_payload(preamble):
333
+ if directive is None:
334
+ return _invalid_agent_directive_preamble_lines(preamble)
335
+ directive_lines = _agent_directive_preamble_lines(directive)
336
+ if directive_lines:
337
+ return directive_lines
338
+ return []
339
+ if directive is None:
340
+ return []
341
+ return _agent_directive_preamble_lines(directive)
342
+
343
+
344
+ def _is_fsm_first_payload(payload: AgentPreamblePayload) -> bool:
345
+ return payload.schema_id in FSM_FIRST_SCHEMAS
346
+
347
+
348
+ def _agent_directive(payload: AgentPreamblePayload) -> AgentDirective | JsonObject | None:
349
+ if payload.agent_directive is None:
350
+ return None
351
+ return _canonical_agent_directive(payload.agent_directive)
352
+
353
+
354
+ def _agent_directive_preamble_lines(directive: AgentDirective | JsonObject) -> list[str]:
355
+ if isinstance(directive, dict):
356
+ directive = JsonObjectAdapter.validate_python(directive)
357
+ control = directive.get("control")
358
+ if not isinstance(control, dict):
359
+ return []
360
+ control = JsonObjectAdapter.validate_python(control)
361
+ banner = _agent_directive_banner(str(control.get("status") or "").strip())
362
+ if not banner:
363
+ return []
364
+ lines = [banner]
365
+ lines.extend(_string_list(directive.get("instructions")))
366
+ summary = str(directive.get("summary") or "").strip()
367
+ if summary:
368
+ lines.append(f"agent_directive.summary: {summary}")
369
+ lines.extend(_fallback_agent_directive_control_lines(control))
370
+ if len(lines) == 1:
371
+ return []
372
+ lines.append("---")
373
+ return lines
374
+ control = directive.control
375
+ banner = _agent_directive_banner(control.status)
376
+ if not banner:
377
+ return []
378
+ lines = [banner]
379
+ lines.extend(directive.instructions)
380
+ summary = directive.summary.strip()
381
+ if summary:
382
+ lines.append(f"agent_directive.summary: {summary}")
383
+ lines.extend(_agent_directive_control_lines(control))
384
+ if len(lines) == 1:
385
+ return []
386
+ lines.append("---")
387
+ return lines
388
+
389
+
390
+ def _canonical_agent_directive(directive: JsonObject) -> AgentDirective | JsonObject | None:
391
+ canonical_error = _canonical_agent_directive_error(directive)
392
+ if canonical_error is None:
393
+ return _fallback_agent_directive(directive)
394
+ if canonical_error:
395
+ return None
396
+ try:
397
+ return AgentDirective.model_validate(directive)
398
+ except ValueError as exc:
399
+ del exc
400
+ return None
401
+
402
+
403
+ def _canonical_agent_directive_error(directive: JsonObject) -> str | None:
404
+ try:
405
+ AgentDirective.model_validate(directive)
406
+ except ValueError as exc:
407
+ return str(exc)
408
+ return ""
409
+
410
+
411
+ def _fallback_agent_directive(directive: JsonObject) -> JsonObject | None:
412
+ error = _agent_directive_fallback_error(directive)
413
+ if error:
414
+ return None
415
+ return directive
416
+
417
+
418
+ def _agent_directive_fallback_error(directive: JsonObject) -> str:
419
+ if directive.get("schema") != _AGENT_DIRECTIVE_SCHEMA:
420
+ return "agent_directive.schema invalid"
421
+ if not _non_empty_text(directive.get("workflow")):
422
+ return "agent_directive.workflow must be non-empty"
423
+ if not _non_empty_text(directive.get("run_id")):
424
+ return "agent_directive.run_id must be non-empty"
425
+ instructions = directive.get("instructions")
426
+ if instructions is not None:
427
+ if not isinstance(instructions, list):
428
+ return "agent_directive.instructions must be a list"
429
+ for line in instructions:
430
+ if not isinstance(line, str):
431
+ return "agent_directive.instructions must be text"
432
+ if line.strip().casefold().startswith("agent_instruction:"):
433
+ return "agent_directive.instructions must not include agent_instruction prefix"
434
+ control = directive.get("control")
435
+ if not isinstance(control, dict):
436
+ return "agent_directive.control must be an object"
437
+ if not _non_empty_text(control.get("state")):
438
+ return "agent_directive.control.state must be non-empty"
439
+ status = str(control.get("status") or "").strip()
440
+ capabilities = control.get("capabilities")
441
+ capabilities = capabilities if isinstance(capabilities, dict) else {}
442
+ continue_allowed = capabilities.get("continue")
443
+ final_report_allowed = capabilities.get("final_report")
444
+ effects = control.get("effects")
445
+ effects = effects if isinstance(effects, list) else []
446
+ resume = str(control.get("resume") or "").strip()
447
+ blockers = _string_list(control.get("blockers"))
448
+ if status == "waiting_agent":
449
+ if continue_allowed is not True:
450
+ return "waiting_agent requires control.capabilities.continue=true"
451
+ if final_report_allowed is True:
452
+ return "waiting_agent requires control.capabilities.final_report=false"
453
+ if not effects and not resume:
454
+ return "waiting_agent requires effects or resume"
455
+ if status in {"completed", "completed_with_warnings"} and final_report_allowed is not True:
456
+ return "completed directive requires control.capabilities.final_report=true"
457
+ if status in {"waiting_human", "waiting_external", "blocked", "failed"} and not blockers and not resume:
458
+ return f"{status} directive requires blockers or resume"
459
+ return ""
460
+
461
+
462
+ def _non_empty_text(value: object) -> bool:
463
+ return isinstance(value, str) and bool(value.strip())
464
+
465
+
466
+ def _invalid_agent_directive_preamble_lines(payload: AgentPreamblePayload) -> list[str]:
467
+ banner = _agent_preamble_banner(payload)
468
+ if not banner:
469
+ return []
470
+ return [
471
+ banner,
472
+ "agent_directive: missing_or_invalid",
473
+ (
474
+ "agent_instruction: pare e reporte bug de contrato em "
475
+ "agent_directive root; nao use diagnostic_context nem campos agent-facing legados."
476
+ ),
477
+ "---",
478
+ ]
479
+
480
+
481
+ def _agent_directive_banner(status: str) -> str:
482
+ match status:
483
+ case "running":
484
+ return ">>> WORKFLOW EM EXECUCAO"
485
+ case "waiting_agent":
486
+ return ">>> CONTINUACAO AUTOMATICA OBRIGATORIA"
487
+ case "waiting_human":
488
+ return "??? DECISAO HUMANA NECESSARIA"
489
+ case "failed":
490
+ return "!!! WORKFLOW FALHOU"
491
+ case "blocked":
492
+ return "!!! ACAO OBRIGATORIA DO WORKFLOW"
493
+ case "waiting_external":
494
+ return "... AGUARDANDO CONDICAO EXTERNA"
495
+ case _:
496
+ return ""
497
+
498
+
499
+ def _agent_directive_control_lines(control: AgentDirectiveControl | JsonObject) -> list[str]:
500
+ if isinstance(control, AgentDirectiveControl):
501
+ return _canonical_agent_directive_control_lines(control)
502
+ if isinstance(control, dict):
503
+ return _fallback_agent_directive_control_lines(JsonObjectAdapter.validate_python(control))
504
+ return []
505
+
506
+
507
+ def _canonical_agent_directive_control_lines(control: AgentDirectiveControl) -> list[str]:
508
+ lines: list[str] = []
509
+ fields = ("status", "state", "phase", "reason", "resume") if control.status == "running" else (
510
+ "status",
511
+ "state",
512
+ "reason",
513
+ "resume",
514
+ )
515
+ for field in fields:
516
+ value = str(getattr(control, field)).strip()
517
+ if value:
518
+ lines.append(f"agent_directive.control.{field}: {value}")
519
+ lines.append(f"agent_directive.control.capabilities.continue: {_json_bool(control.capabilities.continue_)}")
520
+ lines.append(f"agent_directive.control.capabilities.final_report: {_json_bool(control.capabilities.final_report)}")
521
+ effect_kinds = [effect.kind.value for effect in control.effects]
522
+ if effect_kinds:
523
+ lines.append(f"agent_directive.control.effects: {json.dumps(effect_kinds, ensure_ascii=False)}")
524
+ if control.blockers:
525
+ lines.append(f"agent_directive.control.blockers: {json.dumps(control.blockers, ensure_ascii=False)}")
526
+ return lines
527
+
528
+
529
+ def _fallback_agent_directive_control_lines(control: JsonObject) -> list[str]:
530
+ lines: list[str] = []
531
+ status = str(control.get("status") or "").strip()
532
+ fields = ("status", "state", "phase", "reason", "resume") if status == "running" else (
533
+ "status",
534
+ "state",
535
+ "reason",
536
+ "resume",
537
+ )
538
+ for field in fields:
539
+ value = str(control.get(field) or "").strip()
540
+ if value:
541
+ lines.append(f"agent_directive.control.{field}: {value}")
542
+ capabilities = control.get("capabilities")
543
+ if isinstance(capabilities, dict):
544
+ capabilities = JsonObjectAdapter.validate_python(capabilities)
545
+ if "continue" in capabilities:
546
+ lines.append(f"agent_directive.control.capabilities.continue: {_json_bool(capabilities.get('continue'))}")
547
+ if "final_report" in capabilities:
548
+ lines.append(
549
+ f"agent_directive.control.capabilities.final_report: {_json_bool(capabilities.get('final_report'))}"
550
+ )
551
+ effects = control.get("effects")
552
+ if isinstance(effects, list):
553
+ effect_kinds = [
554
+ str(item.get("kind")).strip()
555
+ for item in effects
556
+ if isinstance(item, dict) and str(item.get("kind") or "").strip()
557
+ ]
558
+ if effect_kinds:
559
+ lines.append(f"agent_directive.control.effects: {json.dumps(effect_kinds, ensure_ascii=False)}")
560
+ blockers = _string_list(control.get("blockers"))
561
+ if blockers:
562
+ lines.append(f"agent_directive.control.blockers: {json.dumps(blockers, ensure_ascii=False)}")
563
+ return lines
564
+
565
+
566
+ def _json_bool(value: object) -> str:
567
+ return "true" if value is True else "false"
568
+
569
+
570
+ def _string_list(value: object) -> list[str]:
571
+ if not isinstance(value, list):
572
+ return []
573
+ return [str(item).strip() for item in value if isinstance(item, str) and item.strip()]
574
+
575
+
576
+ def _agent_preamble_banner(payload: AgentPreamblePayload) -> str:
577
+ status = payload.progress_view_model.status.strip()
578
+ category = payload.state_machine_snapshot.current_category.strip()
579
+ if status == "running" or category == "running":
580
+ return ">>> WORKFLOW EM EXECUCAO"
581
+ if status == "waiting_agent" or category == "waiting_agent":
582
+ return ">>> CONTINUACAO AUTOMATICA OBRIGATORIA"
583
+ if status == "waiting_human" or category == "waiting_human" or payload.human_decision_required is True:
584
+ return "??? DECISAO HUMANA NECESSARIA"
585
+ if status == "failed" or category == "failed":
586
+ return "!!! WORKFLOW FALHOU"
587
+ if status in {"blocked", "error", "needs_review", "completed_with_link_blockers"} or category == "blocked":
588
+ return "!!! ACAO OBRIGATORIA DO WORKFLOW"
589
+ if status == "waiting_external" or category == "waiting_external":
590
+ return "... AGUARDANDO CONDICAO EXTERNA"
591
+ return ""
592
+
593
+ def validate_agent_tool_calls(transcript: Any) -> list[dict[str, Any]]:
594
+ """Detect tool calls that include unsupported parameters.
595
+
596
+ This is intentionally a transcript validator, not a prompt rule. It gives
597
+ the lab and hooks a deterministic way to flag tool-contract drift such as
598
+ `wait_for_previous` on shell calls.
599
+ """
600
+ findings: list[dict[str, Any]] = []
601
+ seen: set[tuple[str, ...]] = set()
602
+ agy_plugin_context = _transcript_contains(
603
+ transcript,
604
+ ".gemini/config/plugins/medical-notes-workbench/skills/",
605
+ )
606
+ process_chats_context = any(_transcript_contains(transcript, marker) for marker in _PROCESS_CHATS_CONTEXT_MARKERS)
607
+ process_chats_specialist_seen = False
608
+ for tool_name, parameters in _iter_agent_tool_calls(transcript):
609
+ canonical_tool = _canonical_tool_name(tool_name)
610
+ allowed = _TOOL_ALLOWED_PARAMETERS.get(canonical_tool)
611
+ if allowed:
612
+ for key in parameters:
613
+ if key in allowed:
614
+ continue
615
+ finding_key = (canonical_tool, key)
616
+ if finding_key in seen:
617
+ continue
618
+ seen.add(finding_key)
619
+ findings.append(
620
+ {
621
+ "code": TOOL_PARAMETER_CONTRACT_VIOLATION,
622
+ "severity": "medium",
623
+ "tool_name": canonical_tool,
624
+ "bad_param": key,
625
+ "message": f"Tool call {canonical_tool} included unsupported parameter {key}.",
626
+ "next_action": (
627
+ "Reportar como bug de contrato de tool; sequencie comandos esperando "
628
+ "o resultado da chamada anterior."
629
+ ),
630
+ }
631
+ )
632
+ permission_probe_finding = _permission_probe_finding(canonical_tool)
633
+ if permission_probe_finding and (canonical_tool, "permission_probe") not in seen:
634
+ seen.add((canonical_tool, "permission_probe"))
635
+ findings.append(permission_probe_finding)
636
+ batch_finding = _subagent_batch_finding(canonical_tool, parameters)
637
+ if batch_finding and (canonical_tool, "subagent_batch") not in seen:
638
+ seen.add((canonical_tool, "subagent_batch"))
639
+ findings.append(batch_finding)
640
+ raw_content_finding = _subagent_raw_content_finding(canonical_tool, parameters)
641
+ if raw_content_finding and (canonical_tool, "subagent_raw_content") not in seen:
642
+ seen.add((canonical_tool, "subagent_raw_content"))
643
+ findings.append(raw_content_finding)
644
+ for invocation_finding in _subagent_invocation_packet_findings(canonical_tool, parameters):
645
+ finding_key = (
646
+ canonical_tool,
647
+ "subagent_invocation_packet",
648
+ str(invocation_finding.get("agent_name") or ""),
649
+ str(invocation_finding.get("bad_param") or ""),
650
+ )
651
+ if finding_key in seen:
652
+ continue
653
+ seen.add(finding_key)
654
+ findings.append(invocation_finding)
655
+ invalid_extension_path_finding = _invalid_extension_command_path_finding(canonical_tool, parameters)
656
+ if invalid_extension_path_finding and (canonical_tool, "invalid_extension_command_path") not in seen:
657
+ seen.add((canonical_tool, "invalid_extension_command_path"))
658
+ findings.append(invalid_extension_path_finding)
659
+ stale_script_path_finding = _stale_extension_script_path_finding(
660
+ canonical_tool,
661
+ parameters,
662
+ agy_plugin_context=agy_plugin_context,
663
+ )
664
+ if stale_script_path_finding and (canonical_tool, "stale_extension_script_path") not in seen:
665
+ seen.add((canonical_tool, "stale_extension_script_path"))
666
+ findings.append(stale_script_path_finding)
667
+ shell_chain_finding = _shell_chain_finding(canonical_tool, parameters)
668
+ if shell_chain_finding and (canonical_tool, "shell_chain") not in seen:
669
+ seen.add((canonical_tool, "shell_chain"))
670
+ findings.append(shell_chain_finding)
671
+ public_dev_escape_finding = _public_dev_escape_finding(canonical_tool, parameters)
672
+ if public_dev_escape_finding and (canonical_tool, "public_dev_escape") not in seen:
673
+ seen.add((canonical_tool, "public_dev_escape"))
674
+ findings.append(public_dev_escape_finding)
675
+ direct_style_apply_finding = _style_rewrite_direct_content_apply_finding(canonical_tool, parameters)
676
+ if direct_style_apply_finding and (canonical_tool, "style_rewrite_direct_content_apply") not in seen:
677
+ seen.add((canonical_tool, "style_rewrite_direct_content_apply"))
678
+ findings.append(direct_style_apply_finding)
679
+ unverified_model_finding = _style_rewrite_unverified_model_claim_finding(canonical_tool, parameters)
680
+ if unverified_model_finding and (canonical_tool, "style_rewrite_unverified_model_claim") not in seen:
681
+ seen.add((canonical_tool, "style_rewrite_unverified_model_claim"))
682
+ findings.append(unverified_model_finding)
683
+ unverified_escape_finding = _specialist_unverified_model_escape_finding(canonical_tool, parameters)
684
+ if unverified_escape_finding and (canonical_tool, "specialist_unverified_model_escape") not in seen:
685
+ seen.add((canonical_tool, "specialist_unverified_model_escape"))
686
+ findings.append(unverified_escape_finding)
687
+ style_output_write_finding = _style_rewrite_parent_output_write_finding(canonical_tool, parameters)
688
+ if style_output_write_finding and (
689
+ canonical_tool,
690
+ "style_rewrite_parent_output_write",
691
+ str(style_output_write_finding.get("path") or ""),
692
+ ) not in seen:
693
+ seen.add(
694
+ (
695
+ canonical_tool,
696
+ "style_rewrite_parent_output_write",
697
+ str(style_output_write_finding.get("path") or ""),
698
+ )
699
+ )
700
+ findings.append(style_output_write_finding)
701
+ if process_chats_context:
702
+ raw_write_finding = _process_chats_raw_write_finding(canonical_tool, parameters)
703
+ if raw_write_finding and (
704
+ canonical_tool,
705
+ "process_chats_raw_write",
706
+ str(raw_write_finding.get("path") or ""),
707
+ ) not in seen:
708
+ seen.add(
709
+ (
710
+ canonical_tool,
711
+ "process_chats_raw_write",
712
+ str(raw_write_finding.get("path") or ""),
713
+ )
714
+ )
715
+ findings.append(raw_write_finding)
716
+ artifact_write_finding = _process_chats_parent_artifact_write_without_subagent_finding(
717
+ canonical_tool,
718
+ parameters,
719
+ process_chats_specialist_seen=process_chats_specialist_seen,
720
+ )
721
+ if artifact_write_finding and (
722
+ canonical_tool,
723
+ "process_chats_parent_artifact_write_without_subagent",
724
+ str(artifact_write_finding.get("path") or ""),
725
+ ) not in seen:
726
+ seen.add(
727
+ (
728
+ canonical_tool,
729
+ "process_chats_parent_artifact_write_without_subagent",
730
+ str(artifact_write_finding.get("path") or ""),
731
+ )
732
+ )
733
+ findings.append(artifact_write_finding)
734
+ source_discovery_finding = _workflow_source_discovery_after_block_finding(canonical_tool, parameters)
735
+ if source_discovery_finding and (canonical_tool, "workflow_source_discovery_after_block") not in seen:
736
+ seen.add((canonical_tool, "workflow_source_discovery_after_block"))
737
+ findings.append(source_discovery_finding)
738
+ python_environment_probe_finding = _python_environment_probe_finding(canonical_tool, parameters)
739
+ if python_environment_probe_finding and (canonical_tool, "python_environment_probe") not in seen:
740
+ seen.add((canonical_tool, "python_environment_probe"))
741
+ findings.append(python_environment_probe_finding)
742
+ workflow_artifact_write_finding = _workflow_artifact_direct_write_finding(canonical_tool, parameters)
743
+ if workflow_artifact_write_finding and (
744
+ canonical_tool,
745
+ "workflow_artifact_direct_write",
746
+ str(workflow_artifact_write_finding.get("path") or ""),
747
+ ) not in seen:
748
+ seen.add(
749
+ (
750
+ canonical_tool,
751
+ "workflow_artifact_direct_write",
752
+ str(workflow_artifact_write_finding.get("path") or ""),
753
+ )
754
+ )
755
+ findings.append(workflow_artifact_write_finding)
756
+ workflow_artifact_shell_copy_finding = _workflow_artifact_shell_copy_finding(canonical_tool, parameters)
757
+ if workflow_artifact_shell_copy_finding and (canonical_tool, "workflow_artifact_shell_copy") not in seen:
758
+ seen.add((canonical_tool, "workflow_artifact_shell_copy"))
759
+ findings.append(workflow_artifact_shell_copy_finding)
760
+ workflow_artifact_shell_redirect_finding = _workflow_artifact_shell_redirect_finding(canonical_tool, parameters)
761
+ if workflow_artifact_shell_redirect_finding and (
762
+ canonical_tool,
763
+ "workflow_artifact_shell_redirect",
764
+ ) not in seen:
765
+ seen.add((canonical_tool, "workflow_artifact_shell_redirect"))
766
+ findings.append(workflow_artifact_shell_redirect_finding)
767
+ if process_chats_context and _is_process_chats_specialist_invocation(canonical_tool):
768
+ process_chats_specialist_seen = True
769
+ for batch_finding in _parallel_style_rewrite_findings(transcript):
770
+ finding_key = ("run_shell_command", "parallel_style_rewrite", str(batch_finding.get("mode") or ""))
771
+ if finding_key in seen:
772
+ continue
773
+ seen.add(finding_key)
774
+ findings.append(batch_finding)
775
+ for specialist_finding in _parallel_specialist_invocation_findings(transcript):
776
+ finding_key = ("invoke_agent", "parallel_specialist_invocation", str(specialist_finding.get("call_count") or ""))
777
+ if finding_key in seen:
778
+ continue
779
+ seen.add(finding_key)
780
+ findings.append(specialist_finding)
781
+ for specialist_finding in _duplicate_specialist_invocation_findings(transcript):
782
+ finding_key = (
783
+ "invoke_agent",
784
+ "duplicate_specialist_invocation",
785
+ str(specialist_finding.get("work_id") or ""),
786
+ )
787
+ if finding_key in seen:
788
+ continue
789
+ seen.add(finding_key)
790
+ findings.append(specialist_finding)
791
+ for blocked_finding in _continued_after_blocked_payload_findings(transcript):
792
+ finding_key = (
793
+ "workflow_continued_after_blocked_payload",
794
+ str(blocked_finding.get("blocked_reason") or ""),
795
+ str(blocked_finding.get("tool_name") or ""),
796
+ )
797
+ if finding_key in seen:
798
+ continue
799
+ seen.add(finding_key)
800
+ findings.append(blocked_finding)
801
+ for duplicate_finding in _duplicate_workflow_command_findings(transcript):
802
+ finding_key = (
803
+ "run_shell_command",
804
+ "duplicate_workflow_command",
805
+ str(duplicate_finding.get("workflow") or ""),
806
+ str(duplicate_finding.get("mode") or ""),
807
+ )
808
+ if finding_key in seen:
809
+ continue
810
+ seen.add(finding_key)
811
+ findings.append(duplicate_finding)
812
+ for error_finding in _agent_tool_error_findings(transcript):
813
+ finding_key = ("tool_error", str(error_finding.get("message") or ""))
814
+ if finding_key in seen:
815
+ continue
816
+ seen.add(finding_key)
817
+ findings.append(error_finding)
818
+ for skill_finding in _stale_extension_skill_findings(transcript, agy_plugin_context=agy_plugin_context):
819
+ finding_key = ("stale_skill", str(skill_finding.get("path") or ""))
820
+ if finding_key in seen:
821
+ continue
822
+ seen.add(finding_key)
823
+ findings.append(skill_finding)
824
+ for artifact_finding in _final_artifact_path_findings(transcript):
825
+ finding_key = ("final_artifact_path", str(artifact_finding.get("path") or ""))
826
+ if finding_key in seen:
827
+ continue
828
+ seen.add(finding_key)
829
+ findings.append(artifact_finding)
830
+ for manual_subagent_finding in _manual_packaged_subagent_findings(transcript):
831
+ finding_key = (
832
+ "manual_subagent_definition",
833
+ str(manual_subagent_finding.get("agent_name") or ""),
834
+ )
835
+ if finding_key in seen:
836
+ continue
837
+ seen.add(finding_key)
838
+ findings.append(manual_subagent_finding)
839
+ for hidden_workspace_finding in _agy_hidden_workspace_findings(transcript):
840
+ finding_key = ("agy_hidden_workspace", str(hidden_workspace_finding.get("path") or ""))
841
+ if finding_key in seen:
842
+ continue
843
+ seen.add(finding_key)
844
+ findings.append(hidden_workspace_finding)
845
+ return findings
846
+
847
+
848
+ def _agent_tool_error_findings(transcript: Any) -> list[dict[str, Any]]:
849
+ findings: list[dict[str, Any]] = []
850
+
851
+ def visit(value: Any) -> None:
852
+ if isinstance(value, list):
853
+ for item in value:
854
+ visit(item)
855
+ return
856
+ if not isinstance(value, dict):
857
+ return
858
+ workspace_timeout_finding = _style_rewrite_workspace_permission_timeout_finding(value)
859
+ if workspace_timeout_finding:
860
+ findings.append(workspace_timeout_finding)
861
+ return
862
+ stale_superpowers_finding = _stale_superpowers_tool_error_finding(value)
863
+ if stale_superpowers_finding:
864
+ findings.append(stale_superpowers_finding)
865
+ return
866
+ error_payload = _tool_error_payload(value)
867
+ if error_payload:
868
+ error_type, severity, text = error_payload
869
+ findings.append(
870
+ {
871
+ "code": TOOL_CALL_ERROR,
872
+ "severity": severity,
873
+ "error_type": error_type,
874
+ "tool_type": str(value.get("type") or ""),
875
+ "message": f"Tool call failed before execution: {_normalize_tool_error_text(text)}",
876
+ "next_action": "Reportar a tool call falha no relatório final mesmo se um retry posterior recuperar.",
877
+ }
878
+ )
879
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
880
+ child = value.get(key)
881
+ if isinstance(child, (dict, list)):
882
+ visit(child)
883
+
884
+ visit(transcript)
885
+ return findings
886
+
887
+
888
+ def _style_rewrite_workspace_permission_timeout_finding(value: dict[str, Any]) -> dict[str, Any] | None:
889
+ raw = value.get("error") or value.get("content") or value.get("message") or ""
890
+ text = str(raw)
891
+ lowered = text.lower()
892
+ status = str(value.get("status") or "").lower()
893
+ if status not in {"error", "failed"}:
894
+ return None
895
+ if "permission prompt" not in lowered or "write_file" not in lowered or "timed out" not in lowered:
896
+ return None
897
+ match = re.search(r"target ['\"](?P<path>[^'\"]*tmp/agent-work/fix-wiki/[^'\"]+\.rewrite\.md)['\"]", text)
898
+ if not match:
899
+ return None
900
+ path = match.group("path")
901
+ return {
902
+ "code": STYLE_REWRITE_WORKSPACE_PERMISSION_TIMEOUT,
903
+ "severity": "high",
904
+ "tool_name": "write_file",
905
+ "bad_param": "target",
906
+ "path": path,
907
+ "message": "Style rewrite temp_output was outside the writable AGY/subagent workspace.",
908
+ "next_action": (
909
+ "Repetir a rodada com o temp_dir do work_item adicionado ao workspace antes de invocar "
910
+ "o subagente; não tente contornar por scratch, run_command ou conteúdo colado."
911
+ ),
912
+ }
913
+
914
+
915
+ def _stale_superpowers_tool_error_finding(value: dict[str, Any]) -> dict[str, Any] | None:
916
+ raw = value.get("error") or value.get("content") or value.get("message") or ""
917
+ text = str(raw).replace("\\", "/").casefold()
918
+ status = str(value.get("status") or "").lower()
919
+ event_type = str(value.get("type") or "").upper()
920
+ if ".gemini/extensions/superpowers/skills" not in text:
921
+ return None
922
+ if event_type != "ERROR_MESSAGE" and status not in {"error", "failed"}:
923
+ return None
924
+ return {
925
+ "code": STALE_SUPERPOWERS_SKILL_PATH,
926
+ "severity": "high",
927
+ "tool_name": "read_file",
928
+ "bad_param": "path",
929
+ "path": "~/.gemini/extensions/superpowers/skills/*",
930
+ "message": "Agent tried to load stale Superpowers skill files outside the AGY plugin surface.",
931
+ "next_action": (
932
+ "Reportar como bug de roteamento AGY; use somente skills/docs empacotados no plugin "
933
+ "ou caminhos explícitos do payload oficial."
934
+ ),
935
+ }
936
+
937
+
938
+ def _tool_error_payload(value: dict[str, Any]) -> tuple[str, str, str] | None:
939
+ raw = value.get("error") or value.get("content") or value.get("message") or ""
940
+ text = str(raw)
941
+ lowered = text.lower()
942
+ status = str(value.get("status") or "").lower()
943
+ event_type = str(value.get("type") or "").upper()
944
+ if (
945
+ ("invalid tool call" in lowered or "invalid_tool_params" in lowered)
946
+ and (event_type == "ERROR_MESSAGE" or status in {"error", "failed"})
947
+ ):
948
+ return ("invalid_tool_params", "medium", text)
949
+ if status in {"error", "failed"} and _looks_like_transcript_tool_event(value):
950
+ severity = "low" if _is_low_severity_tool_error(text) else "medium"
951
+ return ("tool_status_error", severity, text)
952
+ return None
953
+
954
+
955
+ def _looks_like_transcript_tool_event(value: dict[str, Any]) -> bool:
956
+ event_type = str(value.get("type") or "").upper()
957
+ if not event_type or event_type in {"ERROR_MESSAGE", "PLANNER_RESPONSE", "SYSTEM_MESSAGE"}:
958
+ return False
959
+ return any(key in value for key in ("step_index", "source", "created_at", "tool_name", "content"))
960
+
961
+
962
+ def _is_low_severity_tool_error(text: str) -> bool:
963
+ lowered = text.lower()
964
+ return (
965
+ "permission denied" in lowered
966
+ and ("read_file" in lowered or "list" in lowered)
967
+ and "system protection boundary" in lowered
968
+ )
969
+
970
+
971
+ def _normalize_tool_error_text(text: str) -> str:
972
+ text = re.sub(r"\s+", " ", text).strip()
973
+ marker = "Error Message:"
974
+ if marker in text:
975
+ text = text.split(marker, 1)[1].strip()
976
+ if len(text) > 300:
977
+ text = text[:297].rstrip() + "..."
978
+ return text
979
+
980
+
981
+ def _public_tool_text_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
982
+ if tool_name != "update_topic":
983
+ return None
984
+ field_hits: list[str] = []
985
+ term_hits: list[str] = []
986
+ for field in _UPDATE_TOPIC_PUBLIC_TEXT_FIELDS:
987
+ value = parameters.get(field)
988
+ if not isinstance(value, str):
989
+ continue
990
+ lowered = value.lower()
991
+ matches = [term for term in _PUBLIC_TOOL_TEXT_FORBIDDEN_TERMS if term in lowered]
992
+ if not matches:
993
+ continue
994
+ field_hits.append(field)
995
+ for term in matches:
996
+ if term not in term_hits:
997
+ term_hits.append(term)
998
+ if not field_hits:
999
+ return None
1000
+ return {
1001
+ "code": PUBLIC_TOOL_TEXT_CONTRACT_VIOLATION,
1002
+ "severity": "medium",
1003
+ "tool_name": tool_name,
1004
+ "bad_param": "public_text",
1005
+ "message": "Tool call update_topic exposed internal workflow terms in public text.",
1006
+ "fields": field_hits,
1007
+ "forbidden_terms": term_hits,
1008
+ "next_action": (
1009
+ "Reportar como bug de UX/tool-contract; traduza update_topic para linguagem pública "
1010
+ "sem flags, comandos, paths ou IDs técnicos."
1011
+ ),
1012
+ }
1013
+
1014
+
1015
+ def _permission_probe_finding(tool_name: str) -> dict[str, Any] | None:
1016
+ if tool_name != "list_permissions":
1017
+ return None
1018
+ return {
1019
+ "code": PREPARATORY_PERMISSION_PROBE,
1020
+ "severity": "low",
1021
+ "tool_name": tool_name,
1022
+ "bad_param": "tool_call",
1023
+ "message": "Agent listed AGY permissions as a preparatory probe before the workflow.",
1024
+ "next_action": (
1025
+ "Reportar como desvio operacional leve; em ambiente já preparado, execute o workflow público "
1026
+ "e reporte bloqueios do payload em vez de sondar permissões."
1027
+ ),
1028
+ }
1029
+
1030
+
1031
+ def _subagent_batch_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1032
+ if tool_name != "invoke_agent":
1033
+ return None
1034
+ agent_name = str(parameters.get("agent_name") or "")
1035
+ if agent_name != "med-knowledge-architect":
1036
+ return None
1037
+ prompt = parameters.get("prompt")
1038
+ if not isinstance(prompt, str):
1039
+ return None
1040
+ work_ids = sorted(set(re.findall(r"style-rewrite-\d{3}-[a-z0-9-]+", prompt)))
1041
+ if len(work_ids) <= 1:
1042
+ return None
1043
+ return {
1044
+ "code": SUBAGENT_BATCH_CONTRACT_VIOLATION,
1045
+ "severity": "medium",
1046
+ "tool_name": tool_name,
1047
+ "bad_param": "prompt",
1048
+ "message": "Tool call invoke_agent batched multiple style rewrite work items into one med-knowledge-architect.",
1049
+ "work_item_count": len(work_ids),
1050
+ "work_ids": work_ids,
1051
+ "next_action": (
1052
+ "Reportar como bug de orquestração; lance um med-knowledge-architect por work_item.target_path."
1053
+ ),
1054
+ }
1055
+
1056
+
1057
+ def _subagent_raw_content_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1058
+ if tool_name == "invoke_agent":
1059
+ agent_name = str(parameters.get("agent_name") or "")
1060
+ if agent_name != "med-knowledge-architect":
1061
+ return None
1062
+ prompt = parameters.get("prompt")
1063
+ if not isinstance(prompt, str) or not _looks_like_raw_markdown_note(prompt):
1064
+ return None
1065
+ return {
1066
+ "code": SUBAGENT_RAW_CONTENT_CONTRACT_VIOLATION,
1067
+ "severity": "high",
1068
+ "tool_name": tool_name,
1069
+ "bad_param": "prompt",
1070
+ "message": "Tool call invoke_agent embedded raw Markdown note content in a med-knowledge-architect prompt.",
1071
+ "next_action": (
1072
+ "Reportar como bug de privacidade/orquestração; passe apenas work_item, target_path, "
1073
+ "rewrite_prompt e temp_output oficiais, sem colar conteúdo clínico no prompt pai."
1074
+ ),
1075
+ }
1076
+ if tool_name not in _INTER_AGENT_MESSAGE_TOOLS:
1077
+ return None
1078
+ field_name, message = _first_string_parameter(parameters, _MESSAGE_PARAMETER_FIELDS)
1079
+ if not message or not _looks_like_raw_markdown_note(message):
1080
+ return None
1081
+ return {
1082
+ "code": SUBAGENT_RAW_CONTENT_CONTRACT_VIOLATION,
1083
+ "severity": "high",
1084
+ "tool_name": tool_name,
1085
+ "bad_param": field_name,
1086
+ "message": f"Tool call {tool_name} embedded raw Markdown note content in an inter-agent message.",
1087
+ "next_action": (
1088
+ "Reportar como bug de privacidade/orquestração; não cole conteúdo clínico no parent "
1089
+ "ou em mensagens entre agentes. Recomece pela rota oficial com o work_item tipado."
1090
+ ),
1091
+ }
1092
+
1093
+
1094
+ def _subagent_invocation_packet_findings(tool_name: str, parameters: dict[str, Any]) -> list[dict[str, Any]]:
1095
+ if tool_name not in {"invoke_agent", "invoke_subagent"}:
1096
+ return []
1097
+ findings: list[dict[str, Any]] = []
1098
+ for agent_name, field_name, prompt in _packaged_subagent_invocation_prompts(parameters):
1099
+ if agent_name != "med-knowledge-architect":
1100
+ continue
1101
+ if not _looks_like_style_rewrite_invocation_prompt(prompt):
1102
+ continue
1103
+ if _is_official_typed_style_rewrite_invocation_prompt(prompt):
1104
+ continue
1105
+ findings.append(
1106
+ {
1107
+ "code": SUBAGENT_INVOCATION_PACKET_CONTRACT_VIOLATION,
1108
+ "severity": "high",
1109
+ "tool_name": tool_name,
1110
+ "bad_param": field_name,
1111
+ "agent_name": agent_name,
1112
+ "message": (
1113
+ f"Tool call {tool_name} sent a handwritten med-knowledge-architect prompt instead "
1114
+ "of the official typed work item packet."
1115
+ ),
1116
+ "next_action": (
1117
+ "Reportar como bug de orquestração; invoque o subagente com o work_item tipado do plano oficial, "
1118
+ "incluindo target_hash_before, temp_output e subagent_output_contract, sem instruções manuais extras."
1119
+ ),
1120
+ }
1121
+ )
1122
+ return findings
1123
+
1124
+
1125
+ def _packaged_subagent_invocation_prompts(parameters: dict[str, Any]) -> list[tuple[str, str, str]]:
1126
+ prompts: list[tuple[str, str, str]] = []
1127
+ field_name, prompt = _first_string_parameter(parameters, _MESSAGE_PARAMETER_FIELDS)
1128
+ _, agent_name = _first_string_parameter(parameters, _AGENT_NAME_PARAMETER_FIELDS)
1129
+ if agent_name and prompt:
1130
+ prompts.append((agent_name, field_name, prompt))
1131
+ for list_field in _AGY_SUBAGENT_LIST_FIELDS:
1132
+ value = parameters.get(list_field)
1133
+ if not isinstance(value, list):
1134
+ continue
1135
+ for item in value:
1136
+ if not isinstance(item, dict):
1137
+ continue
1138
+ nested_field_name, nested_prompt = _first_string_parameter(item, _MESSAGE_PARAMETER_FIELDS)
1139
+ _, nested_agent_name = _first_string_parameter(item, _AGENT_NAME_PARAMETER_FIELDS)
1140
+ if nested_agent_name and nested_prompt:
1141
+ prompts.append((nested_agent_name, f"{list_field}[].{nested_field_name}", nested_prompt))
1142
+ return prompts
1143
+
1144
+
1145
+ def _looks_like_style_rewrite_invocation_prompt(prompt: str) -> bool:
1146
+ lowered = prompt.casefold()
1147
+ return any(marker in lowered for marker in _STYLE_REWRITE_SUBAGENT_PROMPT_MARKERS)
1148
+
1149
+
1150
+ def _is_official_typed_style_rewrite_invocation_prompt(prompt: str) -> bool:
1151
+ if any(marker in prompt for marker in _HANDWRITTEN_SUBAGENT_PROMPT_MARKERS):
1152
+ return False
1153
+ return all(token in prompt for token in _STYLE_REWRITE_TYPED_WORK_ITEM_TOKENS)
1154
+
1155
+
1156
+ def _looks_like_raw_markdown_note(text: str) -> bool:
1157
+ lowered = text.lower()
1158
+ has_raw_note_marker = any(
1159
+ marker in lowered for marker in ("material-fonte", "nota atual", "nota médica abaixo", "nota medica abaixo")
1160
+ )
1161
+ has_markdown_note = bool(re.search(r"(?m)^#\s+\S+", text) or "\n---\n" in text or "```markdown" in lowered)
1162
+ return has_raw_note_marker and has_markdown_note
1163
+
1164
+
1165
+ def _first_string_parameter(parameters: dict[str, Any], fields: tuple[str, ...]) -> tuple[str, str]:
1166
+ for field in fields:
1167
+ value = parameters.get(field)
1168
+ if isinstance(value, str) and value.strip():
1169
+ return field, value
1170
+ return "", ""
1171
+
1172
+
1173
+ def _parallel_style_rewrite_findings(transcript: Any) -> list[dict[str, Any]]:
1174
+ findings: list[dict[str, Any]] = []
1175
+ for batch in _iter_tool_call_batches(transcript):
1176
+ style_calls: list[dict[str, str]] = []
1177
+ dependent_families: list[str] = []
1178
+ for tool_name, parameters in batch:
1179
+ if _canonical_tool_name(tool_name) != "run_shell_command":
1180
+ continue
1181
+ command = _shell_command_text(parameters)
1182
+ for command_family in (
1183
+ "finalize-style-rewrite-output",
1184
+ "collect-style-rewrite-outputs",
1185
+ "apply-style-rewrite",
1186
+ ):
1187
+ if command_family in command:
1188
+ dependent_families.append(command_family)
1189
+ if "apply-style-rewrite" in command or "apply-specialist-style-rewrite" in command:
1190
+ style_calls.append(
1191
+ {
1192
+ "mode": "dry_run" if "--dry-run" in command else "apply",
1193
+ "command": command,
1194
+ }
1195
+ )
1196
+ unique_dependent_families = sorted(set(dependent_families))
1197
+ if len(unique_dependent_families) > 1:
1198
+ findings.append(
1199
+ {
1200
+ "code": DEPENDENT_STYLE_REWRITE_BATCH_CONTRACT_VIOLATION,
1201
+ "severity": "high",
1202
+ "tool_name": "run_shell_command",
1203
+ "bad_param": "tool_batch",
1204
+ "command_families": unique_dependent_families,
1205
+ "message": "Dependent style-rewrite commands were emitted in the same tool batch.",
1206
+ "next_action": (
1207
+ "Reportar como bug de orquestração; use apply-specialist-style-rewrite para finalizar, "
1208
+ "coletar e aplicar um item em uma única chamada oficial."
1209
+ ),
1210
+ }
1211
+ )
1212
+ if len(style_calls) <= 1:
1213
+ continue
1214
+ modes = sorted({item["mode"] for item in style_calls})
1215
+ findings.append(
1216
+ {
1217
+ "code": PARALLEL_STYLE_REWRITE_CONTRACT_VIOLATION,
1218
+ "severity": "medium",
1219
+ "tool_name": "run_shell_command",
1220
+ "bad_param": "tool_batch",
1221
+ "mode": "+".join(modes),
1222
+ "call_count": len(style_calls),
1223
+ "message": "Multiple apply-style-rewrite commands were emitted in the same tool batch.",
1224
+ "next_action": (
1225
+ "Reportar como bug de orquestração; valide e aplique cada rewrite em série, "
1226
+ "aguardando o resultado JSON antes do próximo comando."
1227
+ ),
1228
+ }
1229
+ )
1230
+ return findings
1231
+
1232
+
1233
+ def _parallel_specialist_invocation_findings(transcript: Any) -> list[dict[str, Any]]:
1234
+ for batch in _iter_tool_call_batches(transcript):
1235
+ specialist_calls = [
1236
+ parameters
1237
+ for tool_name, parameters in batch
1238
+ if _is_style_rewrite_specialist_invocation(_canonical_tool_name(tool_name), parameters)
1239
+ ]
1240
+ if len(specialist_calls) > 1:
1241
+ return [_parallel_specialist_invocation_finding(len(specialist_calls))]
1242
+
1243
+ active_tool_ids: list[str] = []
1244
+ active_count = 0
1245
+ for record in _iter_agent_tool_event_records(transcript):
1246
+ tool_name = _canonical_tool_name(_tool_name_from_record(record))
1247
+ event_type = str(record.get("type") or record.get("event_type") or "").casefold()
1248
+ tool_id = str(record.get("tool_id") or record.get("id") or "").strip()
1249
+ parameters = _tool_parameters_from_record(record) or {}
1250
+ if event_type == "tool_result":
1251
+ if tool_id and tool_id in active_tool_ids:
1252
+ active_tool_ids.remove(tool_id)
1253
+ active_count = max(0, active_count - 1)
1254
+ continue
1255
+ if event_type != "tool_use":
1256
+ continue
1257
+ if not _is_style_rewrite_specialist_invocation(tool_name, parameters):
1258
+ continue
1259
+ if active_count > 0:
1260
+ return [_parallel_specialist_invocation_finding(active_count + 1)]
1261
+ active_count += 1
1262
+ if tool_id:
1263
+ active_tool_ids.append(tool_id)
1264
+ return []
1265
+
1266
+
1267
+ def _parallel_specialist_invocation_finding(call_count: int) -> dict[str, Any]:
1268
+ return {
1269
+ "code": SPECIALIST_PARALLEL_INVOCATION_CONTRACT_VIOLATION,
1270
+ "severity": "high",
1271
+ "tool_name": "invoke_agent",
1272
+ "bad_param": "tool_sequence",
1273
+ "call_count": call_count,
1274
+ "message": "Multiple med-knowledge-architect invoke_agent calls were started before a prior specialist receipt/result.",
1275
+ "next_action": (
1276
+ "Reportar como bug de orquestração; no Gemini CLI, execute o lote de reescrita em série, "
1277
+ "aguardando resultado e specialist_task_run_receipt_path antes do próximo invoke_agent."
1278
+ ),
1279
+ }
1280
+
1281
+
1282
+ def _duplicate_specialist_invocation_findings(transcript: Any) -> list[dict[str, Any]]:
1283
+ seen_work_ids: set[str] = set()
1284
+ for record in _iter_agent_tool_event_records(transcript):
1285
+ event_type = str(record.get("type") or record.get("event_type") or "").casefold()
1286
+ if event_type != "tool_use":
1287
+ continue
1288
+ tool_name = _canonical_tool_name(_tool_name_from_record(record))
1289
+ parameters = _tool_parameters_from_record(record) or {}
1290
+ if not _is_style_rewrite_specialist_invocation(tool_name, parameters):
1291
+ continue
1292
+ work_id = _style_rewrite_work_id_from_parameters(parameters)
1293
+ if not work_id:
1294
+ continue
1295
+ if work_id in seen_work_ids:
1296
+ return [
1297
+ {
1298
+ "code": SPECIALIST_DUPLICATE_INVOCATION_CONTRACT_VIOLATION,
1299
+ "severity": "high",
1300
+ "tool_name": "invoke_agent",
1301
+ "bad_param": "tool_sequence",
1302
+ "work_id": work_id,
1303
+ "message": "The same style rewrite work item was sent to med-knowledge-architect more than once.",
1304
+ "next_action": (
1305
+ "Reportar como bug de orquestração; depois do primeiro invoke_agent, finalize com "
1306
+ "recibo oficial se existir, ou pare e reporte o bloqueio de recibo/modelo sem repetir "
1307
+ "o subagente para o mesmo work_id."
1308
+ ),
1309
+ }
1310
+ ]
1311
+ seen_work_ids.add(work_id)
1312
+ return []
1313
+
1314
+
1315
+ def _continued_after_blocked_payload_findings(transcript: object) -> list[JsonObject]:
1316
+ blocked_reason = ""
1317
+ for record in _iter_agent_tool_event_records(transcript):
1318
+ event_type = str(record.get("type") or record.get("event_type") or "").casefold()
1319
+ tool_name = _canonical_tool_name(_tool_name_from_record(record))
1320
+ if event_type == "tool_result":
1321
+ output = str(record.get("output") or record.get("content") or "")
1322
+ found_reason = _blocked_payload_reason_from_output(output)
1323
+ if found_reason:
1324
+ blocked_reason = found_reason
1325
+ continue
1326
+ if event_type != "tool_use" or not blocked_reason:
1327
+ continue
1328
+ parameters = _tool_parameters_from_record(record) or {}
1329
+ if _is_allowed_after_blocked_payload_tool_use(tool_name, parameters, blocked_reason=blocked_reason):
1330
+ continue
1331
+ return [
1332
+ {
1333
+ "code": WORKFLOW_CONTINUED_AFTER_BLOCKED_PAYLOAD,
1334
+ "severity": "high",
1335
+ "tool_name": tool_name,
1336
+ "bad_param": "tool_sequence",
1337
+ "blocked_reason": blocked_reason,
1338
+ "message": "Agent continued executing tools after a workflow payload explicitly blocked continuation.",
1339
+ "next_action": (
1340
+ "Reportar como bug de orquestração; quando o payload disser WORKFLOW BLOQUEADO e "
1341
+ "next_command=null, pare a continuação pública e só feche a proteção do vault quando aplicável."
1342
+ ),
1343
+ }
1344
+ ]
1345
+ return []
1346
+
1347
+
1348
+ def _blocked_payload_reason_from_output(output: str) -> str:
1349
+ if not output or "blocked" not in output or "blocked_reason" not in output:
1350
+ return ""
1351
+ patterns = (
1352
+ r"blocked_reason:\s*([A-Za-z0-9_.-]+)",
1353
+ r'"blocked_reason"\s*:\s*"([^"]+)"',
1354
+ )
1355
+ for pattern in patterns:
1356
+ match = re.search(pattern, output)
1357
+ if match:
1358
+ return match.group(1)
1359
+ return ""
1360
+
1361
+
1362
+ def _is_allowed_after_blocked_payload_tool_use(
1363
+ tool_name: str,
1364
+ parameters: JsonObject,
1365
+ *,
1366
+ blocked_reason: str,
1367
+ ) -> bool:
1368
+ if tool_name in {"tracker_create_task", "tracker_update_task", "tracker_visualize", "update_topic"}:
1369
+ return True
1370
+ if blocked_reason in _RETRYABLE_SPECIALIST_BLOCKED_REASONS:
1371
+ return _is_style_rewrite_specialist_invocation(tool_name, parameters)
1372
+ if tool_name != "run_shell_command":
1373
+ return False
1374
+ command = _shell_command_text(parameters)
1375
+ if "vault_git.py" in command and "run-finish" in command:
1376
+ return True
1377
+ return False
1378
+
1379
+
1380
+ def _is_style_rewrite_specialist_invocation(tool_name: str, parameters: dict[str, Any]) -> bool:
1381
+ if tool_name != "invoke_agent":
1382
+ return False
1383
+ agent_name = str(parameters.get("agent_name") or parameters.get("name") or "").strip()
1384
+ if agent_name == "med-knowledge-architect":
1385
+ return True
1386
+ _field_name, prompt = _first_string_parameter(parameters, _MESSAGE_PARAMETER_FIELDS)
1387
+ return "med-knowledge-architect" in prompt or "style-rewrite-" in prompt
1388
+
1389
+
1390
+ def _style_rewrite_work_id_from_parameters(parameters: dict[str, Any]) -> str:
1391
+ _field_name, prompt = _first_string_parameter(parameters, _MESSAGE_PARAMETER_FIELDS)
1392
+ match = re.search(r"style-rewrite-\d{3}-[a-z0-9-]+", prompt)
1393
+ return match.group(0) if match else ""
1394
+
1395
+
1396
+ def _iter_agent_tool_event_records(node: Any) -> list[dict[str, Any]]:
1397
+ records: list[dict[str, Any]] = []
1398
+
1399
+ def visit(value: Any) -> None:
1400
+ if isinstance(value, list):
1401
+ for item in value:
1402
+ visit(item)
1403
+ return
1404
+ if not isinstance(value, dict):
1405
+ return
1406
+ event_type = str(value.get("type") or value.get("event_type") or "").casefold()
1407
+ if event_type in {"tool_use", "tool_result"}:
1408
+ records.append(value)
1409
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
1410
+ child = value.get(key)
1411
+ if isinstance(child, (dict, list)):
1412
+ visit(child)
1413
+
1414
+ visit(node)
1415
+ return records
1416
+
1417
+
1418
+ def _invalid_extension_command_path_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1419
+ if tool_name != "run_shell_command":
1420
+ return None
1421
+ command = _shell_command_text(parameters)
1422
+ if "dist/gemini-cli-" not in command:
1423
+ return None
1424
+ path_hits = sorted(
1425
+ path for path in set(re.findall(r"""[^\s"']*dist/gemini-cli-[^\s"']+""", command))
1426
+ if "dist/gemini-cli-extension" not in path
1427
+ )
1428
+ if not path_hits:
1429
+ return None
1430
+ return {
1431
+ "code": INVALID_EXTENSION_COMMAND_PATH,
1432
+ "severity": "high",
1433
+ "tool_name": tool_name,
1434
+ "bad_param": "command",
1435
+ "message": "Tool call run_shell_command referenced a non-canonical Gemini extension dist path.",
1436
+ "paths": path_hits[:5],
1437
+ "next_action": (
1438
+ "Reportar como bug de descoberta de caminho; use somente o extensionPath carregado "
1439
+ "pelo bundle ativo e não invente variantes de dist/gemini-cli-extension."
1440
+ ),
1441
+ }
1442
+
1443
+
1444
+ def _stale_extension_script_path_finding(
1445
+ tool_name: str,
1446
+ parameters: dict[str, Any],
1447
+ *,
1448
+ agy_plugin_context: bool = False,
1449
+ ) -> dict[str, Any] | None:
1450
+ if tool_name != "run_shell_command":
1451
+ return None
1452
+ command = _shell_command_text(parameters)
1453
+ if agy_plugin_context and re.search(
1454
+ r"(?:~|/Users/[^/\s\"']+)/\.gemini/extensions/medical-notes-workbench/",
1455
+ command,
1456
+ ):
1457
+ return {
1458
+ "code": STALE_EXTENSION_SCRIPT_PATH,
1459
+ "severity": "high",
1460
+ "tool_name": tool_name,
1461
+ "bad_param": "command",
1462
+ "message": "Tool call run_shell_command used the global Gemini extension path while the session was running from an AGY plugin root.",
1463
+ "next_action": (
1464
+ "Reportar como bug de descoberta de caminho; carregue a skill escopada do plugin "
1465
+ "e use o extensionPath ativo em vez de ~/.gemini/extensions/medical-notes-workbench."
1466
+ ),
1467
+ }
1468
+ if re.search(r"scripts[/\\]mednotes[/\\]vault[/\\]vault_git\.py", command):
1469
+ return {
1470
+ "code": STALE_EXTENSION_SCRIPT_PATH,
1471
+ "severity": "high",
1472
+ "tool_name": tool_name,
1473
+ "bad_param": "command",
1474
+ "message": "Tool call run_shell_command referenced stale vault_git.py path under scripts/mednotes/vault.",
1475
+ "next_action": (
1476
+ "Reportar como bug de descoberta de caminho; use scripts/vault/vault_git.py do bundle ativo."
1477
+ ),
1478
+ }
1479
+ return None
1480
+
1481
+
1482
+ def _stale_extension_skill_findings(transcript: Any, *, agy_plugin_context: bool = False) -> list[dict[str, Any]]:
1483
+ if not agy_plugin_context:
1484
+ return []
1485
+ if not _transcript_contains_stale_skill_view(transcript):
1486
+ return []
1487
+ return [
1488
+ {
1489
+ "code": STALE_EXTENSION_SKILL_PATH,
1490
+ "severity": "high",
1491
+ "tool_name": "view_file",
1492
+ "bad_param": "path",
1493
+ "path": "~/.gemini/config/skills/fix-medical-wiki/SKILL.md",
1494
+ "message": "Agent loaded the unscoped global fix-medical-wiki skill after loading the AGY plugin launcher.",
1495
+ "next_action": (
1496
+ "Reportar como bug de skill routing; o launcher deve carregar "
1497
+ "${extensionPath}/skills/fix-medical-wiki/SKILL.md por path escopado."
1498
+ ),
1499
+ }
1500
+ ]
1501
+
1502
+
1503
+ def _transcript_contains_stale_skill_view(transcript: Any) -> bool:
1504
+ stale_path = ".gemini/config/skills/fix-medical-wiki/skill.md"
1505
+
1506
+ def visit(value: Any) -> bool:
1507
+ if isinstance(value, list):
1508
+ return any(visit(item) for item in value)
1509
+ if not isinstance(value, dict):
1510
+ return False
1511
+ event_type = str(value.get("type") or "").lower()
1512
+ content = str(value.get("content") or "").lower()
1513
+ if event_type == "view_file" and stale_path in content:
1514
+ return True
1515
+ tool_name = _canonical_tool_name(_tool_name_from_record(value))
1516
+ parameters = _tool_parameters_from_record(value)
1517
+ if tool_name in {"view_file", "read_file"} and isinstance(parameters, dict):
1518
+ for raw in parameters.values():
1519
+ if isinstance(raw, str) and stale_path in raw.lower():
1520
+ return True
1521
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
1522
+ child = value.get(key)
1523
+ if isinstance(child, (dict, list)) and visit(child):
1524
+ return True
1525
+ return False
1526
+
1527
+ return visit(transcript)
1528
+
1529
+
1530
+ def _shell_chain_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1531
+ if tool_name != "run_shell_command":
1532
+ return None
1533
+ command = _shell_command_text(parameters)
1534
+ operator = _first_unquoted_shell_chain_operator(command)
1535
+ if not operator:
1536
+ return None
1537
+ return {
1538
+ "code": SHELL_CHAIN_CONTRACT_VIOLATION,
1539
+ "severity": "medium",
1540
+ "tool_name": tool_name,
1541
+ "bad_param": "command",
1542
+ "operator": operator,
1543
+ "message": "Tool call run_shell_command chained multiple shell operations in one command.",
1544
+ "next_action": (
1545
+ "Reportar como bug de orquestração; emita uma tool call por comando e aguarde cada JSON/exit code."
1546
+ ),
1547
+ }
1548
+
1549
+
1550
+ def _public_dev_escape_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1551
+ if tool_name != "run_shell_command":
1552
+ return None
1553
+ command = _shell_command_text(parameters)
1554
+ if not command:
1555
+ return None
1556
+ if not re.search(r"\bMEDNOTES_ALLOW_DEV_ESCAPE\s*=\s*(?:1|true|yes)\b", command, re.IGNORECASE):
1557
+ if not re.search(r"\b--skip-prompt-eval\b", command):
1558
+ return None
1559
+ return {
1560
+ "code": PUBLIC_DEV_ESCAPE_CONTRACT_VIOLATION,
1561
+ "severity": "high",
1562
+ "tool_name": "run_shell_command",
1563
+ "bad_param": "command",
1564
+ "message": "Tool call run_shell_command attempted to use a developer escape in a public workflow.",
1565
+ "next_action": (
1566
+ "Reportar como bug de orquestração; pare a execução pública e retome pela rota oficial "
1567
+ "com recibo/proveniência tipados, sem MEDNOTES_ALLOW_DEV_ESCAPE ou --skip-prompt-eval."
1568
+ ),
1569
+ }
1570
+
1571
+
1572
+ def _style_rewrite_direct_content_apply_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1573
+ if tool_name != "run_shell_command":
1574
+ return None
1575
+ command = _shell_command_text(parameters)
1576
+ if not command or "apply-style-rewrite" not in command:
1577
+ return None
1578
+ if "--target" not in command or "--content" not in command or "--dry-run" in command:
1579
+ return None
1580
+ return {
1581
+ "code": STYLE_REWRITE_DIRECT_CONTENT_APPLY,
1582
+ "severity": "high",
1583
+ "tool_name": "run_shell_command",
1584
+ "bad_param": "command",
1585
+ "message": "Agent attempted to apply a style rewrite from loose --target/--content paths.",
1586
+ "next_action": (
1587
+ "Reportar como bug de orquestração; aplique reescrita médica somente por "
1588
+ "apply-specialist-style-rewrite com plan, manifest, work_id e recibo especialista oficial."
1589
+ ),
1590
+ }
1591
+
1592
+
1593
+ def _style_rewrite_unverified_model_claim_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1594
+ if tool_name != "run_shell_command":
1595
+ return None
1596
+ command = _shell_command_text(parameters)
1597
+ if not command or "finalize-style-rewrite-output" not in command:
1598
+ return None
1599
+ if "--specialist-run-receipt" in command:
1600
+ return None
1601
+ if "--actual-model" not in command and "--provider" not in command:
1602
+ return None
1603
+ return {
1604
+ "code": STYLE_REWRITE_UNVERIFIED_MODEL_CLAIM,
1605
+ "severity": "high",
1606
+ "tool_name": "run_shell_command",
1607
+ "bad_param": "command",
1608
+ "message": "Agent attempted to finalize a specialist rewrite using parent-declared model provenance.",
1609
+ "next_action": (
1610
+ "Reportar como bug de orquestração; o parent não pode declarar Pro/Flash manualmente. "
1611
+ "Use somente specialist-task-run-receipt.v1 validado pelo Workbench."
1612
+ ),
1613
+ }
1614
+
1615
+
1616
+ def _specialist_unverified_model_escape_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1617
+ field_name, text = _first_parameter_containing(parameters, _UNVERIFIED_SPECIALIST_MODEL_ENV)
1618
+ if not text:
1619
+ return None
1620
+ return {
1621
+ "code": SPECIALIST_UNVERIFIED_MODEL_ESCAPE,
1622
+ "severity": "high",
1623
+ "tool_name": tool_name,
1624
+ "bad_param": field_name,
1625
+ "message": "Agent attempted to enable the unverified specialist model escape during a public workflow.",
1626
+ "next_action": (
1627
+ "Reportar como bug de orquestração; reescrita médica pública só pode avançar com "
1628
+ "specialist-task-run-receipt.v1 validado, sem MEDNOTES_ALLOW_UNVERIFIED_SPECIALIST_MODEL."
1629
+ ),
1630
+ }
1631
+
1632
+
1633
+ def _style_rewrite_parent_output_write_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1634
+ if tool_name not in _WORKFLOW_ARTIFACT_WRITE_TOOLS:
1635
+ return None
1636
+ for field, path in _workflow_artifact_write_paths(parameters):
1637
+ if not _looks_like_style_rewrite_parent_output_path(path):
1638
+ continue
1639
+ return {
1640
+ "code": STYLE_REWRITE_PARENT_OUTPUT_WRITE,
1641
+ "severity": "high",
1642
+ "tool_name": tool_name,
1643
+ "bad_param": field,
1644
+ "path": path,
1645
+ "message": (
1646
+ "Agent directly wrote a style-rewrite output artifact that must be produced by "
1647
+ "the specialist runner and Workbench finalization commands."
1648
+ ),
1649
+ "next_action": (
1650
+ "Reportar como bug de autoria/recibo; o parent deve chamar o especialista oficial "
1651
+ "e depois apply-specialist-style-rewrite, nunca escrever .rewrite.md, attestation "
1652
+ "ou receipt por write_file/write_to_file."
1653
+ ),
1654
+ }
1655
+ return None
1656
+
1657
+
1658
+ def _process_chats_raw_write_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1659
+ if tool_name not in _WORKFLOW_ARTIFACT_WRITE_TOOLS:
1660
+ return None
1661
+ for field, path in _workflow_artifact_write_paths(parameters):
1662
+ if not _looks_like_chats_raw_path(path):
1663
+ continue
1664
+ return {
1665
+ "code": PROCESS_CHATS_RAW_WRITE,
1666
+ "severity": "high",
1667
+ "tool_name": tool_name,
1668
+ "bad_param": field,
1669
+ "path": path,
1670
+ "message": "Agent attempted to write a raw chat file during process-chats instead of using wiki/cli.py.",
1671
+ "next_action": (
1672
+ "Reportar como bug de integridade; raw chat body é imutável e YAML/status "
1673
+ "só pode ser mutado por wiki/cli.py triage/discard/publish-batch."
1674
+ ),
1675
+ }
1676
+ return None
1677
+
1678
+
1679
+ def _process_chats_parent_artifact_write_without_subagent_finding(
1680
+ tool_name: str,
1681
+ parameters: dict[str, Any],
1682
+ *,
1683
+ process_chats_specialist_seen: bool,
1684
+ ) -> dict[str, Any] | None:
1685
+ if process_chats_specialist_seen:
1686
+ return None
1687
+ if tool_name not in _WORKFLOW_ARTIFACT_WRITE_TOOLS:
1688
+ return None
1689
+ for field, path in _workflow_artifact_write_paths(parameters):
1690
+ if not _looks_like_process_chats_generated_artifact_path(path):
1691
+ continue
1692
+ return {
1693
+ "code": PROCESS_CHATS_PARENT_ARTIFACT_WRITE_WITHOUT_SUBAGENT,
1694
+ "severity": "high",
1695
+ "tool_name": tool_name,
1696
+ "bad_param": field,
1697
+ "path": path,
1698
+ "message": "Agent wrote a process-chats artifact before any specialist/subagent invocation.",
1699
+ "next_action": (
1700
+ "Reportar como bug de autoria; use plan-subagents e um subagent/runner oficial "
1701
+ "antes de salvar note_plan, coverage, manifest ou Markdown temporário."
1702
+ ),
1703
+ }
1704
+ return None
1705
+
1706
+
1707
+ def _workflow_source_discovery_after_block_finding(tool_name: str, parameters: JsonObject) -> JsonObject | None:
1708
+ command = _shell_command_text(parameters) if tool_name == "run_shell_command" else ""
1709
+ file_path = ""
1710
+ if tool_name in {"read_file", "view_file"}:
1711
+ _field, file_path = _first_string_parameter(parameters, ("file_path", "path", "absolute_path"))
1712
+ if command:
1713
+ lowered_command = command.lower()
1714
+ source_probe = bool(
1715
+ "bundle/scripts/mednotes" in lowered_command
1716
+ and (
1717
+ (
1718
+ re.search(r"\b(?:grep|rg)\b", lowered_command)
1719
+ and (
1720
+ "mednotes_allow_dev_escape" in lowered_command
1721
+ or "specialist-task-run-receipt" in lowered_command
1722
+ or "apply-style-rewrite" in lowered_command
1723
+ or "finalize-style-rewrite-output" in lowered_command
1724
+ )
1725
+ )
1726
+ or (
1727
+ re.search(r"\b(?:cat|sed|nl|head|tail|less)\b", lowered_command)
1728
+ and re.search(r"bundle/scripts/mednotes/[^\"' ]+\.py\b", lowered_command)
1729
+ )
1730
+ )
1731
+ )
1732
+ else:
1733
+ lowered_path = file_path.lower()
1734
+ source_probe = "bundle/scripts/mednotes/" in lowered_path and lowered_path.endswith(".py")
1735
+ if not source_probe:
1736
+ return None
1737
+ return {
1738
+ "code": WORKFLOW_SOURCE_DISCOVERY_AFTER_BLOCK,
1739
+ "severity": "medium",
1740
+ "tool_name": tool_name,
1741
+ "bad_param": "command" if command else "file_path",
1742
+ "message": "Agent inspected Workbench source code while executing a public workflow instead of following the typed payload.",
1743
+ "next_action": (
1744
+ "Reportar como atrito de UX; o workflow deve oferecer continuação oficial ou bloqueio terminal, "
1745
+ "sem induzir o agente a procurar bypass em código-fonte."
1746
+ ),
1747
+ }
1748
+
1749
+
1750
+ def _python_environment_probe_finding(tool_name: str, parameters: dict[str, Any]) -> dict[str, Any] | None:
1751
+ if tool_name != "run_shell_command":
1752
+ return None
1753
+ command = _shell_command_text(parameters)
1754
+ if not re.search(r"\bpython(?:3(?:\.\d+)?)?\s+-c\b", command):
1755
+ return None
1756
+ if not any(
1757
+ marker in command for marker in ("MEDNOTES", "MEDICAL_NOTES_WORKBENCH", "GEMINI", "medical-notes-workbench")
1758
+ ):
1759
+ return None
1760
+ if "uv run" in command or "scripts/run_python.mjs" in command or "wiki/cli.py" in command:
1761
+ return None
1762
+ return {
1763
+ "code": NONCANONICAL_PYTHON_ENVIRONMENT_PROBE,
1764
+ "severity": "medium",
1765
+ "tool_name": tool_name,
1766
+ "bad_param": "command",
1767
+ "message": "Tool call run_shell_command used ad hoc python -c to inspect Workbench environment state.",
1768
+ "next_action": (
1769
+ "Reportar como desvio operacional; use a rota oficial do Workbench ou um recibo tipado, "
1770
+ "e inclua o probe no relatório final se ele já ocorreu."
1771
+ ),
1772
+ }
1773
+
1774
+
1775
+ def _workflow_artifact_direct_write_finding(tool_name: str, parameters: JsonObject) -> JsonObject | None:
1776
+ if tool_name not in _WORKFLOW_ARTIFACT_WRITE_TOOLS:
1777
+ return None
1778
+ for field, path in _workflow_artifact_write_paths(parameters):
1779
+ if not _looks_like_workflow_artifact_path(path):
1780
+ continue
1781
+ return {
1782
+ "code": WORKFLOW_ARTIFACT_DIRECT_WRITE,
1783
+ "severity": "high",
1784
+ "tool_name": tool_name,
1785
+ "bad_param": field,
1786
+ "path": path,
1787
+ "message": f"Tool call {tool_name} directly modified a workflow artifact that must be produced by wiki/cli.py.",
1788
+ "next_action": (
1789
+ "Reportar como bug de orquestração; regenere o artefato pela rota oficial do "
1790
+ "wiki/cli.py e não use write_file/replace/edit para plans, manifests, receipts ou reports."
1791
+ ),
1792
+ }
1793
+ return None
1794
+
1795
+
1796
+ def _workflow_artifact_shell_copy_finding(tool_name: str, parameters: JsonObject) -> JsonObject | None:
1797
+ if tool_name != "run_shell_command":
1798
+ return None
1799
+ command = _shell_command_text(parameters)
1800
+ if not command or not re.search(r"\b(cp|copy|Copy-Item)\b", command):
1801
+ return None
1802
+ normalized = command.replace("\\", "/")
1803
+ if "/.gemini/antigravity-cli/scratch/" not in normalized:
1804
+ return None
1805
+ artifact_names = _workflow_artifact_names_in_command(normalized)
1806
+ if not artifact_names:
1807
+ return None
1808
+ return {
1809
+ "code": WORKFLOW_ARTIFACT_SHELL_COPY,
1810
+ "severity": "medium",
1811
+ "tool_name": tool_name,
1812
+ "bad_param": "command",
1813
+ "artifact_names": artifact_names,
1814
+ "message": "Tool call run_shell_command copied workflow artifacts into AGY scratch outside the official run directory.",
1815
+ "next_action": (
1816
+ "Reportar como contaminação do experimento; preserve artefatos na pasta oficial da rodada "
1817
+ "ou no diretório lab artifact, não em scratch global."
1818
+ ),
1819
+ }
1820
+
1821
+
1822
+ def _workflow_artifact_shell_redirect_finding(tool_name: str, parameters: JsonObject) -> JsonObject | None:
1823
+ if tool_name != "run_shell_command":
1824
+ return None
1825
+ command = _shell_command_text(parameters)
1826
+ if not command or not _has_shell_stdout_redirect(command):
1827
+ return None
1828
+ normalized = command.replace("\\", "/")
1829
+ if "/.gemini/antigravity-cli/scratch/" not in normalized:
1830
+ return None
1831
+ artifact_names = _workflow_artifact_names_in_command(normalized)
1832
+ if not artifact_names:
1833
+ return None
1834
+ return {
1835
+ "code": WORKFLOW_ARTIFACT_SHELL_REDIRECT,
1836
+ "severity": "medium",
1837
+ "tool_name": tool_name,
1838
+ "bad_param": "command",
1839
+ "artifact_names": artifact_names,
1840
+ "message": "Tool call run_shell_command redirected workflow output into AGY scratch outside the official run directory.",
1841
+ "next_action": (
1842
+ "Reportar como contaminação do experimento; use os artefatos oficiais emitidos pelo workflow "
1843
+ "ou o diretório lab artifact, não scratch global."
1844
+ ),
1845
+ }
1846
+
1847
+
1848
+ def _duplicate_workflow_command_findings(transcript: object) -> list[JsonObject]:
1849
+ counts: dict[tuple[str, str], int] = {}
1850
+ for tool_name, parameters in _iter_agent_tool_calls(transcript):
1851
+ if _canonical_tool_name(tool_name) != "run_shell_command":
1852
+ continue
1853
+ command = _shell_command_text(parameters)
1854
+ workflow_key = _workflow_command_key(command)
1855
+ if not workflow_key:
1856
+ continue
1857
+ counts[workflow_key] = counts.get(workflow_key, 0) + 1
1858
+
1859
+ findings: list[JsonObject] = []
1860
+ for (workflow, mode), count in sorted(counts.items()):
1861
+ if count <= 1:
1862
+ continue
1863
+ findings.append(
1864
+ {
1865
+ "code": DUPLICATE_WORKFLOW_COMMAND,
1866
+ "severity": "medium",
1867
+ "tool_name": "run_shell_command",
1868
+ "workflow": workflow,
1869
+ "mode": mode,
1870
+ "count": count,
1871
+ "message": f"Agent invoked the same {workflow} {mode} workflow more than once in one session.",
1872
+ "next_action": (
1873
+ "Reportar como desvio operacional; leia compact_report/full_report ou artefatos oficiais em vez de repetir "
1874
+ "o workflow sem mudança de entrada."
1875
+ ),
1876
+ }
1877
+ )
1878
+ return findings
1879
+
1880
+
1881
+ def _workflow_command_key(command: str) -> tuple[str, str] | None:
1882
+ if not command:
1883
+ return None
1884
+ if re.search(r"(?:^|[\s\"'])fix-wiki(?:\s|$)", command) and "--dry-run" in command and "--apply" not in command:
1885
+ return ("/mednotes:fix-wiki", "preview")
1886
+ return None
1887
+
1888
+
1889
+ def _final_artifact_path_findings(transcript: Any) -> list[dict[str, Any]]:
1890
+ findings: list[dict[str, Any]] = []
1891
+ for path in _final_response_file_uri_paths(transcript):
1892
+ if not _looks_like_reported_workflow_artifact_path(path):
1893
+ continue
1894
+ if Path(path).exists():
1895
+ continue
1896
+ findings.append(
1897
+ {
1898
+ "code": FINAL_ARTIFACT_PATH_INVALID,
1899
+ "severity": "medium",
1900
+ "tool_name": "planner_response",
1901
+ "bad_param": "content",
1902
+ "path": path,
1903
+ "artifact_name": path.replace("\\", "/").rsplit("/", 1)[-1],
1904
+ "message": "Agent final response linked a workflow artifact path that does not exist.",
1905
+ "next_action": (
1906
+ "Reportar como bug de relatório final; use somente os caminhos oficiais emitidos pelo workflow "
1907
+ "e confira existência antes de publicar links de artefato."
1908
+ ),
1909
+ }
1910
+ )
1911
+ return findings
1912
+
1913
+
1914
+ def _manual_packaged_subagent_findings(transcript: Any) -> list[dict[str, Any]]:
1915
+ findings: list[dict[str, Any]] = []
1916
+ seen: set[str] = set()
1917
+ valid_packaged_definitions: set[str] = set()
1918
+
1919
+ def append(agent_name: str) -> None:
1920
+ if agent_name not in _PACKAGED_SPECIALIST_AGENTS:
1921
+ return
1922
+ if agent_name in valid_packaged_definitions:
1923
+ return
1924
+ if agent_name in seen:
1925
+ return
1926
+ seen.add(agent_name)
1927
+ findings.append(
1928
+ {
1929
+ "code": MANUAL_SUBAGENT_CONTRACT_VIOLATION,
1930
+ "severity": "high",
1931
+ "tool_name": "define_subagent",
1932
+ "bad_param": "agent_name",
1933
+ "agent_name": agent_name,
1934
+ "message": f"Agent manually defined the packaged {agent_name} instead of using the bundled agent.",
1935
+ "next_action": (
1936
+ "Reportar como bug de orquestração/modelo; para style_rewrite no AGY, leia o template "
1937
+ "empacotado completo, use define_subagent autorizado e finalize com finalize-agy-specialist-task."
1938
+ ),
1939
+ }
1940
+ )
1941
+
1942
+ def is_authorized_template_definition(agent_name: str, parameters: dict[str, Any]) -> bool:
1943
+ markers = _PACKAGED_SPECIALIST_AGENT_TEMPLATE_MARKERS.get(agent_name)
1944
+ if not markers:
1945
+ return False
1946
+ prompt_parts = [
1947
+ str(parameters.get(field) or "")
1948
+ for field in _SUBAGENT_SYSTEM_PROMPT_PARAMETER_FIELDS
1949
+ if parameters.get(field)
1950
+ ]
1951
+ prompt = "\n".join(prompt_parts)
1952
+ return bool(prompt) and all(marker in prompt for marker in markers)
1953
+
1954
+ def visit(value: Any) -> None:
1955
+ if isinstance(value, list):
1956
+ for item in value:
1957
+ visit(item)
1958
+ return
1959
+ if not isinstance(value, dict):
1960
+ return
1961
+ tool_name = _canonical_tool_name(_tool_name_from_record(value))
1962
+ parameters = _tool_parameters_from_record(value)
1963
+ if tool_name in _SUBAGENT_DEFINITION_TOOLS and parameters is not None:
1964
+ _, agent_name = _first_string_parameter(parameters, _AGENT_NAME_PARAMETER_FIELDS)
1965
+ if is_authorized_template_definition(agent_name, parameters):
1966
+ valid_packaged_definitions.add(agent_name)
1967
+ return
1968
+ append(agent_name)
1969
+ raw_text = str(value.get("content") or value.get("message") or value.get("text") or "")
1970
+ folded = raw_text.casefold()
1971
+ for agent_name in _PACKAGED_SPECIALIST_AGENTS:
1972
+ if f'subagent "{agent_name}" defined successfully' in folded:
1973
+ append(agent_name)
1974
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
1975
+ child = value.get(key)
1976
+ if isinstance(child, (dict, list)):
1977
+ visit(child)
1978
+
1979
+ visit(transcript)
1980
+ return findings
1981
+
1982
+
1983
+ def _agy_hidden_workspace_findings(transcript: Any) -> list[dict[str, Any]]:
1984
+ findings: list[dict[str, Any]] = []
1985
+
1986
+ def visit(value: Any) -> None:
1987
+ if isinstance(value, str):
1988
+ if _AGY_HIDDEN_WORKSPACE_RE.search(value):
1989
+ findings.append(
1990
+ {
1991
+ "code": WORKSPACE_ADD_DIR_HIDDEN_IGNORED,
1992
+ "severity": "high",
1993
+ "tool_name": "add_workspace_folder",
1994
+ "bad_param": "path",
1995
+ "message": "AGY ignored an --add-dir/workspace folder because the path is hidden.",
1996
+ "next_action": (
1997
+ "Preparar o vault de experimento em diretório visível ao AGY e repetir a rodada; "
1998
+ "não contorne a falha lendo e colando conteúdo bruto no subagente."
1999
+ ),
2000
+ }
2001
+ )
2002
+ return
2003
+ if isinstance(value, list):
2004
+ for item in value:
2005
+ visit(item)
2006
+ return
2007
+ if isinstance(value, dict):
2008
+ for item in value.values():
2009
+ visit(item)
2010
+
2011
+ visit(transcript)
2012
+ return findings
2013
+
2014
+
2015
+ def _final_response_file_uri_paths(transcript: Any) -> list[str]:
2016
+ paths: list[str] = []
2017
+
2018
+ def visit(value: Any) -> None:
2019
+ if isinstance(value, list):
2020
+ for item in value:
2021
+ visit(item)
2022
+ return
2023
+ if not isinstance(value, dict):
2024
+ return
2025
+ event_type = str(value.get("type") or "").upper()
2026
+ if event_type == "PLANNER_RESPONSE":
2027
+ for field in ("content", "text", "message", "response"):
2028
+ raw = value.get(field)
2029
+ if isinstance(raw, str):
2030
+ paths.extend(_file_uri_paths_in_text(raw))
2031
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
2032
+ child = value.get(key)
2033
+ if isinstance(child, (dict, list)):
2034
+ visit(child)
2035
+
2036
+ visit(transcript)
2037
+ return paths
2038
+
2039
+
2040
+ def _file_uri_paths_in_text(text: str) -> list[str]:
2041
+ paths: list[str] = []
2042
+ for match in re.finditer(r"file://(?P<path>/[^\s)\]`>\"']+)", text):
2043
+ raw_path = unquote(match.group("path")).rstrip(".,;")
2044
+ if raw_path and raw_path not in paths:
2045
+ paths.append(raw_path)
2046
+ return paths
2047
+
2048
+
2049
+ def _workflow_artifact_names_in_command(command: str) -> list[str]:
2050
+ return sorted(name for name in _WORKFLOW_ARTIFACT_SCRATCH_NAMES if name in command)
2051
+
2052
+
2053
+ def _has_shell_stdout_redirect(command: str) -> bool:
2054
+ quote = ""
2055
+ escaped = False
2056
+ index = 0
2057
+ while index < len(command):
2058
+ char = command[index]
2059
+ if escaped:
2060
+ escaped = False
2061
+ index += 1
2062
+ continue
2063
+ if char == "\\":
2064
+ escaped = True
2065
+ index += 1
2066
+ continue
2067
+ if quote:
2068
+ if char == quote:
2069
+ quote = ""
2070
+ index += 1
2071
+ continue
2072
+ if char in {"'", '"'}:
2073
+ quote = char
2074
+ index += 1
2075
+ continue
2076
+ if char == ">":
2077
+ return True
2078
+ index += 1
2079
+ return bool(re.search(r"\b(?:Out-File|Set-Content)\b", command))
2080
+
2081
+
2082
+ def _workflow_artifact_write_paths(parameters: JsonObject) -> list[tuple[str, str]]:
2083
+ paths: list[tuple[str, str]] = []
2084
+ for field in _WORKFLOW_ARTIFACT_PATH_FIELDS:
2085
+ value = parameters.get(field)
2086
+ if isinstance(value, str) and value.strip():
2087
+ paths.append((field, value.strip()))
2088
+ return paths
2089
+
2090
+
2091
+ def _shell_command_text(parameters: dict[str, Any]) -> str:
2092
+ for field in _SHELL_COMMAND_PARAMETER_FIELDS:
2093
+ value = parameters.get(field)
2094
+ if isinstance(value, str) and value.strip():
2095
+ return _unwrap_tool_command_line(value.strip())
2096
+ return ""
2097
+
2098
+
2099
+ def _unwrap_tool_command_line(command: str) -> str:
2100
+ if len(command) < 2 or command[0] != command[-1] or command[0] not in {"'", '"'}:
2101
+ return command
2102
+ quote = command[0]
2103
+ unwrapped = command[1:-1]
2104
+ if quote == '"':
2105
+ return unwrapped.replace('\\"', '"')
2106
+ return unwrapped.replace("\\'", "'")
2107
+
2108
+
2109
+ def _transcript_contains(transcript: Any, needle: str) -> bool:
2110
+ needle = needle.lower()
2111
+
2112
+ def visit(value: Any) -> bool:
2113
+ if isinstance(value, str):
2114
+ return needle in value.lower()
2115
+ if isinstance(value, list):
2116
+ return any(visit(item) for item in value)
2117
+ if isinstance(value, dict):
2118
+ return any(visit(str(key)) or visit(item) for key, item in value.items())
2119
+ return False
2120
+
2121
+ return visit(transcript)
2122
+
2123
+
2124
+ def _first_parameter_containing(parameters: dict[str, Any], needle: str) -> tuple[str, str]:
2125
+ folded_needle = needle.casefold()
2126
+ for field, value in parameters.items():
2127
+ if not isinstance(value, str):
2128
+ continue
2129
+ if folded_needle in value.casefold():
2130
+ return field, value
2131
+ return "", ""
2132
+
2133
+
2134
+ def _normalized_operational_path(value: str) -> str:
2135
+ return value.replace("\\", "/").casefold()
2136
+
2137
+
2138
+ def _looks_like_workflow_artifact_path(path: str) -> bool:
2139
+ normalized = path.replace("\\", "/")
2140
+ name = normalized.rsplit("/", 1)[-1]
2141
+ if not name.endswith(".json"):
2142
+ return False
2143
+ return "/runs/" in normalized and bool(_WORKFLOW_ARTIFACT_NAME_RE.search(name))
2144
+
2145
+
2146
+ def _looks_like_style_rewrite_parent_output_path(path: str) -> bool:
2147
+ normalized = _normalized_operational_path(path)
2148
+ if "/tmp/agent-work/fix-wiki/style-rewrite-" not in normalized:
2149
+ return False
2150
+ return normalized.endswith(
2151
+ (
2152
+ ".rewrite.md",
2153
+ ".rewrite.md.attestation.json",
2154
+ ".rewrite.md.receipt.json",
2155
+ ".style-rewrite-output.json",
2156
+ )
2157
+ )
2158
+
2159
+
2160
+ def _looks_like_chats_raw_path(path: str) -> bool:
2161
+ return "/chats_raw/" in _normalized_operational_path(path)
2162
+
2163
+
2164
+ def _looks_like_process_chats_generated_artifact_path(path: str) -> bool:
2165
+ normalized = _normalized_operational_path(path)
2166
+ if "/process-chats/" not in normalized:
2167
+ return False
2168
+ return normalized.endswith(_PROCESS_CHATS_ARTIFACT_SUFFIXES)
2169
+
2170
+
2171
+ def _is_process_chats_specialist_invocation(tool_name: str) -> bool:
2172
+ return tool_name in {"invoke_agent", "invoke_subagent", "send_message"}
2173
+
2174
+
2175
+ def _looks_like_reported_workflow_artifact_path(path: str) -> bool:
2176
+ normalized = path.replace("\\", "/")
2177
+ name = normalized.rsplit("/", 1)[-1]
2178
+ return name in _WORKFLOW_ARTIFACT_SCRATCH_NAMES or _looks_like_workflow_artifact_path(path)
2179
+
2180
+
2181
+ def _first_unquoted_shell_chain_operator(command: str) -> str:
2182
+ quote = ""
2183
+ escaped = False
2184
+ index = 0
2185
+ while index < len(command):
2186
+ char = command[index]
2187
+ if escaped:
2188
+ escaped = False
2189
+ index += 1
2190
+ continue
2191
+ if char == "\\":
2192
+ escaped = True
2193
+ index += 1
2194
+ continue
2195
+ if quote:
2196
+ if char == quote:
2197
+ quote = ""
2198
+ index += 1
2199
+ continue
2200
+ if char in {"'", '"'}:
2201
+ quote = char
2202
+ index += 1
2203
+ continue
2204
+ if command.startswith("&&", index):
2205
+ return "&&"
2206
+ if command.startswith("||", index):
2207
+ return "||"
2208
+ if char == ";":
2209
+ return ";"
2210
+ if char == "\n" and command[:index].strip() and command[index + 1 :].strip():
2211
+ return "newline"
2212
+ index += 1
2213
+ return ""
2214
+
2215
+
2216
+ def _iter_agent_tool_calls(node: Any) -> list[tuple[str, dict[str, Any]]]:
2217
+ calls: list[tuple[str, dict[str, Any]]] = []
2218
+
2219
+ def visit(value: Any) -> None:
2220
+ if isinstance(value, list):
2221
+ for item in value:
2222
+ visit(item)
2223
+ return
2224
+ if not isinstance(value, dict):
2225
+ return
2226
+ tool_name = _tool_name_from_record(value)
2227
+ parameters = _tool_parameters_from_record(value)
2228
+ if tool_name and parameters is not None:
2229
+ calls.append((tool_name, parameters))
2230
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
2231
+ child = value.get(key)
2232
+ if isinstance(child, (dict, list)):
2233
+ visit(child)
2234
+
2235
+ visit(node)
2236
+ return calls
2237
+
2238
+
2239
+ def _iter_tool_call_batches(node: Any) -> list[list[tuple[str, dict[str, Any]]]]:
2240
+ batches: list[list[tuple[str, dict[str, Any]]]] = []
2241
+
2242
+ def visit(value: Any) -> None:
2243
+ if isinstance(value, list):
2244
+ for item in value:
2245
+ visit(item)
2246
+ return
2247
+ if not isinstance(value, dict):
2248
+ return
2249
+ for key in ("tool_calls", "toolCalls", "calls"):
2250
+ raw_batch = value.get(key)
2251
+ if not isinstance(raw_batch, list):
2252
+ continue
2253
+ batch: list[tuple[str, dict[str, Any]]] = []
2254
+ for item in raw_batch:
2255
+ if not isinstance(item, dict):
2256
+ continue
2257
+ tool_name = _tool_name_from_record(item)
2258
+ parameters = _tool_parameters_from_record(item)
2259
+ if tool_name and parameters is not None:
2260
+ batch.append((tool_name, parameters))
2261
+ if batch:
2262
+ batches.append(batch)
2263
+ for key in _TRANSCRIPT_CHILD_CONTAINER_KEYS:
2264
+ child = value.get(key)
2265
+ if isinstance(child, (dict, list)):
2266
+ visit(child)
2267
+
2268
+ visit(node)
2269
+ return batches
2270
+
2271
+
2272
+ def _tool_name_from_record(record: dict[str, Any]) -> str:
2273
+ for key in ("tool_name", "toolName", "name", "tool", "recipient_name", "recipient"):
2274
+ value = record.get(key)
2275
+ if isinstance(value, str) and value.strip():
2276
+ return value.strip()
2277
+ return ""
2278
+
2279
+
2280
+ def _tool_parameters_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
2281
+ for key in ("parameters", "args", "arguments", "tool_input", "toolInput", "input"):
2282
+ value = record.get(key)
2283
+ if isinstance(value, dict):
2284
+ return value
2285
+ return None
2286
+
2287
+
2288
+ def _canonical_tool_name(tool_name: str) -> str:
2289
+ normalized = str(tool_name or "").replace("functions.", "").replace("tools.", "")
2290
+ normalized = normalized.replace(" ", "_").lower()
2291
+ if normalized in _RUN_SHELL_TOOL_ALIASES:
2292
+ return "run_shell_command"
2293
+ return normalized