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,1139 @@
1
+ """Staging and publishing generated Wiki notes."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from pydantic import ConfigDict, Field
11
+ from pydantic import ValidationError as PydanticValidationError
12
+
13
+ from mednotes.domains.wiki.batch_state import (
14
+ batch_state_from,
15
+ file_sha256,
16
+ merge_batch_state,
17
+ require_compatible_batch_state,
18
+ )
19
+ from mednotes.domains.wiki.capabilities.graph.coverage import (
20
+ validate_raw_coverage,
21
+ validate_raw_coverage_structure,
22
+ )
23
+ from mednotes.domains.wiki.capabilities.markdown.markdown_query import (
24
+ MarkdownQueryUnavailable,
25
+ ensure_markdown_query_available,
26
+ markdown_query_blocked_payload,
27
+ )
28
+ from mednotes.domains.wiki.capabilities.notes.artifacts import validate_artifact_batch, validate_note_artifacts
29
+ from mednotes.domains.wiki.capabilities.notes.note_style.frontmatter import FrontmatterYamlUnavailable
30
+ from mednotes.domains.wiki.capabilities.notes.provenance import _apply_note_provenance_from_raw_files
31
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text, mutate_raw_frontmatter
32
+ from mednotes.domains.wiki.capabilities.publish.publish_receipts import build_publish_receipt_payload
33
+ from mednotes.domains.wiki.capabilities.style.style import validate_wiki_note_contract
34
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import normalize_key
35
+ from mednotes.domains.wiki.capabilities.vocabulary.taxonomy import (
36
+ _validate_taxonomy_not_title,
37
+ normalize_taxonomy,
38
+ resolve_target_for_note,
39
+ resolve_taxonomy,
40
+ safe_title,
41
+ )
42
+ from mednotes.domains.wiki.common import CollisionError, MedOpsError, MissingPathError, ValidationError, _now_iso
43
+ from mednotes.domains.wiki.config import MedConfig, _path
44
+ from mednotes.domains.wiki.contracts.publish import PublishManifest, PublishManifestBatch
45
+ from mednotes.domains.wiki.contracts.raw_coverage import (
46
+ RawCoveragePlanBatch,
47
+ RawCoverageSummary,
48
+ coverage_summary_from_batches,
49
+ )
50
+ from mednotes.domains.wiki.contracts.workflow_guardrails import (
51
+ PUBLISH_REQUIRED_INPUTS,
52
+ annotate_payload,
53
+ note_target_index,
54
+ )
55
+ from mednotes.domains.wiki.flows.process_chats.process_chats_machine import (
56
+ ProcessChatsErrorContext,
57
+ ProcessChatsPublishRuntimeObservation,
58
+ ProcessChatsState,
59
+ )
60
+ from mednotes.domains.wiki.flows.process_chats.process_chats_runtime_result import (
61
+ process_chats_fsm_payload_from_publish_result,
62
+ )
63
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter, contract_error
64
+
65
+
66
+ class _ArtifactBatchValidationFields(ContractModel):
67
+ """Typed lens for child artifact validation reports aggregated by publish."""
68
+
69
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, validate_assignment=True)
70
+
71
+ required: bool = Field(default=False, strict=True)
72
+ manifest_count: int = Field(default=0, ge=0, strict=True)
73
+ artifact_count: int = Field(default=0, ge=0, strict=True)
74
+ covered_artifact_count: int = Field(default=0, ge=0, strict=True)
75
+ missing_artifact_count: int = Field(default=0, ge=0, strict=True)
76
+
77
+
78
+ class _ProcessChatsPublishSafetyFields(ContractModel):
79
+ """Typed view used to decide whether publish mutated the vault."""
80
+
81
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, validate_assignment=True)
82
+
83
+ created: list[object] = Field(default_factory=list)
84
+ processed_raw_count: int = Field(default=0, ge=0, strict=True)
85
+ manifest_hash: str = ""
86
+
87
+
88
+ class _ProcessChatsRuntimeObservationErrorFields(ContractModel):
89
+ """Typed lens from broad diagnostic context into FSM error context."""
90
+
91
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, validate_assignment=True)
92
+
93
+ root_cause: str = ""
94
+ blocked_reason: str = ""
95
+ affected_artifact: str = ""
96
+ next_action: str = ""
97
+ suggested_fix: str = ""
98
+ retry_scope: str = ""
99
+
100
+
101
+ def _publish_json_object(value: object, *, prefix: str) -> JsonObject:
102
+ try:
103
+ return JsonObjectAdapter.validate_python(value)
104
+ except PydanticValidationError as exc:
105
+ raise contract_error(exc, prefix=prefix) from exc
106
+
107
+
108
+ def resolve_collision(path: Path, mode: str, reserved: set[Path]) -> Path:
109
+ if mode not in {"abort", "suffix"}:
110
+ raise ValidationError(f"Invalid collision mode: {mode}")
111
+ if mode == "abort":
112
+ if path.exists() or path in reserved:
113
+ raise CollisionError(f"Target note already exists: {path}")
114
+ return path
115
+
116
+ candidate = path
117
+ idx = 2
118
+ while candidate.exists() or candidate in reserved:
119
+ candidate = path.with_name(f"{path.stem} ({idx}){path.suffix}")
120
+ idx += 1
121
+ return candidate
122
+
123
+
124
+ def write_new_note(path: Path, content: str, dry_run: bool = False, create_parent: bool = False) -> None:
125
+ if dry_run:
126
+ return
127
+ if path.exists():
128
+ raise CollisionError(f"Target note already exists: {path}")
129
+ if create_parent:
130
+ path.parent.mkdir(parents=True, exist_ok=True)
131
+ elif not path.parent.exists():
132
+ raise MissingPathError(f"Target taxonomy directory does not exist: {path.parent}")
133
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.stem}.", suffix=".tmp", dir=str(path.parent))
134
+ tmp = Path(tmp_name)
135
+ try:
136
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
137
+ fh.write(content)
138
+ if path.exists():
139
+ raise CollisionError(f"Target note appeared during write: {path}")
140
+ os.replace(tmp, path)
141
+ finally:
142
+ if tmp.exists():
143
+ tmp.unlink()
144
+
145
+
146
+ def _load_manifest(path: Path) -> JsonObject:
147
+ if not path.exists():
148
+ raise MissingPathError(f"Manifest not found: {path}")
149
+ try:
150
+ data = json.loads(path.read_text(encoding="utf-8"))
151
+ except json.JSONDecodeError as exc:
152
+ raise ValidationError(f"Invalid manifest JSON: {exc}") from exc
153
+ if not isinstance(data, dict):
154
+ raise ValidationError("Manifest must be a JSON object")
155
+ return _publish_json_object(data, prefix="publish manifest")
156
+
157
+
158
+ def _publish_batch_id(manifest: Path) -> str:
159
+ try:
160
+ return file_sha256(manifest)
161
+ except OSError:
162
+ return str(manifest)
163
+
164
+
165
+ def _blocked_publish_contract_receipt(
166
+ *,
167
+ manifest: Path,
168
+ root_cause: str,
169
+ blocked_reason: str | None = None,
170
+ error_summary: str,
171
+ next_action: str,
172
+ ) -> JsonObject:
173
+ blocked = blocked_reason or root_cause
174
+ error_context = _publish_json_object({
175
+ "phase": "publish_dry_run",
176
+ "blocked_reason": blocked,
177
+ "root_cause": root_cause,
178
+ "affected_artifact": str(manifest),
179
+ "error_summary": error_summary,
180
+ "suggested_fix": next_action,
181
+ "next_action": next_action,
182
+ "retry_scope": "recreate_publish_manifest_then_retry",
183
+ }, prefix="publish blocked receipt")
184
+ return _publish_json_object(annotate_payload(
185
+ {
186
+ "dry_run": True,
187
+ "backup": False,
188
+ "manifest": str(manifest),
189
+ "created": [],
190
+ "raw_updates": [],
191
+ "error_context": error_context,
192
+ "publish_receipt": build_publish_receipt_payload(
193
+ status="blocked",
194
+ batch_id=_publish_batch_id(manifest),
195
+ published_count=0,
196
+ skipped_count=0,
197
+ items=[],
198
+ next_action=next_action,
199
+ error_context=error_context,
200
+ ),
201
+ "runtime_observation": _process_chats_runtime_observation_payload(
202
+ source_state=ProcessChatsState.NOTE_VALIDATION_RUNNING,
203
+ validation_coverage_gap=blocked == "coverage_path_missing",
204
+ validation_manifest_mismatch=blocked != "coverage_path_missing",
205
+ reason_code=blocked,
206
+ next_action=next_action,
207
+ manifest_path=str(manifest),
208
+ error_context=error_context,
209
+ ),
210
+ },
211
+ phase="publish_dry_run",
212
+ status="blocked",
213
+ blocked_reason=blocked,
214
+ next_action=next_action,
215
+ required_inputs=PUBLISH_REQUIRED_INPUTS,
216
+ human_decision_required=False,
217
+ ), prefix="publish blocked receipt")
218
+
219
+
220
+ def _process_chats_runtime_observation_payload(
221
+ *,
222
+ source_state: ProcessChatsState,
223
+ preview_ready: bool = False,
224
+ publish_completed: bool = False,
225
+ link_completed: bool = False,
226
+ link_blocked: bool = False,
227
+ rollback_recorded: bool = False,
228
+ blocked: bool = False,
229
+ quota_wait: bool = False,
230
+ validation_coverage_gap: bool = False,
231
+ validation_manifest_mismatch: bool = False,
232
+ validation_content_invalid: bool = False,
233
+ publish_dry_run_receipt_required: bool = False,
234
+ publish_stale_receipt: bool = False,
235
+ publish_duplicate_target: bool = False,
236
+ publish_provenance_gap: bool = False,
237
+ reason_code: str = "",
238
+ next_action: str = "",
239
+ manifest_path: str = "",
240
+ dry_run_receipt_path: str = "",
241
+ receipt_id: str = "",
242
+ published_count: int = 0,
243
+ link_trigger_context_path: str = "",
244
+ link_receipt_id: str = "",
245
+ link_changed_files: Sequence[str] | None = None,
246
+ error_context: JsonObject | ProcessChatsErrorContext | None = None,
247
+ ) -> JsonObject:
248
+ """Build the canonical process-chats observation at the producer boundary."""
249
+
250
+ typed_error_context = (
251
+ _process_chats_runtime_error_context(error_context, fallback_artifact=manifest_path)
252
+ if isinstance(error_context, dict)
253
+ else error_context
254
+ )
255
+ return ProcessChatsPublishRuntimeObservation(
256
+ source_state=source_state,
257
+ preview_ready=preview_ready,
258
+ publish_completed=publish_completed,
259
+ link_completed=link_completed,
260
+ link_blocked=link_blocked,
261
+ rollback_recorded=rollback_recorded,
262
+ blocked=blocked,
263
+ quota_wait=quota_wait,
264
+ validation_coverage_gap=validation_coverage_gap,
265
+ validation_manifest_mismatch=validation_manifest_mismatch,
266
+ validation_content_invalid=validation_content_invalid,
267
+ publish_dry_run_receipt_required=publish_dry_run_receipt_required,
268
+ publish_stale_receipt=publish_stale_receipt,
269
+ publish_duplicate_target=publish_duplicate_target,
270
+ publish_provenance_gap=publish_provenance_gap,
271
+ reason_code=reason_code,
272
+ next_action=next_action,
273
+ manifest_path=manifest_path,
274
+ dry_run_receipt_path=dry_run_receipt_path,
275
+ receipt_id=receipt_id,
276
+ published_count=published_count,
277
+ link_trigger_context_path=link_trigger_context_path,
278
+ link_receipt_id=link_receipt_id,
279
+ link_changed_files=list(link_changed_files or []),
280
+ error_context=typed_error_context,
281
+ ).to_payload()
282
+
283
+
284
+ def _process_chats_runtime_error_context(
285
+ error_context: JsonObject,
286
+ *,
287
+ fallback_artifact: str,
288
+ ) -> ProcessChatsErrorContext:
289
+ """Conform broad publish diagnostics to the strict FSM error context."""
290
+
291
+ fields = _ProcessChatsRuntimeObservationErrorFields.model_validate(error_context)
292
+ root_cause = fields.root_cause or fields.blocked_reason or "process_chats_blocked"
293
+ next_action = fields.next_action or fields.suggested_fix or "Retomar /mednotes:process-chats pela rota oficial."
294
+ return ProcessChatsErrorContext(
295
+ root_cause=root_cause,
296
+ affected_artifact=fields.affected_artifact or fallback_artifact or "process-chats",
297
+ retry_scope=fields.retry_scope or "process_chats_official_retry",
298
+ next_action=next_action,
299
+ )
300
+
301
+
302
+ def _load_publish_manifest(path: Path) -> PublishManifest:
303
+ try:
304
+ return PublishManifest.model_validate(_load_manifest(path))
305
+ except PydanticValidationError as exc:
306
+ raise contract_error(exc, prefix="publish manifest") from exc
307
+
308
+
309
+ def _paths_match(left: str, right: Path) -> bool:
310
+ left_path = _path(left)
311
+ try:
312
+ return left_path.resolve() == right.resolve()
313
+ except OSError:
314
+ return str(left_path) == str(right)
315
+
316
+
317
+ def _same_path(left: Path, right: Path) -> bool:
318
+ try:
319
+ return left.resolve() == right.resolve()
320
+ except OSError:
321
+ return str(left) == str(right)
322
+
323
+
324
+ def _note_target_key(path: Path) -> str:
325
+ return normalize_key(path.stem)
326
+
327
+
328
+ def _wiki_note_targets(wiki_dir: Path) -> dict[str, list[Path]]:
329
+ raw_targets = note_target_index(wiki_dir, as_relative=False)
330
+ return {key: [path for path in values if isinstance(path, Path)] for key, values in raw_targets.items()}
331
+
332
+
333
+ def _display_path(path: Path, wiki_dir: Path) -> str:
334
+ try:
335
+ return path.relative_to(wiki_dir).as_posix()
336
+ except ValueError:
337
+ return str(path)
338
+
339
+
340
+ def _validate_normalized_target_available(
341
+ target: Path,
342
+ wiki_dir: Path,
343
+ existing_targets: dict[str, list[Path]],
344
+ reserved_targets: dict[str, Path],
345
+ ) -> None:
346
+ target_key = _note_target_key(target)
347
+ reserved = reserved_targets.get(target_key)
348
+ if reserved is not None and not _same_path(reserved, target):
349
+ raise CollisionError(
350
+ "Target note would duplicate another note in this publish batch after "
351
+ f"Obsidian target normalization: {_display_path(target, wiki_dir)} conflicts with "
352
+ f"{_display_path(reserved, wiki_dir)}"
353
+ )
354
+
355
+ conflicts = [path for path in existing_targets.get(target_key, []) if not _same_path(path, target)]
356
+ if conflicts:
357
+ conflict_list = ", ".join(_display_path(path, wiki_dir) for path in conflicts[:5])
358
+ extra = "" if len(conflicts) <= 5 else f" and {len(conflicts) - 5} more"
359
+ raise CollisionError(
360
+ "Target note would duplicate an existing Obsidian target after accent/case "
361
+ f"normalization: {_display_path(target, wiki_dir)} conflicts with {conflict_list}{extra}. "
362
+ "Use the existing note or merge/rename before publishing."
363
+ )
364
+
365
+
366
+ def _manifest_note_count(manifest: PublishManifest) -> int:
367
+ return sum(len(batch.notes) for batch in manifest.batches)
368
+
369
+
370
+ def _staged_manifest_counts(data: JsonObject, *, pending_note: bool = False) -> tuple[int, int]:
371
+ """Count a manifest being staged before it is valid enough to publish."""
372
+ batches = data["batches"] if "batches" in data else None
373
+ if not isinstance(batches, list):
374
+ raise ValidationError("publish manifest must use canonical batches[]")
375
+ note_count = 1 if pending_note else 0
376
+ for batch in batches:
377
+ if not isinstance(batch, dict):
378
+ raise ValidationError("Each manifest batch must be an object")
379
+ notes = batch["notes"] if "notes" in batch else None
380
+ if not isinstance(notes, list):
381
+ raise ValidationError("manifest batch notes must be a list")
382
+ note_count += len(notes)
383
+ return note_count, len(batches)
384
+
385
+
386
+ def _require_no_pending_human_decision(manifest: PublishManifest, *, label: str) -> None:
387
+ if manifest.pending_human_decision():
388
+ raise ValidationError(
389
+ f"human_decision_required: {label} contains pending human_decision_packet; "
390
+ "resolve the decision, update the manifest/note_plan, and rerun publish-batch --dry-run."
391
+ )
392
+
393
+
394
+ def _raw_files_from_summary(summary: JsonObject | RawCoverageSummary, primary_raw_file: Path) -> list[Path]:
395
+ values = summary.raw_files if isinstance(summary, RawCoverageSummary) else (
396
+ summary["raw_files"] if "raw_files" in summary else None
397
+ )
398
+ raw_files: list[Path] = []
399
+ if isinstance(values, list) and values:
400
+ raw_files = [_path(str(value)) for value in values if str(value).strip()]
401
+ else:
402
+ raw_files = [primary_raw_file]
403
+ seen: set[str] = set()
404
+ unique: list[Path] = []
405
+ for path in raw_files:
406
+ key = str(path)
407
+ if key in seen:
408
+ continue
409
+ seen.add(key)
410
+ if not path.exists():
411
+ raise MissingPathError(f"Raw file not found: {path}")
412
+ unique.append(path)
413
+ unique = unique or [primary_raw_file]
414
+ if not any(_same_path(path, primary_raw_file) for path in unique):
415
+ raise ValidationError("provenance_gap: raw_files must include the primary raw_file")
416
+ return unique
417
+
418
+
419
+ def _raw_files_from_batch(batch: PublishManifestBatch, primary_raw_file: Path) -> list[Path]:
420
+ if not batch.raw_files:
421
+ return [primary_raw_file]
422
+ return _raw_files_from_summary(
423
+ JsonObjectAdapter.validate_python({"raw_files": batch.raw_files}),
424
+ primary_raw_file,
425
+ )
426
+
427
+
428
+ def _prepare_note_content(
429
+ content: str,
430
+ *,
431
+ title: str,
432
+ raw_files: list[Path],
433
+ coverage_summary: JsonObject | None = None,
434
+ ) -> str:
435
+ try:
436
+ result = _apply_note_provenance_from_raw_files(
437
+ content,
438
+ raw_files=raw_files,
439
+ title=title,
440
+ coverage_summary=coverage_summary,
441
+ )
442
+ except FrontmatterYamlUnavailable as exc:
443
+ raise ValidationError(f"{exc.blocked_reason}: {exc.next_action}") from exc
444
+ except ValueError as exc:
445
+ raise ValidationError(f"chat_provenance_invalid: {exc}") from exc
446
+ return str(result["text"])
447
+
448
+
449
+ def _artifact_batch_for_raw_files(
450
+ artifact_note_inputs: list[dict[str, str]],
451
+ *,
452
+ raw_files: list[Path],
453
+ artifact_dir: Path | None,
454
+ ) -> JsonObject:
455
+ if len(raw_files) == 1:
456
+ return validate_artifact_batch(
457
+ artifact_note_inputs,
458
+ raw_file=raw_files[0],
459
+ artifact_dir=artifact_dir,
460
+ )
461
+ reports = [
462
+ validate_artifact_batch(
463
+ artifact_note_inputs,
464
+ raw_file=raw_file,
465
+ artifact_dir=artifact_dir,
466
+ )
467
+ for raw_file in raw_files
468
+ ]
469
+ report_fields = [_ArtifactBatchValidationFields.model_validate(report) for report in reports]
470
+ return JsonObjectAdapter.validate_python({
471
+ "schema": "medical-notes-workbench.artifact-html-validation.multi-source.v1",
472
+ "scope": "multi_source_raw_chat_batch",
473
+ "required": any(report.required for report in report_fields),
474
+ "raw_file_count": len(raw_files),
475
+ "manifest_count": sum(report.manifest_count for report in report_fields),
476
+ "artifact_count": sum(report.artifact_count for report in report_fields),
477
+ "covered_artifact_count": sum(report.covered_artifact_count for report in report_fields),
478
+ "missing_artifact_count": sum(report.missing_artifact_count for report in report_fields),
479
+ "reports": reports,
480
+ "errors": [],
481
+ })
482
+
483
+
484
+ def _batch_for_stage(data: JsonObject, raw_file: Path) -> JsonObject:
485
+ raw_text = str(raw_file)
486
+ batches = data["batches"] if "batches" in data else None
487
+ if not isinstance(batches, list):
488
+ raise ValidationError("publish manifest must use canonical batches[]")
489
+ for batch in batches:
490
+ if not isinstance(batch, dict):
491
+ raise ValidationError("Each manifest batch must be an object")
492
+ if "raw_file" in batch and _paths_match(str(batch["raw_file"]), raw_file):
493
+ notes = batch["notes"] if "notes" in batch else None
494
+ if not isinstance(notes, list):
495
+ raise ValidationError("manifest batch notes must be a list")
496
+ validated = JsonObjectAdapter.validate_python(batch)
497
+ batch.clear()
498
+ batch.update(validated)
499
+ return batch
500
+ new_batch: JsonObject = {"raw_file": raw_text, "notes": []}
501
+ batches.append(new_batch)
502
+ return new_batch
503
+
504
+
505
+ def plan_publish_batch(
506
+ manifest: PublishManifest,
507
+ config: MedConfig,
508
+ collision: str,
509
+ allow_new_taxonomy_leaf: bool = True,
510
+ require_coverage: bool = True,
511
+ ) -> list[JsonObject]:
512
+ planned_batches: list[JsonObject] = []
513
+ reserved: set[Path] = set()
514
+ reserved_targets: dict[str, Path] = {}
515
+ existing_targets = _wiki_note_targets(config.wiki_dir)
516
+ _require_no_pending_human_decision(manifest, label="manifest")
517
+ for batch in manifest.batches:
518
+ batch_payload = batch.to_payload()
519
+ raw_file = _path(batch.raw_file)
520
+ if not raw_file.exists():
521
+ raise MissingPathError(f"Raw file not found: {raw_file}")
522
+ notes: list[JsonObject] = []
523
+ coverage_path_value = batch.coverage_path
524
+ if require_coverage and not coverage_path_value:
525
+ raise ValidationError(
526
+ "Manifest batch missing coverage_path; create an exhaustive raw coverage inventory "
527
+ "and stage notes with stage-note --coverage <coverage.json>"
528
+ )
529
+ coverage_structure: JsonObject | None = None
530
+ raw_files = _raw_files_from_batch(batch, raw_file)
531
+ if coverage_path_value:
532
+ coverage_path = _path(coverage_path_value)
533
+ coverage_structure = validate_raw_coverage_structure(
534
+ coverage_path,
535
+ raw_file,
536
+ require_triage_note_plan=require_coverage,
537
+ )
538
+ raw_files = _raw_files_from_summary(coverage_structure, raw_file)
539
+ artifact_note_inputs: list[dict[str, str]] = []
540
+ for item in batch.notes:
541
+ content_path = _path(item.content_path)
542
+ if not content_path.exists():
543
+ raise MissingPathError(f"Content file not found: {content_path}")
544
+ content = content_path.read_text(encoding="utf-8")
545
+ prepared_content = _prepare_note_content(
546
+ content,
547
+ title=item.title,
548
+ raw_files=raw_files,
549
+ coverage_summary=coverage_structure,
550
+ )
551
+ validate_wiki_note_contract(prepared_content, title=item.title, raw_file=raw_file)
552
+ artifact_validation = validate_note_artifacts(
553
+ prepared_content,
554
+ raw_file=raw_file,
555
+ artifact_dir=config.artifact_dir,
556
+ )
557
+ artifact_note_inputs.append(
558
+ {
559
+ "title": item.title,
560
+ "content_path": str(content_path),
561
+ "content": prepared_content,
562
+ }
563
+ )
564
+ target, taxonomy_resolution = resolve_target_for_note(
565
+ config.wiki_dir,
566
+ item.taxonomy,
567
+ item.title,
568
+ allow_new_taxonomy_leaf=allow_new_taxonomy_leaf,
569
+ )
570
+ target = resolve_collision(target, collision, reserved)
571
+ _validate_normalized_target_available(target, config.wiki_dir, existing_targets, reserved_targets)
572
+ reserved.add(target)
573
+ reserved_targets[_note_target_key(target)] = target
574
+ notes.append(
575
+ {
576
+ "taxonomy": taxonomy_resolution.taxonomy,
577
+ "taxonomy_requested": taxonomy_resolution.requested_taxonomy,
578
+ "taxonomy_canonicalized": list(taxonomy_resolution.canonicalized),
579
+ "taxonomy_new_dirs": list(taxonomy_resolution.new_dirs),
580
+ "title": item.title,
581
+ "content_path": str(content_path),
582
+ "target_path": str(target),
583
+ "artifact_validation": artifact_validation,
584
+ }
585
+ )
586
+ planned_batch: JsonObject = {
587
+ "raw_file": str(raw_file),
588
+ "raw_files": [str(path) for path in raw_files],
589
+ "notes": notes,
590
+ "artifact_validation": _artifact_batch_for_raw_files(
591
+ artifact_note_inputs,
592
+ raw_files=raw_files,
593
+ artifact_dir=config.artifact_dir,
594
+ ),
595
+ }
596
+ if coverage_path_value:
597
+ coverage_path = _path(coverage_path_value)
598
+ planned_batch["coverage_path"] = str(coverage_path)
599
+ coverage_summary = validate_raw_coverage(
600
+ coverage_path,
601
+ raw_file,
602
+ [str(note["title"]) for note in notes],
603
+ require_triage_note_plan=require_coverage,
604
+ )
605
+ raw_files = _raw_files_from_summary(coverage_summary, raw_file)
606
+ planned_batch["raw_files"] = [str(path) for path in raw_files]
607
+ require_compatible_batch_state(
608
+ batch_payload,
609
+ coverage_summary,
610
+ left_label="manifest batch",
611
+ right_label="coverage inventory",
612
+ )
613
+ planned_batch["coverage"] = coverage_summary
614
+ coverage_state_basis = {**coverage_summary, **batch_payload}
615
+ if not coverage_state_basis.get("coverage_hash"):
616
+ coverage_state_basis["coverage_hash"] = file_sha256(coverage_path)
617
+ batch_state = batch_state_from(coverage_state_basis)
618
+ if batch_state:
619
+ planned_batch["batch_state"] = batch_state
620
+ planned_batches.append(planned_batch)
621
+ return planned_batches
622
+
623
+
624
+ def taxonomy_new_leaf_authorization_from_plan(plan: Sequence[JsonObject]) -> JsonObject:
625
+ notes: list[JsonObject] = []
626
+ for batch in plan:
627
+ for item in batch.get("notes", []):
628
+ new_dirs = [str(value) for value in item.get("taxonomy_new_dirs", [])]
629
+ if not new_dirs:
630
+ continue
631
+ notes.append(
632
+ {
633
+ "target_path": str(item["target_path"]),
634
+ "taxonomy": str(item["taxonomy"]),
635
+ "taxonomy_requested": str(item.get("taxonomy_requested", "")),
636
+ "taxonomy_new_dirs": new_dirs,
637
+ }
638
+ )
639
+ return JsonObjectAdapter.validate_python({
640
+ "required": bool(notes),
641
+ "authorized_by_dry_run_receipt": bool(notes),
642
+ "note_count": len(notes),
643
+ "notes": notes,
644
+ })
645
+
646
+
647
+ def _raw_coverage_plan_batches(plan: Sequence[object]) -> list[RawCoveragePlanBatch]:
648
+ """Validate publish's planned-batch projection before deriving coverage truth."""
649
+
650
+ try:
651
+ return [RawCoveragePlanBatch.model_validate(batch) for batch in plan]
652
+ except PydanticValidationError as exc:
653
+ raise contract_error(exc, prefix="publish.raw_coverage_plan_batch_invalid") from exc
654
+
655
+
656
+ def coverage_summary_from_plan(plan: Sequence[object]) -> JsonObject:
657
+ return coverage_summary_from_batches(_raw_coverage_plan_batches(plan)).to_payload()
658
+
659
+
660
+ def taxonomy_new_leaf_authorization_for_manifest(
661
+ manifest: Path,
662
+ config: MedConfig,
663
+ *,
664
+ collision: str = "abort",
665
+ allow_new_taxonomy_leaf: bool = True,
666
+ require_coverage: bool = True,
667
+ ) -> JsonObject:
668
+ publish_manifest = _load_publish_manifest(manifest)
669
+ if require_coverage:
670
+ publish_manifest.require_coverage()
671
+ plan = plan_publish_batch(
672
+ publish_manifest,
673
+ config,
674
+ collision,
675
+ allow_new_taxonomy_leaf=allow_new_taxonomy_leaf,
676
+ require_coverage=require_coverage,
677
+ )
678
+ return taxonomy_new_leaf_authorization_from_plan(plan)
679
+
680
+
681
+ def _missing_parent_dirs_before_write(target: Path, wiki_dir: Path) -> list[Path]:
682
+ missing: list[Path] = []
683
+ current = target.parent
684
+ while current != wiki_dir and not current.exists():
685
+ missing.append(current)
686
+ current = current.parent
687
+ return missing
688
+
689
+
690
+ def _unique_paths(paths: list[Path]) -> list[Path]:
691
+ seen: set[str] = set()
692
+ unique: list[Path] = []
693
+ for path in paths:
694
+ key = str(path)
695
+ if key in seen:
696
+ continue
697
+ seen.add(key)
698
+ unique.append(path)
699
+ return unique
700
+
701
+
702
+ def _rollback_publish_failure(
703
+ created_paths: list[Path],
704
+ raw_originals: dict[Path, str],
705
+ raw_restore_order: list[Path],
706
+ parent_dirs_to_prune: list[Path],
707
+ ) -> dict[str, list[str]]:
708
+ rollback = {
709
+ "deleted_notes": [],
710
+ "restored_raw_files": [],
711
+ "removed_dirs": [],
712
+ "rollback_errors": [],
713
+ }
714
+
715
+ for path in reversed(created_paths):
716
+ try:
717
+ if path.exists():
718
+ path.unlink()
719
+ rollback["deleted_notes"].append(str(path))
720
+ except Exception as exc: # pragma: no cover - exercised only on OS-level rollback failures
721
+ rollback["rollback_errors"].append(f"delete note {path}: {exc}")
722
+
723
+ for raw_file in reversed(_unique_paths(raw_restore_order)):
724
+ original = raw_originals.get(raw_file)
725
+ if original is None:
726
+ continue
727
+ try:
728
+ atomic_write_text(raw_file, original)
729
+ rollback["restored_raw_files"].append(str(raw_file))
730
+ except Exception as exc: # pragma: no cover - exercised only on OS-level rollback failures
731
+ rollback["rollback_errors"].append(f"restore raw chat {raw_file}: {exc}")
732
+
733
+ dirs = sorted(_unique_paths(parent_dirs_to_prune), key=lambda item: len(item.parts), reverse=True)
734
+ for directory in dirs:
735
+ try:
736
+ if directory.exists() and directory.is_dir() and not any(directory.iterdir()):
737
+ directory.rmdir()
738
+ rollback["removed_dirs"].append(str(directory))
739
+ except Exception as exc: # pragma: no cover - exercised only on OS-level rollback failures
740
+ rollback["rollback_errors"].append(f"remove directory {directory}: {exc}")
741
+
742
+ return rollback
743
+
744
+
745
+ def _format_rollback_message(exc: Exception, rollback: dict[str, list[str]]) -> str:
746
+ return (
747
+ "Batch publish failed; automatic rollback attempted. "
748
+ f"Original error: {exc}. "
749
+ f"Deleted notes: {rollback['deleted_notes']}. "
750
+ f"Restored raw chats: {rollback['restored_raw_files']}. "
751
+ f"Removed empty directories: {rollback['removed_dirs']}. "
752
+ f"Rollback errors: {rollback['rollback_errors']}."
753
+ )
754
+
755
+
756
+ def publish_batch(
757
+ manifest: Path,
758
+ config: MedConfig,
759
+ collision: str = "abort",
760
+ dry_run: bool = False,
761
+ backup: bool = False,
762
+ allow_new_taxonomy_leaf: bool = True,
763
+ require_coverage: bool = True,
764
+ ) -> JsonObject:
765
+ result = publish_batch_operation_result(
766
+ manifest,
767
+ config,
768
+ collision=collision,
769
+ dry_run=dry_run,
770
+ backup=backup,
771
+ allow_new_taxonomy_leaf=allow_new_taxonomy_leaf,
772
+ require_coverage=require_coverage,
773
+ )
774
+ return process_chats_fsm_payload_from_publish_result(
775
+ result,
776
+ run_id=_process_chats_run_id(manifest, result),
777
+ version_control_safety=_process_chats_version_control_safety(result, applying=not dry_run),
778
+ )
779
+
780
+
781
+ def publish_batch_operation_result(
782
+ manifest: Path,
783
+ config: MedConfig,
784
+ collision: str = "abort",
785
+ dry_run: bool = False,
786
+ backup: bool = False,
787
+ allow_new_taxonomy_leaf: bool = True,
788
+ require_coverage: bool = True,
789
+ ) -> JsonObject:
790
+ backup = False
791
+ try:
792
+ typed_manifest = _load_publish_manifest(manifest)
793
+ if require_coverage:
794
+ typed_manifest.require_coverage()
795
+ except (PydanticValidationError, ValidationError, ValueError) as exc:
796
+ blocked_reason = "coverage_path_missing" if "coverage_path" in str(exc) else "manifest_invalid"
797
+ return _blocked_publish_contract_receipt(
798
+ manifest=manifest,
799
+ root_cause="publish.manifest_contract_invalid",
800
+ blocked_reason=blocked_reason,
801
+ error_summary=str(exc),
802
+ next_action="Recriar manifest, note_plan e coverage pela rota oficial antes de publicar.",
803
+ )
804
+ if not dry_run:
805
+ try:
806
+ ensure_markdown_query_available(
807
+ wiki_dir=config.wiki_dir,
808
+ raw_dir=config.raw_dir,
809
+ state_dir=config.state_dir,
810
+ )
811
+ except MarkdownQueryUnavailable as exc:
812
+ error_context = {
813
+ "blocked_reason": exc.blocked_reason,
814
+ "root_cause": "markdown_query_index_unavailable",
815
+ "affected_artifact": "markdown_query_index",
816
+ "error_summary": str(exc),
817
+ "suggested_fix": exc.next_action,
818
+ "next_action": exc.next_action,
819
+ "retry_scope": "setup_markdown_query_index_then_retry",
820
+ "details": exc.payload,
821
+ }
822
+ return annotate_payload(
823
+ {
824
+ **markdown_query_blocked_payload(
825
+ phase="publish_apply",
826
+ required_inputs=PUBLISH_REQUIRED_INPUTS,
827
+ ),
828
+ "error_context": error_context,
829
+ "publish_receipt": build_publish_receipt_payload(
830
+ status="blocked",
831
+ batch_id=_publish_batch_id(manifest),
832
+ published_count=0,
833
+ skipped_count=0,
834
+ items=[],
835
+ next_action=exc.next_action,
836
+ error_context=error_context,
837
+ ),
838
+ "runtime_observation": _process_chats_runtime_observation_payload(
839
+ source_state=ProcessChatsState.PUBLISH_APPLY_REQUESTED,
840
+ blocked=True,
841
+ publish_stale_receipt=True,
842
+ reason_code=exc.blocked_reason,
843
+ next_action=exc.next_action,
844
+ manifest_path=str(manifest),
845
+ receipt_id=_publish_batch_id(manifest),
846
+ error_context=error_context,
847
+ ),
848
+ },
849
+ phase="publish_apply",
850
+ status="blocked",
851
+ blocked_reason=exc.blocked_reason,
852
+ next_action=exc.next_action,
853
+ required_inputs=PUBLISH_REQUIRED_INPUTS,
854
+ human_decision_required=False,
855
+ )
856
+ plan = plan_publish_batch(
857
+ typed_manifest,
858
+ config,
859
+ collision,
860
+ allow_new_taxonomy_leaf=allow_new_taxonomy_leaf,
861
+ require_coverage=require_coverage,
862
+ )
863
+ new_leaf_authorization = taxonomy_new_leaf_authorization_from_plan(plan)
864
+ created: list[str] = []
865
+ created_paths: list[Path] = []
866
+ parent_dirs_to_prune: list[Path] = []
867
+ raw_restore_order: list[Path] = []
868
+ raw_updates: list[JsonObject] = []
869
+ if dry_run:
870
+ return annotate_payload({
871
+ "dry_run": True,
872
+ "backup": backup,
873
+ "manifest": str(manifest),
874
+ "manifest_hash": file_sha256(manifest),
875
+ "allow_new_taxonomy_leaf": allow_new_taxonomy_leaf,
876
+ "require_coverage": require_coverage,
877
+ "batch_state": [batch["batch_state"] for batch in plan if batch.get("batch_state")],
878
+ "coverage_summary": coverage_summary_from_plan(plan),
879
+ "new_taxonomy_leaf_authorization": new_leaf_authorization,
880
+ "planned_batches": plan,
881
+ "created": [],
882
+ "raw_updates": [],
883
+ "publish_receipt": build_publish_receipt_payload(
884
+ status="ready_to_publish",
885
+ batch_id=_publish_batch_id(manifest),
886
+ published_count=0,
887
+ skipped_count=0,
888
+ items=[],
889
+ next_action="Revisar o plano e então rodar publish-batch sem --dry-run com o mesmo manifest.",
890
+ ),
891
+ "runtime_observation": _process_chats_runtime_observation_payload(
892
+ source_state=ProcessChatsState.STAGING_MANIFEST_READY,
893
+ preview_ready=True,
894
+ reason_code="ready_to_publish",
895
+ next_action="Revisar o plano e então rodar publish-batch sem --dry-run com o mesmo manifest.",
896
+ manifest_path=str(manifest),
897
+ dry_run_receipt_path=str(manifest),
898
+ receipt_id=_publish_batch_id(manifest),
899
+ ),
900
+ },
901
+ phase="publish_dry_run",
902
+ status="ready_to_publish",
903
+ next_action="Revisar o plano e então rodar publish-batch sem --dry-run com o mesmo manifest.",
904
+ required_inputs=PUBLISH_REQUIRED_INPUTS,
905
+ )
906
+
907
+ raw_files_to_update = _unique_paths(
908
+ [
909
+ Path(raw_file)
910
+ for batch in plan
911
+ for raw_file in (batch.get("raw_files") or [batch["raw_file"]])
912
+ ]
913
+ )
914
+ raw_originals = {
915
+ raw_file: raw_file.read_text(encoding="utf-8")
916
+ for raw_file in raw_files_to_update
917
+ }
918
+
919
+ try:
920
+ for batch in plan:
921
+ batch_raw_files = [Path(path) for path in (batch.get("raw_files") or [batch["raw_file"]])]
922
+ coverage_value = batch.get("coverage")
923
+ coverage_summary = JsonObjectAdapter.validate_python(coverage_value) if isinstance(coverage_value, dict) else None
924
+ for item in batch["notes"]:
925
+ content = Path(item["content_path"]).read_text(encoding="utf-8")
926
+ prepared_content = _prepare_note_content(
927
+ content,
928
+ title=str(item["title"]),
929
+ raw_files=batch_raw_files,
930
+ coverage_summary=coverage_summary,
931
+ )
932
+ target_path = Path(item["target_path"])
933
+ parent_dirs_to_prune.extend(_missing_parent_dirs_before_write(target_path, config.wiki_dir))
934
+ write_new_note(target_path, prepared_content, create_parent=bool(item.get("taxonomy_new_dirs")))
935
+ created_paths.append(target_path)
936
+ created.append(item["target_path"])
937
+ for raw_file in raw_files_to_update:
938
+ raw_restore_order.append(raw_file)
939
+ raw_updates.append(
940
+ mutate_raw_frontmatter(
941
+ raw_file,
942
+ {"status": "processado", "processed_at": _now_iso()},
943
+ dry_run=False,
944
+ backup=backup,
945
+ )
946
+ )
947
+ except Exception as exc:
948
+ rollback = _rollback_publish_failure(
949
+ created_paths,
950
+ raw_originals,
951
+ raw_restore_order,
952
+ parent_dirs_to_prune,
953
+ )
954
+ raise MedOpsError(_format_rollback_message(exc, rollback)) from exc
955
+
956
+ return annotate_payload({
957
+ "dry_run": False,
958
+ "backup": backup,
959
+ "manifest": str(manifest),
960
+ "manifest_hash": file_sha256(manifest),
961
+ "allow_new_taxonomy_leaf": allow_new_taxonomy_leaf,
962
+ "require_coverage": require_coverage,
963
+ "batch_state": [batch["batch_state"] for batch in plan if batch.get("batch_state")],
964
+ "coverage_summary": coverage_summary_from_plan(plan),
965
+ "created": created,
966
+ "raw_updates": raw_updates,
967
+ "created_count": len(created),
968
+ "processed_raw_count": len(raw_updates),
969
+ "new_taxonomy_leaf_authorization": {
970
+ **new_leaf_authorization,
971
+ "authorized_by_dry_run_receipt": new_leaf_authorization["required"],
972
+ },
973
+ "publish_receipt": build_publish_receipt_payload(
974
+ status="published",
975
+ batch_id=_publish_batch_id(manifest),
976
+ published_count=len(created),
977
+ skipped_count=0,
978
+ items=[{"path": path, "status": "published"} for path in created],
979
+ next_action=(
980
+ "Rodar run-linker --diagnose para gerar o diagnóstico do grafo e, se seguro, "
981
+ "run-linker --apply --diagnosis <link-diagnosis.json>."
982
+ ),
983
+ ),
984
+ "runtime_observation": _process_chats_runtime_observation_payload(
985
+ source_state=ProcessChatsState.PUBLISH_APPLY_REQUESTED,
986
+ publish_completed=True,
987
+ reason_code="published",
988
+ next_action=(
989
+ "Rodar run-linker --diagnose para gerar o diagnóstico do grafo e, se seguro, "
990
+ "run-linker --apply --diagnosis <link-diagnosis.json>."
991
+ ),
992
+ manifest_path=str(manifest),
993
+ receipt_id=_publish_batch_id(manifest),
994
+ published_count=len(created),
995
+ ),
996
+ },
997
+ phase="publish_apply",
998
+ status="published",
999
+ next_action=(
1000
+ "Rodar run-linker --diagnose para gerar o diagnóstico do grafo e, se seguro, "
1001
+ "run-linker --apply --diagnosis <link-diagnosis.json>."
1002
+ ),
1003
+ required_inputs=PUBLISH_REQUIRED_INPUTS,
1004
+ )
1005
+
1006
+
1007
+ def _process_chats_run_id(manifest: Path, result: JsonObject) -> str:
1008
+ fields = _ProcessChatsPublishSafetyFields.model_validate(result)
1009
+ basis = fields.manifest_hash
1010
+ if not basis:
1011
+ try:
1012
+ basis = file_sha256(manifest)
1013
+ except OSError:
1014
+ basis = manifest.name
1015
+ safe = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in basis)[:48].strip("-")
1016
+ return f"process-chats-{safe or 'run'}"
1017
+
1018
+
1019
+ def _process_chats_version_control_safety(result: JsonObject, *, applying: bool) -> JsonObject:
1020
+ fields = _ProcessChatsPublishSafetyFields.model_validate(result)
1021
+ mutated = applying and (bool(fields.created) or fields.processed_raw_count > 0)
1022
+ return {
1023
+ "resource_guard_active": mutated,
1024
+ "run_start_seen": mutated,
1025
+ "run_finish_seen": mutated,
1026
+ "restore_point_before": "vault-guard" if mutated else "",
1027
+ "restore_point_after": "vault-guard" if mutated else "",
1028
+ "sync_status": "not_checked",
1029
+ "backup_online": "not_checked",
1030
+ "direct_mutation_forbidden": True,
1031
+ "mutation_without_guard": False,
1032
+ "rollback_declared": mutated,
1033
+ "no_resource_mutation": not mutated,
1034
+ }
1035
+
1036
+
1037
+ def stage_note(
1038
+ manifest: Path,
1039
+ raw_file: Path,
1040
+ taxonomy: str,
1041
+ title: str,
1042
+ content_path: Path,
1043
+ dry_run: bool = False,
1044
+ config: MedConfig | None = None,
1045
+ allow_new_taxonomy_leaf: bool = True,
1046
+ coverage_path: Path | None = None,
1047
+ ) -> JsonObject:
1048
+ taxonomy_resolution = (
1049
+ resolve_taxonomy(config.wiki_dir, taxonomy, title=title, allow_new_leaf=allow_new_taxonomy_leaf)
1050
+ if config is not None
1051
+ else None
1052
+ )
1053
+ canonical_taxonomy = taxonomy_resolution.taxonomy if taxonomy_resolution else "/".join(normalize_taxonomy(taxonomy))
1054
+ _validate_taxonomy_not_title(tuple(canonical_taxonomy.split("/")), title)
1055
+ filename = safe_title(title)
1056
+ if taxonomy_resolution is not None and config is not None:
1057
+ target = config.wiki_dir.joinpath(*taxonomy_resolution.parts, f"{filename}.md")
1058
+ _validate_normalized_target_available(target, config.wiki_dir, _wiki_note_targets(config.wiki_dir), {})
1059
+ if not raw_file.exists():
1060
+ raise MissingPathError(f"Raw file not found: {raw_file}")
1061
+ if not content_path.exists():
1062
+ raise MissingPathError(f"Content file not found: {content_path}")
1063
+ content = content_path.read_text(encoding="utf-8")
1064
+ if manifest.exists():
1065
+ data = _load_manifest(manifest)
1066
+ _load_publish_manifest(manifest)
1067
+ else:
1068
+ data = {"schema": "medical-notes-workbench.publish-manifest.v1", "batches": []}
1069
+ item = {
1070
+ "taxonomy": canonical_taxonomy,
1071
+ "title": title,
1072
+ "content_path": str(content_path),
1073
+ "safe_filename": f"{filename}.md",
1074
+ }
1075
+ batch = _batch_for_stage(data, raw_file)
1076
+ notes = batch["notes"]
1077
+ coverage_summary: JsonObject | None = None
1078
+ raw_files = [raw_file]
1079
+ if coverage_path is not None:
1080
+ coverage_summary = validate_raw_coverage_structure(coverage_path, raw_file)
1081
+ existing_coverage = batch.get("coverage_path")
1082
+ if existing_coverage and not _paths_match(str(existing_coverage), coverage_path):
1083
+ raise ValidationError(
1084
+ f"Manifest batch already has a different coverage_path: {existing_coverage}"
1085
+ )
1086
+ batch["coverage_path"] = str(coverage_path)
1087
+ raw_files = _raw_files_from_summary(coverage_summary, raw_file)
1088
+ batch["raw_files"] = [str(path) for path in raw_files]
1089
+ merge_batch_state(
1090
+ batch,
1091
+ coverage_summary,
1092
+ target_label="manifest batch",
1093
+ source_label="coverage inventory",
1094
+ )
1095
+ prepared_content = _prepare_note_content(
1096
+ content,
1097
+ title=title,
1098
+ raw_files=raw_files,
1099
+ coverage_summary=coverage_summary,
1100
+ )
1101
+ validate_wiki_note_contract(prepared_content, title=title, raw_file=raw_file)
1102
+ artifact_validation = validate_note_artifacts(
1103
+ prepared_content,
1104
+ raw_file=raw_file,
1105
+ artifact_dir=config.artifact_dir if config is not None else None,
1106
+ )
1107
+ if not dry_run:
1108
+ manifest.parent.mkdir(parents=True, exist_ok=True)
1109
+ notes.append(item)
1110
+ atomic_write_text(manifest, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
1111
+ note_count, batch_count = _staged_manifest_counts(data, pending_note=dry_run)
1112
+ result: JsonObject = {
1113
+ "manifest": str(manifest),
1114
+ "dry_run": dry_run,
1115
+ "staged": item,
1116
+ "artifact_validation": artifact_validation,
1117
+ "note_count": note_count,
1118
+ "batch_count": batch_count,
1119
+ }
1120
+ if coverage_path is not None:
1121
+ result["coverage_path"] = str(coverage_path)
1122
+ if coverage_summary is not None:
1123
+ result["raw_files"] = [str(path) for path in _raw_files_from_summary(coverage_summary, raw_file)]
1124
+ batch_state = batch_state_from(coverage_summary)
1125
+ if batch_state:
1126
+ result["batch_state"] = batch_state
1127
+ if taxonomy_resolution is not None and config is not None:
1128
+ result["taxonomy_resolution"] = taxonomy_resolution.to_json(config.wiki_dir, title=title)
1129
+ return annotate_payload(
1130
+ result,
1131
+ phase="stage_note",
1132
+ status="preview_ready",
1133
+ next_action=(
1134
+ "Adicionar as demais notas/coberturas ao manifest antes do publish-batch --dry-run."
1135
+ if not dry_run
1136
+ else "Se a nota estiver correta, repetir stage-note sem --dry-run."
1137
+ ),
1138
+ required_inputs=["raw_file", "taxonomy", "title", "content_path", "coverage_path"],
1139
+ )