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,1767 @@
1
+ """Safe subagent planning for Wiki workflows."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import unicodedata
8
+ from collections.abc import Sequence
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, StrictStr, field_validator
14
+
15
+ from mednotes.domains.wiki.capabilities.atomicity.atomicity import build_atomicity_split_plan
16
+ from mednotes.domains.wiki.capabilities.notes.artifacts import discover_artifact_manifests
17
+ from mednotes.domains.wiki.capabilities.notes.meaning_planner import plan_meaning_work_items
18
+ from mednotes.domains.wiki.capabilities.notes.note_plan import (
19
+ PLANNED_MEANING_ACTION,
20
+ TRIAGE_NOTE_PLAN_V2_SCHEMA,
21
+ note_plan_summary,
22
+ parse_triage_note_plan,
23
+ )
24
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import covered_raw_chat_index, list_by_status, read_note_meta
25
+ from mednotes.domains.wiki.capabilities.specialist.plan_attestation import attach_subagent_plan_attestation
26
+ from mednotes.domains.wiki.capabilities.style.style import validate_wiki_style
27
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import normalize_key
28
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_curator_batch import build_vocabulary_curator_batch_plan
29
+ from mednotes.domains.wiki.common import SUBAGENT_PLAN_SCHEMA, MedOpsError, ValidationError
30
+ from mednotes.domains.wiki.config import MedConfig, _user_state_dir
31
+ from mednotes.domains.wiki.contracts.agents import SubagentBatchPlan
32
+ from mednotes.domains.wiki.contracts.workflow_guardrails import (
33
+ PROCESS_CHATS_REQUIRED_INPUTS,
34
+ STYLE_REWRITE_REQUIRED_INPUTS,
35
+ annotate_payload,
36
+ note_target_index,
37
+ plan_status,
38
+ )
39
+ from mednotes.domains.wiki.contracts.workflow_outcomes import (
40
+ DecisionEvidence,
41
+ RejectedAutomation,
42
+ WorkflowDecision,
43
+ )
44
+ from mednotes.kernel.agent_directive import AgentDirective
45
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
46
+ from mednotes.kernel.public_report import WorkflowPublicReport
47
+ from mednotes.platform.user_config import ParallelismConfig
48
+
49
+ _DEFAULT_PARALLELISM = ParallelismConfig()
50
+ DEFAULT_PROCESS_CHATS_MAX_CONCURRENCY = _DEFAULT_PARALLELISM.process_chats_max_parallel_architects
51
+ DEFAULT_STYLE_REWRITE_MAX_CONCURRENCY = _DEFAULT_PARALLELISM.fix_wiki_max_parallel_rewrites
52
+ CANONICAL_MERGE_PLAN_SCHEMA = "medical-notes-workbench.canonical-merge-plan.v1"
53
+
54
+
55
+ class _PlannedMeaningTarget(ContractModel):
56
+ """Typed target identity extracted from a validated triage note plan."""
57
+
58
+ id: StrictStr
59
+ title: StrictStr
60
+ target_key: StrictStr
61
+
62
+
63
+ class _PlannedMatch(ContractModel):
64
+ """Typed source-to-target match used to decide canonical merge routes."""
65
+
66
+ raw_file: StrictStr
67
+ work_id: StrictStr
68
+ id: StrictStr
69
+ title: StrictStr
70
+
71
+
72
+ class _TriageNotePlanItem(BaseModel):
73
+ """Typed view of a triage note-plan item used for routing decisions."""
74
+
75
+ model_config = ConfigDict(extra="ignore")
76
+
77
+ id: StrictStr = ""
78
+ action: StrictStr = ""
79
+ staged_title: StrictStr = ""
80
+ title: StrictStr = ""
81
+
82
+
83
+ class _TriageNotePlan(BaseModel):
84
+ """Validated triage note plan plus its public JSON payload.
85
+
86
+ The parent workflow still passes the full note-plan payload to downstream
87
+ contracts, but all routing decisions in this module read this typed view.
88
+ """
89
+
90
+ model_config = ConfigDict(extra="ignore")
91
+
92
+ schema_: StrictStr = Field(default="", alias="schema")
93
+ items: list[_TriageNotePlanItem] = Field(default_factory=list)
94
+ _payload: JsonObject = PrivateAttr(default_factory=dict)
95
+
96
+ @classmethod
97
+ def from_payload(cls, payload: object) -> _TriageNotePlan:
98
+ json_payload = JsonObjectAdapter.validate_python(payload)
99
+ plan = cls.model_validate(json_payload)
100
+ plan._payload = json_payload
101
+ return plan
102
+
103
+ def public_payload(self) -> JsonObject:
104
+ return dict(self._payload)
105
+
106
+
107
+ class _DuplicateTarget(ContractModel):
108
+ """Typed duplicate target; JSON projection happens only at boundaries."""
109
+
110
+ id: StrictStr = ""
111
+ title: StrictStr = ""
112
+ target_key: StrictStr
113
+ conflict_type: Literal["ambiguous_existing_wiki_note", "existing_wiki_note", "planned_in_batch"]
114
+ existing_paths: list[StrictStr] = Field(default_factory=list)
115
+ planned_matches: list[_PlannedMatch] = Field(default_factory=list)
116
+
117
+
118
+ class _SubagentAnnotationPayload(BaseModel):
119
+ """Typed read model for annotating a public subagent plan payload."""
120
+
121
+ model_config = ConfigDict(extra="ignore")
122
+
123
+ phase: StrictStr = ""
124
+ agent: StrictStr = ""
125
+ work_items: list[JsonObject] = Field(default_factory=list)
126
+ blocked_items: list[JsonObject] = Field(default_factory=list)
127
+
128
+
129
+ class _SubagentAnnotationWorkItem(BaseModel):
130
+ """Typed read model for one work item while preserving its raw output."""
131
+
132
+ model_config = ConfigDict(extra="ignore")
133
+
134
+ phase: StrictStr = ""
135
+ expected_output_schema: JsonObject | StrictStr | None = None
136
+
137
+
138
+ class _SubagentGeneratedPlanSummary(BaseModel):
139
+ """Minimal typed view for generated plans before concurrency policy."""
140
+
141
+ model_config = ConfigDict(extra="ignore")
142
+
143
+ item_count: int = Field(default=0, ge=0)
144
+
145
+
146
+ class _StyleRewriteAuditReport(BaseModel):
147
+ """Typed style-audit row used to plan specialist rewrite work."""
148
+
149
+ model_config = ConfigDict(extra="ignore")
150
+
151
+ requires_llm_rewrite: bool = False
152
+ path: StrictStr = ""
153
+ title: StrictStr = ""
154
+ rewrite_prompt: StrictStr = ""
155
+ errors: list[JsonObject] = Field(default_factory=list)
156
+ warnings: list[JsonObject] = Field(default_factory=list)
157
+
158
+ @field_validator("path", "title", "rewrite_prompt", mode="before")
159
+ @classmethod
160
+ def _optional_text(cls, value: object) -> str:
161
+ return "" if value is None else str(value)
162
+
163
+ @field_validator("errors", "warnings", mode="before")
164
+ @classmethod
165
+ def _optional_json_list(cls, value: object) -> list[JsonObject]:
166
+ if not isinstance(value, list):
167
+ return []
168
+ return [JsonObjectAdapter.validate_python(item) for item in value if isinstance(item, dict)]
169
+
170
+
171
+ class _StyleRewriteAuditSummary(BaseModel):
172
+ """Typed style-audit summary used in the public subagent plan payload."""
173
+
174
+ model_config = ConfigDict(extra="ignore")
175
+
176
+ schema_: StrictStr = Field(default="", alias="schema")
177
+ wiki_dir: StrictStr = ""
178
+ file_count: int = Field(default=0, ge=0)
179
+ error_count: int = Field(default=0, ge=0)
180
+ warning_count: int = Field(default=0, ge=0)
181
+ reports: list[_StyleRewriteAuditReport] = Field(default_factory=list)
182
+
183
+ class _RawChatPlanningRow(BaseModel):
184
+ """Typed raw-chat listing row used by subagent planning."""
185
+
186
+ model_config = ConfigDict(extra="ignore")
187
+
188
+ path: StrictStr
189
+ titulo_triagem: StrictStr = ""
190
+ fonte_id: StrictStr = ""
191
+
192
+
193
+ class _ReadNoteMeta(BaseModel):
194
+ """Typed metadata slice read from raw chat YAML/frontmatter."""
195
+
196
+ model_config = ConfigDict(extra="ignore")
197
+
198
+ note_plan: StrictStr = ""
199
+
200
+
201
+ class _MeaningPlannerResult(BaseModel):
202
+ """Typed view of meaning-planner output before architect fan-out."""
203
+
204
+ model_config = ConfigDict(extra="ignore")
205
+
206
+ work_items: list[JsonObject] = Field(default_factory=list)
207
+ blocked_items: list[JsonObject] = Field(default_factory=list)
208
+ next_action: StrictStr = ""
209
+
210
+
211
+ class _ArchitectParsedItem(ContractModel):
212
+ """Internal typed architect planning row before public payload emission."""
213
+
214
+ item: JsonObject
215
+ note_plan: _TriageNotePlan
216
+ targets: list[_PlannedMeaningTarget] = Field(default_factory=list)
217
+ duplicate_targets: list[_DuplicateTarget] = Field(default_factory=list)
218
+
219
+
220
+ def _json_str_field(payload: JsonObject, key: str, default: str = "") -> str:
221
+ """Read an optional public JSON string after the object boundary is typed."""
222
+
223
+ if key not in payload:
224
+ return default
225
+ value = payload[key]
226
+ if value is None:
227
+ return default
228
+ return str(value)
229
+
230
+
231
+ def _json_list_field(payload: JsonObject, key: str) -> list[object]:
232
+ """Read an optional public JSON list without `.get()` fallback semantics."""
233
+
234
+ if key not in payload:
235
+ return []
236
+ value = payload[key]
237
+ return list(value) if isinstance(value, list) else []
238
+
239
+
240
+ def _json_object_list_field(payload: JsonObject, key: str) -> list[JsonObject]:
241
+ """Read an optional list of public JSON objects from a typed payload."""
242
+
243
+ return [JsonObjectAdapter.validate_python(item) for item in _json_list_field(payload, key) if isinstance(item, dict)]
244
+
245
+
246
+ def _json_object_field(payload: JsonObject, key: str) -> JsonObject | None:
247
+ """Read one optional public JSON object from a typed payload."""
248
+
249
+ if key not in payload:
250
+ return None
251
+ value = payload[key]
252
+ return JsonObjectAdapter.validate_python(value) if isinstance(value, dict) else None
253
+
254
+
255
+ def _slug(value: str) -> str:
256
+ normalized = unicodedata.normalize("NFKD", value)
257
+ ascii_text = "".join(char for char in normalized if not unicodedata.combining(char))
258
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_text).strip("-._").lower()
259
+ return slug or "raw"
260
+
261
+
262
+ def _file_sha256(path: Path) -> str:
263
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
264
+
265
+
266
+ def configured_subagent_max_concurrency(config: MedConfig, phase: str) -> int:
267
+ parallelism = config.user_config.parallelism
268
+ defaults = {
269
+ "triage": parallelism.process_chats_max_parallel_triagers,
270
+ "architect": parallelism.process_chats_max_parallel_architects,
271
+ "style-rewrite": parallelism.fix_wiki_max_parallel_rewrites,
272
+ "note-merge": parallelism.fix_wiki_max_parallel_rewrites,
273
+ "atomicity-split": parallelism.fix_wiki_max_parallel_rewrites,
274
+ "vocabulary-curation": parallelism.link_max_parallel_curators,
275
+ }
276
+ if phase not in defaults:
277
+ raise ValidationError(f"Unknown subagent planning phase: {phase}")
278
+ return defaults[phase]
279
+
280
+
281
+ def _chunked(items: list[JsonObject], size: int) -> list[list[JsonObject]]:
282
+ return [items[index : index + size] for index in range(0, len(items), size)]
283
+
284
+
285
+ def _batch_refs(items: list[JsonObject], size: int) -> list[JsonObject]:
286
+ batches: list[JsonObject] = []
287
+ for batch_index, batch in enumerate(_chunked(items, size), start=1):
288
+ batches.append(
289
+ {
290
+ "batch": batch_index,
291
+ "max_concurrency": size,
292
+ "item_count": len(batch),
293
+ "work_ids": [str(item["work_id"]) for item in batch],
294
+ "owner_keys": [str(item["owner_key"]) for item in batch],
295
+ }
296
+ )
297
+ return batches
298
+
299
+
300
+ def _expected_output_schema_for_phase(phase: str) -> dict[str, str]:
301
+ schemas = {
302
+ "triage": {
303
+ "schema": "medical-notes-workbench.triage-output.v1",
304
+ "description": "Saida estruturada do triager com note_plan v2.",
305
+ },
306
+ "architect": {
307
+ "schema": "medical-notes-workbench.architect-output.v1",
308
+ "description": "Nota ou rewrite produzido pelo architect.",
309
+ },
310
+ "style-rewrite": {
311
+ "schema": "medical-notes-workbench.style-rewrite-output-attestation.v1",
312
+ "description": (
313
+ "O subagente escreve Markdown em temp_output; o pai gera a atestação assinada do Workbench com "
314
+ "finalize-style-rewrite-output."
315
+ ),
316
+ },
317
+ "note-merge": {
318
+ "schema": "medical-notes-workbench.note-merge-output.v1",
319
+ "description": "Merge semantico para apply-note-merge.",
320
+ },
321
+ "atomicity-split": {
322
+ "schema": "medical-notes-workbench.atomicity-split-bundle.v1",
323
+ "description": "Bundle de split atomico para apply-atomicity-split.",
324
+ },
325
+ "atomicity_split": {
326
+ "schema": "medical-notes-workbench.atomicity-split-bundle.v1",
327
+ "description": "Bundle de split atomico para apply-atomicity-split.",
328
+ },
329
+ "vocabulary-curation": {
330
+ "schema": "medical-notes-workbench.note-semantic-ingestion.v1",
331
+ "description": "JSON de curadoria semantica para apply-curator-batch.",
332
+ },
333
+ "vocabulary_curation": {
334
+ "schema": "medical-notes-workbench.note-semantic-ingestion.v1",
335
+ "description": "JSON de curadoria semantica para apply-curator-batch.",
336
+ },
337
+ }
338
+ if phase in schemas:
339
+ return dict(schemas[phase])
340
+ return dict(schemas["architect"])
341
+
342
+
343
+ def _annotate_work_items_for_subagent_contract(payload: JsonObject) -> JsonObject:
344
+ typed_payload = _SubagentAnnotationPayload.model_validate(payload)
345
+ phase = typed_payload.phase
346
+ agent = typed_payload.agent
347
+ annotated = dict(payload)
348
+ work_items = []
349
+ for raw_item in typed_payload.work_items:
350
+ typed_item = _SubagentAnnotationWorkItem.model_validate(raw_item)
351
+ item = dict(raw_item)
352
+ item_phase = typed_item.phase or phase
353
+ item.setdefault("phase", item_phase)
354
+ item.setdefault("agent", agent)
355
+ if not isinstance(typed_item.expected_output_schema, dict):
356
+ item["expected_output_schema"] = _expected_output_schema_for_phase(item_phase)
357
+ work_items.append(item)
358
+ annotated["work_items"] = work_items
359
+ annotated.setdefault("blocked_item_count", len(typed_payload.blocked_items))
360
+ annotated.setdefault("parent_applies_outputs", True)
361
+ return annotated
362
+
363
+
364
+ def _typed_subagent_plan_payload(payload: JsonObject) -> JsonObject:
365
+ typed_payload = attach_subagent_plan_attestation(_annotate_work_items_for_subagent_contract(payload))
366
+ SubagentBatchPlan.model_validate(typed_payload)
367
+ return typed_payload
368
+
369
+
370
+ def _default_subagent_temp_root(phase: str) -> Path:
371
+ base = _user_state_dir() / "tmp" / "agent-work"
372
+ if phase == "triage":
373
+ return base / "process-chats" / "triage"
374
+ if phase == "architect":
375
+ return base / "process-chats"
376
+ if phase in {"style-rewrite", "note-merge", "atomicity-split", "atomicity_split"}:
377
+ return base / "fix-wiki"
378
+ if phase in {"vocabulary-curation", "vocabulary_curation"}:
379
+ return base / "vocabulary-curation"
380
+ return base / _slug(phase)
381
+
382
+
383
+ def _style_rewrite_subagent_output_contract() -> JsonObject:
384
+ return {
385
+ "schema": "medical-notes-workbench.subagent-output-contract.v1",
386
+ "write_markdown_to": "temp_output",
387
+ "subagent_must_create_attestation": False,
388
+ "subagent_must_create_specialist_task_run_receipt": False,
389
+ "parent_must_not_fabricate_specialist_task_run_receipt": True,
390
+ "parent_may_call_specialist_task_receipt_finalizer": True,
391
+ "official_receipt_finalizers": [
392
+ "call_specialist_model",
393
+ "finalize-agy-specialist-task",
394
+ "finalize-opencode-specialist-task",
395
+ ],
396
+ "missing_specialist_task_run_receipt_action": "run_official_receipt_finalizer_or_stop",
397
+ "parent_only_fields": ["output_attestation_path"],
398
+ "runner_only_fields": ["specialist_task_run_receipt_path"],
399
+ "attestation_created_by": "finalize-style-rewrite-output",
400
+ }
401
+
402
+
403
+ def _extension_root() -> Path:
404
+ from mednotes.platform.paths import extension_root
405
+
406
+ return extension_root()
407
+
408
+
409
+ def _agent_readable_docs_root(root: Path) -> Path:
410
+ """Prefer source docs when running the built extension from ignored dist/ in dev."""
411
+
412
+ if root.name == "gemini-cli-extension" and root.parent.name == "dist":
413
+ source_root = root.parents[1] / "extension"
414
+ required = (
415
+ source_root / "docs" / "agent-prompt-hardening.md",
416
+ source_root / "docs" / "knowledge-architect.md",
417
+ source_root / "docs" / "semantic-linker.md",
418
+ )
419
+ if all(path.exists() for path in required):
420
+ return source_root
421
+ return root
422
+
423
+
424
+ def _style_rewrite_context_docs() -> JsonObject:
425
+ root = _agent_readable_docs_root(_extension_root())
426
+ return {
427
+ "schema": "medical-notes-workbench.subagent-context-docs.v1",
428
+ "required_read_files": [
429
+ str(root / "docs" / "agent-prompt-hardening.md"),
430
+ str(root / "docs" / "knowledge-architect.md"),
431
+ str(root / "docs" / "semantic-linker.md"),
432
+ ],
433
+ "forbidden_discovery_roots": [str(Path.home())],
434
+ "agent_instruction": (
435
+ "Leia os required_read_files empacotados antes de redigir a nota. "
436
+ "Não faça descoberta ampla em forbidden_discovery_roots; se um doc faltar, bloqueie como packaged_agent_template_unavailable."
437
+ ),
438
+ }
439
+
440
+
441
+ def _planned_meaning_targets(note_plan: _TriageNotePlan) -> list[_PlannedMeaningTarget]:
442
+ targets: list[_PlannedMeaningTarget] = []
443
+ for item in note_plan.items:
444
+ if item.action != PLANNED_MEANING_ACTION:
445
+ continue
446
+ title = str(item.staged_title or item.title).strip()
447
+ if not title:
448
+ continue
449
+ targets.append(
450
+ _PlannedMeaningTarget(
451
+ id=item.id.strip(),
452
+ title=title,
453
+ target_key=normalize_key(title),
454
+ )
455
+ )
456
+ return targets
457
+
458
+
459
+ def _launchable_work_item(item: JsonObject, *, write_policy: str = "temp_note_allowed") -> JsonObject:
460
+ item["launchable"] = True
461
+ item["write_policy"] = write_policy
462
+ return item
463
+
464
+
465
+ def _non_launchable_blocked_item(item: JsonObject, *, write_policy: str = "no_temp_note") -> JsonObject:
466
+ item["launchable"] = False
467
+ item["write_policy"] = write_policy
468
+ item.pop("temp_dir", None)
469
+ item.pop("temp_output", None)
470
+ return item
471
+
472
+
473
+ def _duplicate_next_action(blocked_reason: str = "duplicate_planned_meaning_targets") -> str:
474
+ if blocked_reason == "canonical_merge_required":
475
+ return (
476
+ "Chame architect para merge canônico no alvo existente: gerar rewrite completo com delta validado, "
477
+ "ou ajustar a triagem para not_a_note se não houver delta."
478
+ )
479
+ if blocked_reason == "human_decision_required.ambiguous_canonical_target":
480
+ return (
481
+ "Escolha explicitamente o alvo canônico antes de lançar architects; depois "
482
+ "replaneje ou ajuste a triagem para planned_meaning/not_a_note."
483
+ )
484
+ return (
485
+ "Revise o note_plan antes de arquitetura: converta duplicatas para "
486
+ "not_a_note ou consolide fontes em um unico planned_meaning."
487
+ )
488
+
489
+
490
+ def _duplicate_blocked_reason(duplicate_targets: Sequence[_DuplicateTarget]) -> str:
491
+ if any(target.conflict_type == "ambiguous_existing_wiki_note" for target in duplicate_targets):
492
+ return "human_decision_required.ambiguous_canonical_target"
493
+ if any(target.conflict_type == "existing_wiki_note" for target in duplicate_targets):
494
+ return "canonical_merge_required"
495
+ return "duplicate_planned_meaning_targets"
496
+
497
+
498
+ def _decision_options_for_existing_paths(paths: Sequence[object]) -> list[JsonObject]:
499
+ options: list[JsonObject] = []
500
+ for index, path in enumerate(paths, start=1):
501
+ label = str(path)
502
+ options.append(
503
+ {
504
+ "id": f"use_existing_{index}",
505
+ "label": label,
506
+ "value": label,
507
+ "consequence": "Consolidar informação nova nesse alvo canônico ou marcar como not_a_note.",
508
+ }
509
+ )
510
+ return options
511
+
512
+
513
+ def _decision_options_for_planned_matches(matches: Sequence[_PlannedMatch]) -> list[JsonObject]:
514
+ return [
515
+ {
516
+ "id": "canonical_merge",
517
+ "label": "Fundir em uma nota canônica",
518
+ "value": "canonical_merge",
519
+ "consequence": "Um architect consolida todas as fontes e preserva múltiplas referências.",
520
+ },
521
+ {
522
+ "id": "split_triage",
523
+ "label": "Separar triagem",
524
+ "value": "split_triage",
525
+ "consequence": "Ajustar note_plan para separar temas ou remover duplicata antes da arquitetura.",
526
+ },
527
+ {
528
+ "id": "mark_not_a_note",
529
+ "label": "Marcar como já coberto",
530
+ "value": "not_a_note",
531
+ "consequence": "Atualizar note_plan como not_a_note quando a informação não exigir nota nova.",
532
+ },
533
+ ] + [
534
+ {
535
+ "id": f"inspect_{index}",
536
+ "label": f"Inspecionar {Path(match.raw_file).name}",
537
+ "value": match.raw_file,
538
+ "consequence": "Usar este raw como evidência antes de escolher a rota.",
539
+ }
540
+ for index, match in enumerate(matches[:3], start=1)
541
+ ]
542
+
543
+
544
+ def _planned_match_payloads(matches: Sequence[_PlannedMatch]) -> list[JsonObject]:
545
+ """Serialize typed planned matches before they cross a JSON/public boundary."""
546
+
547
+ return [match.to_payload() for match in matches]
548
+
549
+
550
+ def _packet_for_ambiguous_target(
551
+ *,
552
+ target_key: str,
553
+ target_title: str,
554
+ options: Sequence[object],
555
+ planned_matches: list[_PlannedMatch] | None = None,
556
+ ) -> JsonObject:
557
+ option_payload = (
558
+ _decision_options_for_existing_paths(options)
559
+ if options and not isinstance(options[0], dict)
560
+ else _decision_options_for_planned_matches(planned_matches or [])
561
+ )
562
+ return _ask_human_packet(
563
+ kind="ambiguous_canonical_target",
564
+ phase="architect",
565
+ blocked_reason="human_decision_required.ambiguous_canonical_target",
566
+ target_kind="wiki_note",
567
+ target_key=target_key,
568
+ question=f"Qual alvo canônico deve receber '{target_title}'?",
569
+ options=option_payload,
570
+ resume_action="Registrar a escolha no note_plan e reexecutar plan-subagents --phase architect.",
571
+ context={"planned_matches": _planned_match_payloads(planned_matches or [])},
572
+ evidence_summary=f"'{target_title}' tem mais de um alvo canônico plausível.",
573
+ developer_summary="Ambiguous planned_meaning target after accent/case normalization.",
574
+ )
575
+
576
+
577
+ def _packet_for_existing_canonical_target(target: _DuplicateTarget) -> JsonObject:
578
+ title = target.title
579
+ target_key = target.target_key
580
+ existing_paths = target.existing_paths
581
+ return _ask_human_packet(
582
+ kind="canonical_merge_required",
583
+ phase="architect",
584
+ blocked_reason="canonical_merge_required",
585
+ target_kind="existing_wiki_note",
586
+ target_key=target_key,
587
+ question=f"Como tratar a informação nova planejada para nota existente '{title}'?",
588
+ options=[
589
+ *_decision_options_for_existing_paths(existing_paths),
590
+ {
591
+ "id": "rename_new_note",
592
+ "label": "Criar nota separada com outro título",
593
+ "value": "rename_new_note",
594
+ "consequence": "Ajustar staged_title no note_plan e repetir arquitetura.",
595
+ },
596
+ ],
597
+ resume_action="Escolher rota de merge/renomeação, ajustar note_plan e reexecutar plan-subagents --phase architect.",
598
+ context={"existing_paths": existing_paths, "target_title": title},
599
+ evidence_summary=f"'{title}' já existe na Wiki e pode receber merge ou nota separada.",
600
+ developer_summary="Existing canonical target requires an editorial route before architect fan-out.",
601
+ )
602
+
603
+
604
+ def _ask_human_packet(
605
+ *,
606
+ kind: str,
607
+ phase: str,
608
+ blocked_reason: str,
609
+ target_kind: str,
610
+ target_key: str,
611
+ question: str,
612
+ options: list[JsonObject],
613
+ resume_action: str,
614
+ context: JsonObject,
615
+ evidence_summary: str,
616
+ developer_summary: str,
617
+ ) -> JsonObject:
618
+ recommended_option_id = _json_str_field(options[0], "id") if options else "inspect_first"
619
+ if not options:
620
+ options = [
621
+ {
622
+ "id": "inspect_first",
623
+ "label": "Inspecionar antes de escolher",
624
+ "value": "inspect_first",
625
+ "consequence": "Replanejar depois de revisar os candidatos.",
626
+ }
627
+ ]
628
+ decision = WorkflowDecision(
629
+ kind="ask_human",
630
+ phase=phase,
631
+ reason_code=blocked_reason,
632
+ public_summary=question,
633
+ developer_summary=developer_summary,
634
+ evidence=[
635
+ DecisionEvidence(
636
+ summary=evidence_summary,
637
+ technical_code=blocked_reason,
638
+ source=phase,
639
+ candidates=[{"target_key": target_key, **context}],
640
+ risk="Escolha automatica pode fundir ou separar notas canônicas incorretamente.",
641
+ )
642
+ ],
643
+ rejected_automations=[
644
+ RejectedAutomation(kind="auto_fix", reason_code="ambiguous_canonical_target", reason="Nao ha alvo unico dominante para corrigir automaticamente."),
645
+ RejectedAutomation(kind="auto_defer", reason_code="blocks_architect", reason="Pular a escolha impediria cobertura correta do raw chat."),
646
+ RejectedAutomation(kind="auto_plan", reason_code="plan_needs_canonical_target", reason="O plano precisa de um alvo canônico antes de lançar subagentes."),
647
+ ],
648
+ next_action=resume_action,
649
+ resume_action=resume_action,
650
+ recommended_option_id=recommended_option_id,
651
+ options=options,
652
+ )
653
+ packet = decision.to_human_decision_packet()
654
+ packet["kind"] = kind
655
+ packet["type"] = kind
656
+ packet["target_kind"] = target_kind
657
+ packet["target_key"] = target_key
658
+ packet.setdefault("context", {}).update(context)
659
+ return packet
660
+
661
+
662
+ def _single_planned_meaning_target(parsed: _ArchitectParsedItem, target_key: str) -> bool:
663
+ targets = parsed.targets
664
+ if len(targets) != 1:
665
+ return False
666
+ return targets[0].target_key == target_key
667
+
668
+
669
+ def _find_note_plan_item(note_plan: _TriageNotePlan, item_id: str) -> JsonObject:
670
+ raw_items = _json_list_field(note_plan.public_payload(), "items")
671
+ for item in raw_items:
672
+ if isinstance(item, dict):
673
+ payload = JsonObjectAdapter.validate_python(item)
674
+ if _json_str_field(payload, "id").strip() == item_id:
675
+ return payload
676
+ return {}
677
+
678
+
679
+ def _artifact_payload_for_raw(config: MedConfig, raw_file: Path) -> JsonObject:
680
+ artifact_manifests = discover_artifact_manifests(raw_file, artifact_dir=config.artifact_dir)
681
+ payload: JsonObject = {
682
+ "artifact_manifest_count": len(artifact_manifests),
683
+ "artifact_count": sum(len(manifest.artifacts) for manifest in artifact_manifests),
684
+ }
685
+ if artifact_manifests:
686
+ payload["artifact_manifests"] = [manifest.to_json() for manifest in artifact_manifests]
687
+ return payload
688
+
689
+
690
+ def _canonical_merge_work_item(
691
+ config: MedConfig,
692
+ spec: JsonObject,
693
+ *,
694
+ target_key: str,
695
+ planned_matches: list[_PlannedMatch],
696
+ parsed_by_work_id: dict[str, _ArchitectParsedItem],
697
+ temp_root: Path,
698
+ index: int,
699
+ ) -> JsonObject:
700
+ target_title = planned_matches[0].title
701
+ work_id = f"canonical-merge-{index:03d}-{_slug(target_title)}"
702
+ sources: list[JsonObject] = []
703
+ artifact_manifest_count = 0
704
+ artifact_count = 0
705
+ artifact_manifests: list[JsonObject] = []
706
+ for match in planned_matches:
707
+ parsed = parsed_by_work_id[match.work_id]
708
+ item = parsed.item
709
+ raw_file = Path(str(item["raw_file"]))
710
+ note_plan_item = _find_note_plan_item(parsed.note_plan, match.id)
711
+ source: JsonObject = {
712
+ "raw_file": str(raw_file),
713
+ "work_id": str(item["work_id"]),
714
+ "fonte_id": _json_str_field(item, "fonte_id"),
715
+ "titulo_triagem": _json_str_field(item, "titulo_triagem"),
716
+ "note_plan_item_id": match.id,
717
+ "planned_title": match.title,
718
+ "note_plan_item": note_plan_item,
719
+ }
720
+ artifact_payload = _artifact_payload_for_raw(config, raw_file)
721
+ artifact_manifest_count += int(artifact_payload["artifact_manifest_count"])
722
+ artifact_count += int(artifact_payload["artifact_count"])
723
+ artifact_manifests.extend(_json_object_list_field(artifact_payload, "artifact_manifests"))
724
+ sources.append(source)
725
+
726
+ temp_dir = temp_root / work_id
727
+ merge_plan_sources = [
728
+ {
729
+ "raw_file": source["raw_file"],
730
+ "note_plan_item_id": source["note_plan_item_id"],
731
+ "planned_title": source["planned_title"],
732
+ "fonte_id": source["fonte_id"],
733
+ }
734
+ for source in sources
735
+ ]
736
+ item: JsonObject = {
737
+ "work_id": work_id,
738
+ "agent": spec["agent"],
739
+ "item_type": "canonical_merge",
740
+ "merge_action": "create_new_canonical_note",
741
+ "target_kind": "new_wiki_note",
742
+ "target_title": target_title,
743
+ "target_key": target_key,
744
+ "owner_key": f"target:{target_key}",
745
+ "source_count": len(sources),
746
+ "raw_files": [source["raw_file"] for source in sources],
747
+ "sources": sources,
748
+ "canonical_merge_plan": {
749
+ "schema": CANONICAL_MERGE_PLAN_SCHEMA,
750
+ "target_kind": "new_wiki_note",
751
+ "target_title": target_title,
752
+ "target_key": target_key,
753
+ "sources": merge_plan_sources,
754
+ "required_delta_per_source": True,
755
+ "required_multi_reference_provenance": True,
756
+ },
757
+ "artifact_manifest_count": artifact_manifest_count,
758
+ "artifact_count": artifact_count,
759
+ "temp_dir": str(temp_dir),
760
+ "temp_output": str(temp_dir / f"{_slug(target_title)}.md"),
761
+ }
762
+ if artifact_manifests:
763
+ item["artifact_manifests"] = artifact_manifests
764
+ return _launchable_work_item(item)
765
+
766
+
767
+ def _existing_canonical_merge_work_item(
768
+ config: MedConfig,
769
+ spec: JsonObject,
770
+ *,
771
+ target_key: str,
772
+ planned_matches: list[_PlannedMatch],
773
+ parsed_by_work_id: dict[str, _ArchitectParsedItem],
774
+ existing_paths: Sequence[object],
775
+ temp_root: Path,
776
+ index: int,
777
+ ) -> JsonObject:
778
+ target_title = planned_matches[0].title
779
+ existing_path = str(existing_paths[0])
780
+ target_path = config.wiki_dir / existing_path
781
+ work_id = f"canonical-existing-merge-{index:03d}-{_slug(target_path.stem)}"
782
+ sources: list[JsonObject] = []
783
+ artifact_manifest_count = 0
784
+ artifact_count = 0
785
+ artifact_manifests: list[JsonObject] = []
786
+ for match in planned_matches:
787
+ parsed = parsed_by_work_id[match.work_id]
788
+ item = parsed.item
789
+ raw_file = Path(str(item["raw_file"]))
790
+ note_plan_item = _find_note_plan_item(parsed.note_plan, match.id)
791
+ source: JsonObject = {
792
+ "raw_file": str(raw_file),
793
+ "work_id": str(item["work_id"]),
794
+ "fonte_id": _json_str_field(item, "fonte_id"),
795
+ "titulo_triagem": _json_str_field(item, "titulo_triagem"),
796
+ "note_plan_item_id": match.id,
797
+ "planned_title": match.title,
798
+ "note_plan_item": note_plan_item,
799
+ }
800
+ artifact_payload = _artifact_payload_for_raw(config, raw_file)
801
+ artifact_manifest_count += int(artifact_payload["artifact_manifest_count"])
802
+ artifact_count += int(artifact_payload["artifact_count"])
803
+ artifact_manifests.extend(_json_object_list_field(artifact_payload, "artifact_manifests"))
804
+ sources.append(source)
805
+
806
+ temp_dir = temp_root / work_id
807
+ merge_plan_sources = [
808
+ {
809
+ "raw_file": source["raw_file"],
810
+ "note_plan_item_id": source["note_plan_item_id"],
811
+ "planned_title": source["planned_title"],
812
+ "fonte_id": source["fonte_id"],
813
+ }
814
+ for source in sources
815
+ ]
816
+ item: JsonObject = {
817
+ "work_id": work_id,
818
+ "agent": spec["agent"],
819
+ "item_type": "canonical_merge",
820
+ "merge_action": "update_existing_canonical_note",
821
+ "target_kind": "existing_wiki_note",
822
+ "target_title": target_path.stem,
823
+ "requested_title": target_title,
824
+ "target_key": target_key,
825
+ "target_path": str(target_path),
826
+ "existing_paths": [str(path) for path in existing_paths],
827
+ "owner_key": f"target:{target_key}",
828
+ "source_count": len(sources),
829
+ "raw_files": [source["raw_file"] for source in sources],
830
+ "sources": sources,
831
+ "canonical_merge_plan": {
832
+ "schema": CANONICAL_MERGE_PLAN_SCHEMA,
833
+ "target_kind": "existing_wiki_note",
834
+ "target_title": target_path.stem,
835
+ "requested_title": target_title,
836
+ "target_key": target_key,
837
+ "target_path": str(target_path),
838
+ "existing_paths": [str(path) for path in existing_paths],
839
+ "sources": merge_plan_sources,
840
+ "required_delta_per_source": True,
841
+ "required_multi_reference_provenance": True,
842
+ },
843
+ "apply_command": "apply-canonical-merge",
844
+ "artifact_manifest_count": artifact_manifest_count,
845
+ "artifact_count": artifact_count,
846
+ "temp_dir": str(temp_dir),
847
+ "temp_output": str(temp_dir / f"{target_path.stem}.rewrite.md"),
848
+ }
849
+ if artifact_manifests:
850
+ item["artifact_manifests"] = artifact_manifests
851
+ return _launchable_work_item(item, write_policy="existing_note_rewrite")
852
+
853
+
854
+ def _canonical_merge_blocked_item(
855
+ *,
856
+ target_key: str,
857
+ planned_matches: list[_PlannedMatch],
858
+ reason: str,
859
+ message: str,
860
+ ) -> JsonObject:
861
+ first_match = planned_matches[0]
862
+ item: JsonObject = {
863
+ "work_id": f"canonical-merge-blocked-{_slug(target_key)}",
864
+ "item_type": "canonical_merge",
865
+ "blocked_reason": reason,
866
+ "target_key": target_key,
867
+ "target_title": first_match.title,
868
+ "planned_matches": [match.to_payload() for match in planned_matches],
869
+ "reason": message,
870
+ "next_action": _duplicate_next_action(reason),
871
+ }
872
+ if reason == "human_decision_required.ambiguous_canonical_target":
873
+ packet = _packet_for_ambiguous_target(
874
+ target_key=target_key,
875
+ target_title=first_match.title or target_key,
876
+ options=[],
877
+ planned_matches=planned_matches,
878
+ )
879
+ item["human_decision_packet"] = packet
880
+ return _non_launchable_blocked_item(item)
881
+
882
+
883
+ def _decision_packets_from_blocked_items(blocked_items: list[JsonObject]) -> list[JsonObject]:
884
+ packets: list[JsonObject] = []
885
+ seen: set[str] = set()
886
+ for item in blocked_items:
887
+ packet = _json_object_field(item, "human_decision_packet")
888
+ if packet is not None:
889
+ packet_key = json.dumps(packet, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
890
+ if packet_key not in seen:
891
+ packets.append(packet)
892
+ seen.add(packet_key)
893
+ for packet in _json_object_list_field(item, "human_decision_packets"):
894
+ packet_key = json.dumps(packet, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
895
+ if packet_key in seen:
896
+ continue
897
+ packets.append(packet)
898
+ seen.add(packet_key)
899
+ return packets
900
+
901
+
902
+ def _is_canonical_merge_work_item(item: JsonObject) -> bool:
903
+ """Count only typed work-item intent, not arbitrary payload text."""
904
+
905
+ return _json_str_field(item, "item_type") == "canonical_merge"
906
+
907
+
908
+ def _with_decision_packets(payload: JsonObject, blocked_items: list[JsonObject]) -> JsonObject:
909
+ packets = _decision_packets_from_blocked_items(blocked_items)
910
+ if not packets:
911
+ return payload
912
+ payload["human_decision_packets"] = packets
913
+ if len(packets) == 1:
914
+ payload["human_decision_packet"] = packets[0]
915
+ return payload
916
+
917
+
918
+ def _process_chats_no_pending_directive() -> JsonObject:
919
+ return AgentDirective.model_validate(
920
+ {
921
+ "workflow": "/mednotes:process-chats",
922
+ "run_id": "process-chats-architect-no-pending",
923
+ "control": {
924
+ "status": "completed",
925
+ "state": "no_pending",
926
+ "phase": "architect",
927
+ "reason": "no_pending",
928
+ "capabilities": {"continue": False, "final_report": True},
929
+ "effects": [],
930
+ "blockers": [],
931
+ "resume": "",
932
+ "report": {"requires": ["public_report"]},
933
+ "limits": {"raw_content": False, "absolute_paths": False, "ad_hoc_scripts": False},
934
+ },
935
+ "summary": "Nenhum chat novo para processar.",
936
+ "instructions": [
937
+ "This completes /mednotes:process-chats because there are no new chats to process.",
938
+ "Use reports.public_report.lines as the public response.",
939
+ "Do not run validate-wiki, fix-wiki, run-linker, publish-batch or subagents.",
940
+ "Report zero vault mutations and do not expose local paths or file links.",
941
+ "Do not add a technical summary after reports.public_report.lines.",
942
+ "Do not mention internal terminal-state field names, schemas, hashes or local paths in the public response.",
943
+ ],
944
+ }
945
+ ).to_payload()
946
+
947
+
948
+ def _process_chats_terminal_no_pending_contract() -> JsonObject:
949
+ directive = _process_chats_no_pending_directive()
950
+ public_report = WorkflowPublicReport(
951
+ workflow="/mednotes:process-chats",
952
+ run_id="process-chats-architect-no-pending",
953
+ headline="Nenhum chat novo para processar.",
954
+ lines=[
955
+ "Nenhuma nota foi publicada ou preparada.",
956
+ "Nenhum raw chat novo foi processado.",
957
+ "Nada foi escrito na Wiki.",
958
+ "Coverage/manifest não se aplicam porque não houve publicação.",
959
+ "O linker/grafo não precisa rodar porque nenhuma nota foi publicada.",
960
+ ],
961
+ ).to_payload()
962
+ return {
963
+ "workflow": "/mednotes:process-chats",
964
+ "process_chats_terminal_state": "no_pending",
965
+ "reports": {
966
+ "summary": "Nenhum chat novo para processar.",
967
+ "public_report": public_report,
968
+ },
969
+ "agent_directive": directive,
970
+ }
971
+
972
+
973
+ def _plan_architect_subagents(
974
+ config: MedConfig,
975
+ spec: JsonObject,
976
+ rows: list[JsonObject],
977
+ *,
978
+ total_available_count: int,
979
+ concurrency: int,
980
+ temp_root: Path,
981
+ limit: int | None,
982
+ ) -> JsonObject:
983
+ existing_targets = note_target_index(config.wiki_dir, as_relative=True)
984
+ parsed_items: list[_ArchitectParsedItem] = []
985
+ meaning_work_items: list[JsonObject] = []
986
+ blocked_items: list[JsonObject] = []
987
+ seen: set[str] = set()
988
+
989
+ typed_rows = [_RawChatPlanningRow.model_validate(row) for row in rows]
990
+ for index, row in enumerate(typed_rows, start=1):
991
+ raw_file = row.path
992
+ raw_key = str(Path(raw_file).expanduser())
993
+ if raw_key in seen:
994
+ continue
995
+ seen.add(raw_key)
996
+ work_id = f"architect-{index:03d}-{_slug(Path(raw_file).stem)}"
997
+ item: JsonObject = {
998
+ "work_id": work_id,
999
+ "agent": spec["agent"],
1000
+ "item_type": spec["item_type"],
1001
+ "raw_file": raw_file,
1002
+ "owner_key": raw_key,
1003
+ "titulo_triagem": row.titulo_triagem,
1004
+ "fonte_id": row.fonte_id,
1005
+ }
1006
+ try:
1007
+ raw_plan = _ReadNoteMeta.model_validate(read_note_meta(Path(raw_file))).note_plan
1008
+ if not raw_plan:
1009
+ raise ValidationError("Raw chat missing triage note_plan; rerun triage with --note-plan")
1010
+ note_plan = _TriageNotePlan.from_payload(parse_triage_note_plan(raw_plan, Path(raw_file)))
1011
+ except ValidationError as exc:
1012
+ item.update(
1013
+ {
1014
+ "blocked_reason": "missing_or_invalid_note_plan",
1015
+ "note_plan_error": str(exc),
1016
+ "next_action": "Refaça a triagem com --note-plan exaustivo antes de planejar arquitetura.",
1017
+ }
1018
+ )
1019
+ _non_launchable_blocked_item(item)
1020
+ blocked_items.append(item)
1021
+ continue
1022
+
1023
+ item["note_plan"] = note_plan.public_payload()
1024
+ item.update(note_plan_summary(note_plan.public_payload()))
1025
+ if note_plan.schema_ == TRIAGE_NOTE_PLAN_V2_SCHEMA:
1026
+ planner = _MeaningPlannerResult.model_validate(
1027
+ plan_meaning_work_items(
1028
+ config,
1029
+ note_plan.public_payload(),
1030
+ raw_file=Path(raw_file),
1031
+ temp_root=temp_root,
1032
+ agent=str(spec["agent"]),
1033
+ )
1034
+ )
1035
+ for blocked_item in planner.blocked_items:
1036
+ blocked_item.setdefault("agent", spec["agent"])
1037
+ blocked_item.setdefault("source_work_id", work_id)
1038
+ blocked_item.setdefault("owner_key", raw_key)
1039
+ blocked_item.setdefault(
1040
+ "next_action",
1041
+ planner.next_action or "Corrija o triage-note-plan.v2 antes do architect.",
1042
+ )
1043
+ blocked_item["note_plan"] = note_plan.public_payload()
1044
+ blocked_item.update(note_plan_summary(note_plan.public_payload()))
1045
+ _non_launchable_blocked_item(blocked_item)
1046
+ blocked_items.append(blocked_item)
1047
+ item["meaning_planner_work_items"] = list(planner.work_items)
1048
+ targets = _planned_meaning_targets(note_plan)
1049
+ duplicate_targets: list[_DuplicateTarget] = []
1050
+ for target in targets:
1051
+ matches = existing_targets[target.target_key] if target.target_key in existing_targets else []
1052
+ if matches:
1053
+ duplicate_targets.append(
1054
+ _DuplicateTarget(
1055
+ id=target.id,
1056
+ title=target.title,
1057
+ target_key=target.target_key,
1058
+ conflict_type="ambiguous_existing_wiki_note" if len(matches) > 1 else "existing_wiki_note",
1059
+ existing_paths=[str(path) for path in matches[:5]],
1060
+ )
1061
+ )
1062
+ parsed_items.append(
1063
+ _ArchitectParsedItem(
1064
+ item=item,
1065
+ note_plan=note_plan,
1066
+ targets=targets,
1067
+ duplicate_targets=duplicate_targets,
1068
+ )
1069
+ )
1070
+ continue
1071
+ targets = _planned_meaning_targets(note_plan)
1072
+ duplicate_targets: list[_DuplicateTarget] = []
1073
+ for target in targets:
1074
+ matches = existing_targets[target.target_key] if target.target_key in existing_targets else []
1075
+ if matches:
1076
+ duplicate_targets.append(
1077
+ _DuplicateTarget(
1078
+ id=target.id,
1079
+ title=target.title,
1080
+ target_key=target.target_key,
1081
+ conflict_type="ambiguous_existing_wiki_note" if len(matches) > 1 else "existing_wiki_note",
1082
+ existing_paths=[str(path) for path in matches[:5]],
1083
+ )
1084
+ )
1085
+ parsed_items.append(
1086
+ _ArchitectParsedItem(
1087
+ item=item,
1088
+ note_plan=note_plan,
1089
+ targets=targets,
1090
+ duplicate_targets=duplicate_targets,
1091
+ )
1092
+ )
1093
+
1094
+ planned_by_key: dict[str, list[_PlannedMatch]] = {}
1095
+ parsed_by_work_id: dict[str, _ArchitectParsedItem] = {}
1096
+ for parsed in parsed_items:
1097
+ item = parsed.item
1098
+ parsed_by_work_id[str(item["work_id"])] = parsed
1099
+ for target in parsed.targets:
1100
+ planned_by_key.setdefault(target.target_key, []).append(
1101
+ _PlannedMatch(
1102
+ raw_file=str(item["raw_file"]),
1103
+ work_id=str(item["work_id"]),
1104
+ id=target.id,
1105
+ title=target.title,
1106
+ )
1107
+ )
1108
+
1109
+ work_items: list[JsonObject] = list(meaning_work_items)
1110
+ consumed_work_ids: set[str] = set()
1111
+ canonical_merge_index = 1
1112
+ for target_key, planned_matches in planned_by_key.items():
1113
+ if len(planned_matches) <= 1 or target_key in existing_targets:
1114
+ continue
1115
+ group = [parsed_by_work_id[match.work_id] for match in planned_matches]
1116
+ if not all(_single_planned_meaning_target(parsed, target_key) for parsed in group):
1117
+ blocked_items.append(
1118
+ _canonical_merge_blocked_item(
1119
+ target_key=target_key,
1120
+ planned_matches=planned_matches,
1121
+ reason="human_decision_required.ambiguous_canonical_target",
1122
+ message=(
1123
+ "At least one raw chat has additional planned_meaning targets; choose whether to "
1124
+ "split triage or make one canonical merge work item before spawning architects."
1125
+ ),
1126
+ )
1127
+ )
1128
+ consumed_work_ids.update(match.work_id for match in planned_matches)
1129
+ continue
1130
+ try:
1131
+ work_items.append(
1132
+ _canonical_merge_work_item(
1133
+ config,
1134
+ spec,
1135
+ target_key=target_key,
1136
+ planned_matches=planned_matches,
1137
+ parsed_by_work_id=parsed_by_work_id,
1138
+ temp_root=temp_root,
1139
+ index=canonical_merge_index,
1140
+ )
1141
+ )
1142
+ except MedOpsError as exc:
1143
+ blocked_items.append(
1144
+ _canonical_merge_blocked_item(
1145
+ target_key=target_key,
1146
+ planned_matches=planned_matches,
1147
+ reason="missing_or_invalid_artifact_manifest",
1148
+ message=str(exc),
1149
+ )
1150
+ )
1151
+ consumed_work_ids.update(match.work_id for match in planned_matches)
1152
+ canonical_merge_index += 1
1153
+
1154
+ existing_merge_index = 1
1155
+ for target_key, planned_matches in planned_by_key.items():
1156
+ existing_matches = existing_targets[target_key] if target_key in existing_targets else []
1157
+ if len(existing_matches) != 1:
1158
+ continue
1159
+ group = [parsed_by_work_id[match.work_id] for match in planned_matches]
1160
+ if not all(_single_planned_meaning_target(parsed, target_key) for parsed in group):
1161
+ blocked_items.append(
1162
+ _canonical_merge_blocked_item(
1163
+ target_key=target_key,
1164
+ planned_matches=planned_matches,
1165
+ reason="human_decision_required.ambiguous_canonical_target",
1166
+ message=(
1167
+ "At least one raw chat has additional planned_meaning targets; choose whether to "
1168
+ "split triage or merge only the intended delta into the existing canonical note."
1169
+ ),
1170
+ )
1171
+ )
1172
+ consumed_work_ids.update(match.work_id for match in planned_matches)
1173
+ continue
1174
+ try:
1175
+ work_items.append(
1176
+ _existing_canonical_merge_work_item(
1177
+ config,
1178
+ spec,
1179
+ target_key=target_key,
1180
+ planned_matches=planned_matches,
1181
+ parsed_by_work_id=parsed_by_work_id,
1182
+ existing_paths=existing_matches,
1183
+ temp_root=temp_root,
1184
+ index=existing_merge_index,
1185
+ )
1186
+ )
1187
+ except MedOpsError as exc:
1188
+ blocked_items.append(
1189
+ _canonical_merge_blocked_item(
1190
+ target_key=target_key,
1191
+ planned_matches=planned_matches,
1192
+ reason="missing_or_invalid_artifact_manifest",
1193
+ message=str(exc),
1194
+ )
1195
+ )
1196
+ consumed_work_ids.update(match.work_id for match in planned_matches)
1197
+ existing_merge_index += 1
1198
+
1199
+ for parsed in parsed_items:
1200
+ item = parsed.item
1201
+ if str(item["work_id"]) in consumed_work_ids:
1202
+ continue
1203
+ duplicate_targets: list[_DuplicateTarget] = [
1204
+ target if isinstance(target, _DuplicateTarget) else _DuplicateTarget.model_validate(target)
1205
+ for target in parsed.duplicate_targets
1206
+ ]
1207
+ for target in parsed.targets:
1208
+ planned_matches = planned_by_key[target.target_key] if target.target_key in planned_by_key else []
1209
+ if len(planned_matches) > 1:
1210
+ duplicate_targets.append(
1211
+ _DuplicateTarget(
1212
+ id=target.id,
1213
+ title=target.title,
1214
+ target_key=target.target_key,
1215
+ conflict_type="planned_in_batch",
1216
+ planned_matches=planned_matches,
1217
+ )
1218
+ )
1219
+ if duplicate_targets:
1220
+ blocked_reason = _duplicate_blocked_reason(duplicate_targets)
1221
+ item.update(
1222
+ {
1223
+ "blocked_reason": blocked_reason,
1224
+ "duplicate_targets": [target.to_payload() for target in duplicate_targets],
1225
+ "next_action": _duplicate_next_action(blocked_reason),
1226
+ }
1227
+ )
1228
+ if blocked_reason == "canonical_merge_required":
1229
+ packet = _packet_for_existing_canonical_target(duplicate_targets[0])
1230
+ item["canonical_merge"] = {
1231
+ "schema": CANONICAL_MERGE_PLAN_SCHEMA,
1232
+ "target_kind": "existing_wiki_note",
1233
+ "target_title": duplicate_targets[0].title,
1234
+ "target_key": duplicate_targets[0].target_key,
1235
+ "existing_paths": duplicate_targets[0].existing_paths,
1236
+ }
1237
+ item["human_decision_packet"] = packet
1238
+ elif blocked_reason == "human_decision_required.ambiguous_canonical_target":
1239
+ packets = [
1240
+ _packet_for_ambiguous_target(
1241
+ target_key=target.target_key,
1242
+ target_title=target.title or target.target_key,
1243
+ options=target.existing_paths,
1244
+ planned_matches=target.planned_matches,
1245
+ )
1246
+ for target in duplicate_targets
1247
+ if target.conflict_type == "ambiguous_existing_wiki_note"
1248
+ ]
1249
+ if not packets:
1250
+ packets = [
1251
+ _packet_for_ambiguous_target(
1252
+ target_key=target.target_key,
1253
+ target_title=target.title or target.target_key,
1254
+ options=[],
1255
+ planned_matches=target.planned_matches,
1256
+ )
1257
+ for target in duplicate_targets
1258
+ if target.conflict_type == "planned_in_batch"
1259
+ ]
1260
+ item["human_decision_packets"] = packets
1261
+ _non_launchable_blocked_item(item)
1262
+ blocked_items.append(item)
1263
+ continue
1264
+
1265
+ meaning_items = _json_object_list_field(item, "meaning_planner_work_items")
1266
+ if meaning_items:
1267
+ for planned in meaning_items:
1268
+ work_item = dict(planned)
1269
+ work_item["source_work_id"] = item["work_id"]
1270
+ work_item["titulo_triagem"] = _json_str_field(item, "titulo_triagem")
1271
+ work_item["fonte_id"] = _json_str_field(item, "fonte_id")
1272
+ work_item["note_plan"] = item["note_plan"]
1273
+ work_item.update(note_plan_summary(parsed.note_plan.public_payload()))
1274
+ target_path = _json_str_field(work_item, "target_path")
1275
+ note_plan_item_id = _json_str_field(work_item, "note_plan_item_id")
1276
+ work_item.setdefault(
1277
+ "owner_key",
1278
+ target_path or f"meaning:{note_plan_item_id or item['owner_key']}",
1279
+ )
1280
+ try:
1281
+ work_item.update(_artifact_payload_for_raw(config, Path(item["raw_file"])))
1282
+ except MedOpsError as exc:
1283
+ work_item.update(
1284
+ {
1285
+ "blocked_reason": "missing_or_invalid_artifact_manifest",
1286
+ "artifact_manifest_error": str(exc),
1287
+ "next_action": (
1288
+ "Corrija o manifesto HTML do Gemini ou remova a dependência antes de lançar architects."
1289
+ ),
1290
+ }
1291
+ )
1292
+ _non_launchable_blocked_item(work_item)
1293
+ blocked_items.append(work_item)
1294
+ continue
1295
+ write_policy = (
1296
+ "existing_note_rewrite"
1297
+ if _json_str_field(work_item, "target_kind") == "existing_wiki_note"
1298
+ else "temp_note_allowed"
1299
+ )
1300
+ _launchable_work_item(work_item, write_policy=write_policy)
1301
+ work_items.append(work_item)
1302
+ continue
1303
+
1304
+ try:
1305
+ artifact_payload = _artifact_payload_for_raw(config, Path(item["raw_file"]))
1306
+ except MedOpsError as exc:
1307
+ item.update(
1308
+ {
1309
+ "blocked_reason": "missing_or_invalid_artifact_manifest",
1310
+ "artifact_manifest_error": str(exc),
1311
+ "next_action": "Corrija o manifesto HTML do Gemini ou remova a dependência antes de lançar architects.",
1312
+ }
1313
+ )
1314
+ _non_launchable_blocked_item(item)
1315
+ blocked_items.append(item)
1316
+ continue
1317
+ item.update(artifact_payload)
1318
+ item["temp_dir"] = str(temp_root / item["work_id"])
1319
+ _launchable_work_item(item)
1320
+ work_items.append(item)
1321
+
1322
+ batches = _batch_refs(work_items, concurrency)
1323
+ status, next_action, _blocked_requires_attention = plan_status(
1324
+ item_count=len(work_items),
1325
+ blocked_item_count=len(blocked_items),
1326
+ )
1327
+ human_decision_required = bool(_decision_packets_from_blocked_items(blocked_items))
1328
+ payload = _with_decision_packets({
1329
+ "schema": SUBAGENT_PLAN_SCHEMA,
1330
+ "phase": "architect",
1331
+ "agent": spec["agent"],
1332
+ "unit": spec["unit"],
1333
+ "max_concurrency": concurrency,
1334
+ "item_count": len(work_items),
1335
+ "total_available_count": total_available_count,
1336
+ "blocked_item_count": len(blocked_items),
1337
+ "blocked_items": blocked_items,
1338
+ "canonical_merge_item_count": sum(1 for item in work_items if _is_canonical_merge_work_item(item)),
1339
+ "limit": limit,
1340
+ "truncated": limit is not None and len(rows) < total_available_count,
1341
+ "parallel_safe": len(work_items) > 1,
1342
+ "launch_source": "work_items",
1343
+ "batch_contract": "batches contain work_id references only; never spawn from both work_items and batch references.",
1344
+ "work_items": work_items,
1345
+ "batches": batches,
1346
+ "rules": [
1347
+ "Spawn at most one subagent per work_item.owner_key.",
1348
+ "Never spawn multiple subagents for the same raw chat or generated note.",
1349
+ "Use work_items as the only full launch payload; batches only group existing work_ids.",
1350
+ "Only work_items with launchable=true may be sent to med-knowledge-architect; blocked_items are stop packets.",
1351
+ "Canonical merges into an existing Wiki note are launchable architect work_items with write_policy=existing_note_rewrite and must be applied with apply-canonical-merge.",
1352
+ "Blocked architect items with write_policy=no_temp_note must not produce temp Markdown and must not be deferred to fix-wiki.",
1353
+ "Do not split one raw chat across multiple med-knowledge-architect agents.",
1354
+ "Architect work_items must follow the triage-authored note_plan exactly.",
1355
+ "raw-coverage.v1 includes only coverage-bearing v2 items: planned_meaning and not_a_note. Do not include attach_to_planned_meaning or needs_context as raw-coverage items; attach details must be folded into the target note, and needs_context blocks before architect.",
1356
+ "raw-coverage.v1 must carry raw_file, exhaustive=true, items[], and the same batch_id/run_id/source_artifact_hash metadata as the note_plan when present.",
1357
+ "Architect planning blocks planned_meaning targets that duplicate existing Wiki notes or another planned raw chat after accent/case normalization.",
1358
+ "When several simple raw chats target the same new note, plan one canonical_merge work_item owned by target_key.",
1359
+ "Canonical merge work_items must preserve new information from every source and report delta_per_source plus multi-reference provenance.",
1360
+ "Every architect result must include an exhaustive raw coverage inventory before staging.",
1361
+ "If artifact_manifests is non-empty, the staged note group for that raw chat must cover every listed artifact: HTML needs iframe/link/provenance, image needs Markdown embed/Figura caption/provenance.",
1362
+ "Do not launch more subagents than item_count or max_concurrency.",
1363
+ "If item_count is 0 or 1, there is no useful fan-out for this phase.",
1364
+ "When limit is set, spawn only the returned work_items",
1365
+ "Rerun planning after serial consolidation before launching more.",
1366
+ "Run serial consolidation after each batch returns.",
1367
+ ],
1368
+ "serial_after": spec["serial_after"],
1369
+ "canonical_parent_commands": spec["canonical_parent_commands"],
1370
+ }, blocked_items)
1371
+ terminal_no_pending = not work_items and not blocked_items
1372
+ if terminal_no_pending:
1373
+ payload.update(_process_chats_terminal_no_pending_contract())
1374
+ payload["parent_applies_outputs"] = False
1375
+ payload["serial_after"] = ["terminal no-op: write the final no-pending report and stop"]
1376
+ payload["canonical_parent_commands"] = [
1377
+ "terminal no-op: no publish/link/fix command is valid when there are no new chats to process"
1378
+ ]
1379
+ return _typed_subagent_plan_payload(annotate_payload(payload,
1380
+ phase="architect",
1381
+ status=status,
1382
+ blocked_reason="preconditions_failed" if blocked_items and not work_items else "",
1383
+ next_action="" if terminal_no_pending else next_action,
1384
+ required_inputs=[] if terminal_no_pending else PROCESS_CHATS_REQUIRED_INPUTS,
1385
+ human_decision_required=human_decision_required,
1386
+ ))
1387
+
1388
+
1389
+ def plan_subagents(
1390
+ config: MedConfig,
1391
+ phase: str,
1392
+ max_concurrency: int | None = None,
1393
+ temp_root: Path | None = None,
1394
+ limit: int | None = None,
1395
+ fix_wiki_plan_path: Path | None = None,
1396
+ style_audit: JsonObject | None = None,
1397
+ ) -> JsonObject:
1398
+ specs: dict[str, JsonObject] = {
1399
+ "triage": {
1400
+ "agent": "med-chat-triager",
1401
+ "mode": "pending",
1402
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "triage"),
1403
+ "item_type": "raw_chat",
1404
+ "unit": "one pending raw chat per subagent",
1405
+ "serial_after": [
1406
+ "official subagent runner saves the top-level triager output to work_item.triager_output_path and writes a signed subagent-run-receipt.v1 for that exact output",
1407
+ "parent extracts note_plan to work_item.note_plan_path, writes eval to work_item.triager_eval_path with --subagent-run-receipt and --require-subagent-run-receipt, and only applies triage when triager-prompt-eval.v1 passes and the signed receipt/output/note_plan chain revalidates",
1408
+ "parent must not create, edit, re-sign, or patch subagent-run-receipt.v1; missing/invalid receipt means re-run the packaged triager through the official runner",
1409
+ "parent never patches the triager output or note_plan by hand; failed eval means re-run the triager with error_context or stop",
1410
+ "parent does not read raw chat content before plan-subagents returns work_items and does not write triage artifacts under repo-root tmp/",
1411
+ "parent refreshes list-triados before architect planning",
1412
+ ],
1413
+ "canonical_parent_commands": [
1414
+ 'eval triager output (triager-prompt-eval.v1): uv run python "<wiki/cli.py>" eval-triager-output --raw-file "<raw_file>" --output "<triager-output.json>" --subagent-run-receipt "<subagent-run-receipt.json>" --require-subagent-run-receipt --report "<triager-eval.json>" --json',
1415
+ 'triage: uv run python "<wiki/cli.py>" triage --raw-file "<raw_file>" --tipo medicina --titulo "<titulo_triagem>" --fonte-id "<fonte_id>" --note-plan "<note-plan.json>" --triager-eval "<triager-eval.json>" --json',
1416
+ 'discard: uv run python "<wiki/cli.py>" discard --raw-file "<raw_file>" --reason "<reason>"',
1417
+ ],
1418
+ },
1419
+ "architect": {
1420
+ "agent": "med-knowledge-architect",
1421
+ "mode": "triados",
1422
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "architect"),
1423
+ "item_type": "triaged_raw_chat",
1424
+ "unit": "one triaged raw chat per subagent, or one canonical merge target; all notes split from a raw chat stay together",
1425
+ "serial_after": [
1426
+ "parent validates/fixes each returned temp note or existing-note rewrite",
1427
+ "parent stages new notes with wiki/cli.py stage-note and the architect coverage inventory",
1428
+ "parent applies existing-note canonical rewrites with wiki/cli.py apply-canonical-merge",
1429
+ "catalog, dry-run, guard, publish and linker stay serial for staged new notes",
1430
+ ],
1431
+ "canonical_parent_commands": [
1432
+ 'validate-note: uv run python "<wiki/cli.py>" validate-note --content "<temp.md>" --title "<title>" --raw-file "<raw_file>" --json',
1433
+ 'fix-note: uv run python "<wiki/cli.py>" fix-note --content "<temp.md>" --title "<title>" --raw-file "<raw_file>" --output "<temp.md>" --json',
1434
+ 'apply canonical merge dry-run: uv run python "<wiki/cli.py>" apply-canonical-merge --target "<existing-note.md>" --content "<rewrite.md>" --coverage "<coverage.json>" --dry-run --json',
1435
+ 'apply canonical merge: uv run python "<wiki/cli.py>" apply-canonical-merge --target "<existing-note.md>" --content "<rewrite.md>" --coverage "<coverage.json>" --json',
1436
+ 'stage-note: uv run python "<wiki/cli.py>" stage-note --manifest "<manifest.json>" --raw-file "<raw_file>" --coverage "<coverage.json>" --taxonomy "<taxonomy>" --title "<title>" --content "<temp.md>"',
1437
+ 'publish dry-run: uv run python "<wiki/cli.py>" publish-batch --manifest "<manifest.json>" --dry-run',
1438
+ 'publish: uv run python "<wiki/cli.py>" publish-batch --manifest "<manifest.json>"',
1439
+ 'diagnose links: uv run python "<wiki/cli.py>" run-linker --diagnose --json',
1440
+ 'apply links if diagnosis is safe: uv run python "<wiki/cli.py>" run-linker --apply --diagnosis "<link-diagnosis.json>" --json',
1441
+ ],
1442
+ },
1443
+ "style-rewrite": {
1444
+ "agent": "med-knowledge-architect",
1445
+ "mode": "wiki_style_rewrite",
1446
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "style-rewrite"),
1447
+ "item_type": "wiki_note_style_rewrite",
1448
+ "unit": "one existing Wiki_Medicina note per subagent; each target path is unique",
1449
+ "serial_after": [
1450
+ "parent applies each returned temp rewrite atomically with wiki/cli.py apply-specialist-style-rewrite",
1451
+ "parent refreshes the next style-rewrite batch with plan-subagents until the style queue is empty",
1452
+ "parent runs full fix-wiki verification once after the style queue is empty",
1453
+ ],
1454
+ "canonical_parent_commands": [
1455
+ "Gemini CLI specialist rewrite: consume the call_specialist_model WorkflowEffect with one current_batch_items entry and the packaged med-knowledge-architect agent",
1456
+ 'AGY specialist receipt finalization after packaged invoke_subagent: uv run python "<wiki/cli.py>" finalize-agy-specialist-task --plan "<style-rewrite-plan.json>" --work-id "<work_id>" --transcript "<agy-transcript-or-task-log>" [--runtime-log "<agy-cli.log>"] --json',
1457
+ 'OpenCode specialist receipt finalization after native task: uv run python "<wiki/cli.py>" finalize-opencode-specialist-task --plan "<style-rewrite-plan.json>" --work-id "<work_id>" --json',
1458
+ 'apply specialist rewrite: uv run python "<wiki/cli.py>" apply-specialist-style-rewrite --plan "<style-rewrite-plan.json>" --manifest "<style-rewrite-manifest.json>" --work-id "<work_id>" --specialist-run-receipt "<specialist-task-run-receipt.json>" --json',
1459
+ ],
1460
+ },
1461
+ "note-merge": {
1462
+ "agent": "med-knowledge-architect",
1463
+ "mode": "wiki_note_merge",
1464
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "note-merge"),
1465
+ "item_type": "wiki_note_merge",
1466
+ "unit": "one semantic note merge group per subagent; title/stem duplicates are not sufficient",
1467
+ "serial_after": [
1468
+ "parent validates each returned merge with wiki/cli.py apply-note-merge --dry-run",
1469
+ "parent applies accepted merges serially with wiki/cli.py apply-note-merge",
1470
+ "parent runs /mednotes:link once after accepted note merges",
1471
+ ],
1472
+ "canonical_parent_commands": [
1473
+ 'apply merge dry-run: uv run python "<wiki/cli.py>" apply-note-merge --plan "<plan.json>" --content "<merged.md>" --dry-run --json',
1474
+ 'apply merge: uv run python "<wiki/cli.py>" apply-note-merge --plan "<plan.json>" --content "<merged.md>" --json',
1475
+ ],
1476
+ },
1477
+ "vocabulary-curation": {
1478
+ "agent": "med-link-graph-curator",
1479
+ "mode": "vocabulary_curation",
1480
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "vocabulary-curation"),
1481
+ "item_type": "vocabulary_semantic_ingestion",
1482
+ "unit": "one pending vocabulary note per subagent",
1483
+ "serial_after": [
1484
+ "parent collects note-semantic-ingestion.v1 outputs",
1485
+ "parent writes vocabulary-curator-batch-output-manifest.v1",
1486
+ "parent runs wiki/cli.py eval-curator-batch",
1487
+ "parent applies outputs with wiki/cli.py apply-curator-batch --prompt-eval",
1488
+ ],
1489
+ "canonical_parent_commands": [
1490
+ 'eval curator batch: uv run python "<wiki/cli.py>" eval-curator-batch --plan "<plan.json>" --outputs "<manifest.json>" --report "<curator-prompt-eval.json>" --json',
1491
+ 'apply curator batch: uv run python "<wiki/cli.py>" apply-curator-batch --plan "<plan.json>" --outputs "<manifest.json>" --prompt-eval "<curator-prompt-eval.json>" --receipt "<receipt.json>" --json',
1492
+ ],
1493
+ },
1494
+ "atomicity-split": {
1495
+ "agent": "med-knowledge-architect",
1496
+ "mode": "wiki_atomicity_split",
1497
+ "default_max_concurrency": configured_subagent_max_concurrency(config, "atomicity-split"),
1498
+ "item_type": "wiki_atomicity_split",
1499
+ "unit": "one non-atomic source note per subagent",
1500
+ "serial_after": [
1501
+ "parent collects atomicity-split-bundle.v1 outputs",
1502
+ "parent applies accepted bundles serially with wiki/cli.py apply-atomicity-split",
1503
+ "parent runs the linker once per parent batch unless apply-atomicity-split was not deferred",
1504
+ ],
1505
+ "canonical_parent_commands": [
1506
+ 'apply split: uv run python "<wiki/cli.py>" apply-atomicity-split --bundle "<bundle.json>" --json',
1507
+ ],
1508
+ },
1509
+ }
1510
+ if phase not in specs:
1511
+ raise ValidationError(f"Unknown subagent planning phase: {phase}")
1512
+ spec = specs[phase]
1513
+ concurrency = int(max_concurrency) if max_concurrency is not None else int(spec["default_max_concurrency"])
1514
+ if concurrency < 1:
1515
+ raise ValidationError("--max-concurrency must be at least 1")
1516
+ if limit is not None and limit < 1:
1517
+ raise ValidationError("--limit must be at least 1")
1518
+ if temp_root is None:
1519
+ temp_root = _default_subagent_temp_root(phase)
1520
+
1521
+ if phase == "vocabulary-curation":
1522
+ if config.vocabulary_db_path is None:
1523
+ raise ValidationError("vocabulary-curation requires a vocabulary DB path")
1524
+ if temp_root is None:
1525
+ raise ValidationError("Internal error: vocabulary-curation temp_root was not resolved")
1526
+ batch_id = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ-vocabulary-curation")
1527
+ plan = build_vocabulary_curator_batch_plan(
1528
+ db_path=config.vocabulary_db_path,
1529
+ batch_id=batch_id,
1530
+ output_dir=temp_root,
1531
+ limit=limit or 20,
1532
+ )
1533
+ plan_summary = _SubagentGeneratedPlanSummary.model_validate(plan)
1534
+ if max_concurrency is not None:
1535
+ plan["max_concurrency"] = min(int(max_concurrency), plan_summary.item_count) if plan_summary.item_count else 0
1536
+ else:
1537
+ plan["max_concurrency"] = min(concurrency, plan_summary.item_count) if plan_summary.item_count else 0
1538
+ plan["serial_after"] = spec["serial_after"]
1539
+ plan["canonical_parent_commands"] = spec["canonical_parent_commands"]
1540
+ return _typed_subagent_plan_payload(plan)
1541
+
1542
+ if phase == "atomicity-split":
1543
+ if fix_wiki_plan_path is None:
1544
+ raise ValidationError("atomicity-split requires --fix-wiki-plan <fix-wiki-plan.json>")
1545
+ if temp_root is None:
1546
+ raise ValidationError("Internal error: atomicity-split temp_root was not resolved")
1547
+ batch_id = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ-atomicity-split")
1548
+ plan = build_atomicity_split_plan(
1549
+ fix_wiki_plan_path=fix_wiki_plan_path,
1550
+ batch_id=batch_id,
1551
+ temp_root=temp_root,
1552
+ limit=limit or 20,
1553
+ )
1554
+ plan_summary = _SubagentGeneratedPlanSummary.model_validate(plan)
1555
+ if max_concurrency is not None:
1556
+ plan["max_concurrency"] = min(int(max_concurrency), plan_summary.item_count) if plan_summary.item_count else 0
1557
+ else:
1558
+ plan["max_concurrency"] = min(concurrency, plan_summary.item_count) if plan_summary.item_count else 0
1559
+ plan["serial_after"] = spec["serial_after"]
1560
+ plan["canonical_parent_commands"] = spec["canonical_parent_commands"]
1561
+ return _typed_subagent_plan_payload(plan)
1562
+
1563
+ if phase == "note-merge":
1564
+ return _typed_subagent_plan_payload({
1565
+ "schema": SUBAGENT_PLAN_SCHEMA,
1566
+ "phase": "note-merge",
1567
+ "agent": spec["agent"],
1568
+ "status": "skipped",
1569
+ "skipped_reason": "no_note_merge_work",
1570
+ "mode": spec["mode"],
1571
+ "item_type": spec["item_type"],
1572
+ "unit": spec["unit"],
1573
+ "max_concurrency": concurrency,
1574
+ "item_count": 0,
1575
+ "blocked_item_count": 0,
1576
+ "total_available_count": 0,
1577
+ "work_items": [],
1578
+ "blocked_items": [],
1579
+ "batches": [],
1580
+ "parallel_safe": False,
1581
+ "serial_after": spec["serial_after"],
1582
+ "canonical_parent_commands": spec["canonical_parent_commands"],
1583
+ "rules": [
1584
+ "Only semantic identity evidence may create a note-merge work item.",
1585
+ "Do not infer a merge from title, stem, accent, case, or path similarity alone.",
1586
+ "Every apply requires note-merge-plan.v1, preservation report, source hashes, expected aliases, and expected chats.",
1587
+ ],
1588
+ "next_action": "Sem grupos semânticos de note_merge prontos neste diagnóstico.",
1589
+ })
1590
+
1591
+ if phase == "style-rewrite":
1592
+ if temp_root is None:
1593
+ raise ValidationError("Internal error: style-rewrite temp_root was not resolved")
1594
+ audit = _StyleRewriteAuditSummary.model_validate(
1595
+ style_audit if style_audit is not None else validate_wiki_style(config.wiki_dir)
1596
+ )
1597
+ work_items: list[JsonObject] = []
1598
+ seen: set[str] = set()
1599
+ rewrite_reports = [report for report in audit.reports if report.requires_llm_rewrite and report.path]
1600
+ total_available_count = len(rewrite_reports)
1601
+ if limit is not None:
1602
+ rewrite_reports = rewrite_reports[:limit]
1603
+ for index, report in enumerate(rewrite_reports, start=1):
1604
+ target_path = Path(report.path)
1605
+ owner_key = str(target_path.expanduser())
1606
+ if owner_key in seen:
1607
+ continue
1608
+ seen.add(owner_key)
1609
+ work_id = f"{phase}-{index:03d}-{_slug(target_path.stem)}"
1610
+ item: JsonObject = {
1611
+ "work_id": work_id,
1612
+ "agent": spec["agent"],
1613
+ "item_type": spec["item_type"],
1614
+ "target_path": str(target_path),
1615
+ "target_hash_before": _file_sha256(target_path),
1616
+ "owner_key": owner_key,
1617
+ "title": report.title or target_path.stem,
1618
+ "rewrite_prompt": report.rewrite_prompt,
1619
+ "model_policy": "medical_specialist_authoring.v1",
1620
+ "required_model_tier": "specialist",
1621
+ "preferred_model_tier": "pro",
1622
+ "errors": report.errors,
1623
+ "warnings": report.warnings,
1624
+ "temp_dir": str(temp_root / work_id),
1625
+ "temp_output": str(temp_root / work_id / f"{target_path.stem}.rewrite.md"),
1626
+ "output_attestation_path": str(temp_root / work_id / f"{target_path.stem}.rewrite.md.attestation.json"),
1627
+ "specialist_task_run_receipt_path": str(
1628
+ temp_root / work_id / f"{target_path.stem}.specialist-task-run-receipt.json"
1629
+ ),
1630
+ "subagent_output_contract": _style_rewrite_subagent_output_contract(),
1631
+ "context_docs": _style_rewrite_context_docs(),
1632
+ }
1633
+ temp_dir = Path(str(item["temp_dir"]))
1634
+ temp_dir.mkdir(parents=True, exist_ok=True)
1635
+ (temp_dir / ".keep").touch(exist_ok=True)
1636
+ work_items.append(item)
1637
+ batches = _batch_refs(work_items, concurrency)
1638
+ return _typed_subagent_plan_payload({
1639
+ "schema": SUBAGENT_PLAN_SCHEMA,
1640
+ "phase": phase,
1641
+ "agent": spec["agent"],
1642
+ "status": "ready" if work_items else "skipped",
1643
+ "skipped_reason": "" if work_items else "no_style_rewrite_work",
1644
+ "unit": spec["unit"],
1645
+ "max_concurrency": concurrency,
1646
+ "item_count": len(work_items),
1647
+ "total_available_count": total_available_count,
1648
+ "blocked_item_count": 0,
1649
+ "limit": limit,
1650
+ "truncated": len(work_items) < total_available_count,
1651
+ "parallel_safe": len(work_items) > 1,
1652
+ "launch_source": "work_items",
1653
+ "batch_contract": "batches contain work_id references only; never spawn from both work_items and batch references.",
1654
+ "work_items": work_items,
1655
+ "batches": batches,
1656
+ "rules": [
1657
+ "Spawn at most one subagent per work_item.target_path.",
1658
+ "Never spawn multiple subagents for the same Wiki note.",
1659
+ "Use work_items as the only full launch payload; batches only group existing work_ids.",
1660
+ "Do not split one note rewrite across multiple med-knowledge-architect agents.",
1661
+ "Do not launch more subagents than item_count or max_concurrency.",
1662
+ "If item_count is 0 or 1, there is no useful fan-out for this phase.",
1663
+ "When limit is set, spawn only the returned work_items",
1664
+ "Rerun planning after serial consolidation before launching more.",
1665
+ "Run serial apply-style-rewrite validation and application after each batch returns.",
1666
+ ],
1667
+ "serial_after": spec["serial_after"],
1668
+ "canonical_parent_commands": spec["canonical_parent_commands"],
1669
+ "source_audit": {
1670
+ "schema": audit.schema_,
1671
+ "wiki_dir": audit.wiki_dir or str(config.wiki_dir),
1672
+ "file_count": audit.file_count,
1673
+ "error_count": audit.error_count,
1674
+ "warning_count": audit.warning_count,
1675
+ },
1676
+ })
1677
+
1678
+ covered_ids = set(covered_raw_chat_index(config.wiki_dir)) if spec["mode"] == "pending" else set()
1679
+ rows = list_by_status(config.raw_dir, str(spec["mode"]), covered_raw_chat_ids=covered_ids)
1680
+ total_available_count = len(rows)
1681
+ if limit is not None:
1682
+ rows = rows[:limit]
1683
+ if phase == "architect":
1684
+ if temp_root is None:
1685
+ raise ValidationError("Internal error: architect temp_root was not resolved")
1686
+ return _plan_architect_subagents(
1687
+ config,
1688
+ spec,
1689
+ rows,
1690
+ total_available_count=total_available_count,
1691
+ concurrency=concurrency,
1692
+ temp_root=temp_root,
1693
+ limit=limit,
1694
+ )
1695
+ work_items: list[JsonObject] = []
1696
+ blocked_items: list[JsonObject] = []
1697
+ seen: set[str] = set()
1698
+ for index, row in enumerate(rows, start=1):
1699
+ raw_file = str(row["path"])
1700
+ raw_key = str(Path(raw_file).expanduser())
1701
+ if raw_key in seen:
1702
+ continue
1703
+ seen.add(raw_key)
1704
+ work_id = f"{phase}-{index:03d}-{_slug(Path(raw_file).stem)}"
1705
+ item: JsonObject = {
1706
+ "work_id": work_id,
1707
+ "agent": spec["agent"],
1708
+ "item_type": spec["item_type"],
1709
+ "raw_file": raw_file,
1710
+ "owner_key": raw_key,
1711
+ "titulo_triagem": _json_str_field(row, "titulo_triagem"),
1712
+ "fonte_id": _json_str_field(row, "fonte_id"),
1713
+ }
1714
+ if temp_root is not None:
1715
+ temp_dir = temp_root / work_id
1716
+ item["temp_dir"] = str(temp_dir)
1717
+ if phase == "triage":
1718
+ item["triager_output_path"] = str(temp_dir / "triager-output.json")
1719
+ item["note_plan_path"] = str(temp_dir / "note-plan.json")
1720
+ item["triager_eval_path"] = str(temp_dir / "triager-eval.json")
1721
+ work_items.append(item)
1722
+
1723
+ batches = _batch_refs(work_items, concurrency)
1724
+ status, next_action, _blocked_requires_attention = plan_status(
1725
+ item_count=len(work_items),
1726
+ blocked_item_count=len(blocked_items),
1727
+ )
1728
+ human_decision_required = bool(_decision_packets_from_blocked_items(blocked_items))
1729
+ return _typed_subagent_plan_payload(annotate_payload({
1730
+ "schema": SUBAGENT_PLAN_SCHEMA,
1731
+ "phase": phase,
1732
+ "agent": spec["agent"],
1733
+ "unit": spec["unit"],
1734
+ "max_concurrency": concurrency,
1735
+ "item_count": len(work_items),
1736
+ "total_available_count": total_available_count,
1737
+ "blocked_item_count": len(blocked_items),
1738
+ "blocked_items": blocked_items,
1739
+ "limit": limit,
1740
+ "truncated": limit is not None and len(rows) < total_available_count,
1741
+ "parallel_safe": len(work_items) > 1,
1742
+ "launch_source": "work_items",
1743
+ "batch_contract": "batches contain work_id references only; never spawn from both work_items and batch references.",
1744
+ "work_items": work_items,
1745
+ "batches": batches,
1746
+ "rules": [
1747
+ "Spawn at most one subagent per work_item.raw_file.",
1748
+ "Never spawn multiple subagents for the same raw chat or generated note.",
1749
+ "Use work_items as the only full launch payload; batches only group existing work_ids.",
1750
+ "Do not replace med-chat-triager by reading multiple raw chats in the parent agent.",
1751
+ "Parent must use work_item.triager_output_path, work_item.note_plan_path, and work_item.triager_eval_path; do not write workflow artifacts under repo-root tmp/.",
1752
+ "Do not launch more subagents than item_count or max_concurrency.",
1753
+ "If item_count is 0 or 1, there is no useful fan-out for this phase.",
1754
+ "When limit is set, spawn only the returned work_items",
1755
+ "Rerun planning after serial consolidation before launching more.",
1756
+ "Parent must apply triage or discard serially after each batch returns.",
1757
+ ],
1758
+ "serial_after": spec["serial_after"],
1759
+ "canonical_parent_commands": spec["canonical_parent_commands"],
1760
+ },
1761
+ phase=phase,
1762
+ status=status,
1763
+ blocked_reason="preconditions_failed" if blocked_items and not work_items else "",
1764
+ next_action=next_action,
1765
+ required_inputs=STYLE_REWRITE_REQUIRED_INPUTS if phase == "style-rewrite" else PROCESS_CHATS_REQUIRED_INPUTS,
1766
+ human_decision_required=human_decision_required,
1767
+ ))