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,877 @@
1
+ """Plan and apply controlled atomicity split/rewrite bundles."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ import unicodedata
9
+ from pathlib import Path
10
+ from types import SimpleNamespace
11
+ from typing import Any
12
+
13
+ from pydantic import ConfigDict, Field
14
+
15
+ from mednotes.domains.wiki.capabilities.notes.note_style import infer_title, split_frontmatter, validate_note_style
16
+ from mednotes.domains.wiki.capabilities.notes.provenance import (
17
+ ChatProvenance,
18
+ apply_note_provenance,
19
+ classify_note_provenance,
20
+ merge_chat_provenance,
21
+ )
22
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
23
+ from mednotes.domains.wiki.common import FileWriteError, MissingPathError, ValidationError, wiki_cli_relative_command
24
+ from mednotes.domains.wiki.contracts.workflow_guardrails import (
25
+ SUBAGENT_OUTPUT_CONTRACT_BLOCKED_REASON,
26
+ subagent_output_contract_errors,
27
+ )
28
+ from mednotes.domains.wiki.flows.link.link_triggers import LINK_TRIGGER_CONTEXT_SCHEMA, write_trigger_context
29
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
30
+
31
+ ATOMICITY_SPLIT_PLAN_SCHEMA = "medical-notes-workbench.atomicity-split-plan.v1"
32
+ ATOMICITY_SPLIT_BUNDLE_SCHEMA = "medical-notes-workbench.atomicity-split-bundle.v1"
33
+ ATOMICITY_SPLIT_RECEIPT_SCHEMA = "medical-notes-workbench.atomicity-split-receipt.v1"
34
+
35
+ ATOMICITY_REASONS = {"non_atomic_note", "one_note_multiple_meanings"}
36
+ ATOMICITY_PROBLEM_CODE = "identity.atomicity.one_note_multiple_meanings"
37
+ SUPPORTED_STRATEGIES = {"rename_source_and_create_notes", "rewrite_source_and_create_notes"}
38
+ IMAGE_FRONTMATTER_KEYS = {"images_enriched", "images_enriched_at", "image_count", "image_sources"}
39
+
40
+
41
+ class _AtomicityDeferredItemFields(ContractModel):
42
+ """Typed fields consumed from deferred vocabulary/atomicity work items."""
43
+
44
+ model_config = ConfigDict(extra="ignore", populate_by_name=True, validate_assignment=True)
45
+
46
+ reason: str = ""
47
+ note_path: str = ""
48
+ work_id: str = ""
49
+ content_hash: str = ""
50
+ semantic_signal: JsonObject = Field(default_factory=dict)
51
+ atomicity_decision: str = ""
52
+
53
+
54
+ def _slug(value: str) -> str:
55
+ normalized = unicodedata.normalize("NFKD", value)
56
+ ascii_text = "".join(char for char in normalized if not unicodedata.combining(char))
57
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_text).strip("-._").lower()
58
+ return slug or "atomicity"
59
+
60
+
61
+ def _load_fix_wiki_plan(path: Path) -> dict[str, Any]:
62
+ try:
63
+ payload = json.loads(path.read_text(encoding="utf-8"))
64
+ except FileNotFoundError as exc:
65
+ raise MissingPathError(f"Fix-wiki plan not found: {path}") from exc
66
+ except json.JSONDecodeError as exc:
67
+ raise ValidationError(f"Invalid fix-wiki plan JSON: {path}: {exc}") from exc
68
+ if not isinstance(payload, dict) or payload.get("schema") != "medical-notes-workbench.fix-wiki-plan.v1":
69
+ raise ValidationError("Atomicity split planning requires medical-notes-workbench.fix-wiki-plan.v1.")
70
+ return payload
71
+
72
+
73
+ def _load_bundle(path: Path) -> dict[str, Any]:
74
+ try:
75
+ payload = json.loads(path.read_text(encoding="utf-8"))
76
+ except FileNotFoundError as exc:
77
+ raise MissingPathError(f"Atomicity split bundle not found: {path}") from exc
78
+ except json.JSONDecodeError as exc:
79
+ raise ValidationError(f"Invalid atomicity split bundle JSON: {path}: {exc}") from exc
80
+ if not isinstance(payload, dict) or payload.get("schema") != ATOMICITY_SPLIT_BUNDLE_SCHEMA:
81
+ raise ValidationError(f"Expected {ATOMICITY_SPLIT_BUNDLE_SCHEMA}: {path}")
82
+ return payload
83
+
84
+
85
+ def _sha256_bytes(path: Path) -> str:
86
+ return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
87
+
88
+
89
+ def _safe_wiki_path(value: str, wiki_dir: Path) -> Path:
90
+ path = Path(value).expanduser()
91
+ if not path.is_absolute():
92
+ path = wiki_dir / path
93
+ try:
94
+ path.resolve(strict=False).relative_to(wiki_dir.resolve(strict=False))
95
+ except ValueError as exc:
96
+ raise ValidationError(f"Atomicity split path is outside wiki_dir: {path}") from exc
97
+ return path
98
+
99
+
100
+ def _wiki_relative(path: Path, wiki_dir: Path) -> str:
101
+ try:
102
+ return path.resolve(strict=False).relative_to(wiki_dir.resolve(strict=False)).as_posix()
103
+ except ValueError:
104
+ return str(path)
105
+
106
+
107
+ def _frontmatter_blocks(frontmatter: str) -> dict[str, str]:
108
+ lines = frontmatter.splitlines(keepends=True)
109
+ blocks: dict[str, str] = {}
110
+ idx = 0
111
+ while idx < len(lines):
112
+ line = lines[idx]
113
+ match = re.match(r"^([A-Za-z0-9_-]+)\s*:", line)
114
+ if not match:
115
+ idx += 1
116
+ continue
117
+ key = match.group(1).strip()
118
+ block_lines = [line]
119
+ idx += 1
120
+ while idx < len(lines):
121
+ next_line = lines[idx]
122
+ if re.match(r"^[A-Za-z0-9_-]+\s*:", next_line):
123
+ break
124
+ block_lines.append(next_line)
125
+ idx += 1
126
+ blocks[key] = "".join(block_lines).strip()
127
+ return blocks
128
+
129
+
130
+ def _image_metadata(text: str) -> dict[str, str]:
131
+ frontmatter, _body = split_frontmatter(text)
132
+ if frontmatter is None:
133
+ return {}
134
+ return {key: value for key, value in _frontmatter_blocks(frontmatter).items() if key in IMAGE_FRONTMATTER_KEYS}
135
+
136
+
137
+ def _chat_provenance_urls(text: str) -> list[str]:
138
+ result: list[str] = []
139
+ seen: set[str] = set()
140
+ state = classify_note_provenance(text)
141
+ for value in [*state.chat_ids, *state.legacy_urls]:
142
+ chat_id = ChatProvenance(str(value)).id
143
+ if not chat_id:
144
+ continue
145
+ url = f"https://gemini.google.com/app/{chat_id}"
146
+ if url not in seen:
147
+ seen.add(url)
148
+ result.append(url)
149
+ return result
150
+
151
+
152
+ def _chat_provenance_from_text(text: str) -> list[ChatProvenance]:
153
+ state = classify_note_provenance(text)
154
+ chats = [ChatProvenance(str(value)) for value in state.chat_ids]
155
+ chats.extend(ChatProvenance(str(value)) for value in state.legacy_urls)
156
+ return merge_chat_provenance(chats)
157
+
158
+
159
+ class _SourceChatLookup:
160
+ def __init__(self, source_path: Path, source_text: str) -> None:
161
+ self.title = infer_title(source_text, source_path)
162
+
163
+ def lookup_chat(self, chat_id: str) -> SimpleNamespace:
164
+ chat = ChatProvenance(chat_id)
165
+ return SimpleNamespace(
166
+ id=chat.id,
167
+ title=self.title or f"Chat {chat.id[:8]}",
168
+ url=f"https://gemini.google.com/app/{chat.id}",
169
+ date_created=chat.date_created,
170
+ date_exported=chat.date_exported,
171
+ )
172
+
173
+
174
+ def _canonicalize_output_provenance(text: str, *, source_path: Path, source_text: str) -> str:
175
+ chats = _chat_provenance_from_text(source_text)
176
+ if not chats:
177
+ return text
178
+ result = apply_note_provenance(
179
+ text,
180
+ chats=chats,
181
+ chat_lookup=_SourceChatLookup(source_path, source_text),
182
+ )
183
+ return str(result["text"])
184
+
185
+
186
+ def _output_items(bundle: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
187
+ replacement = bundle.get("replacement_source")
188
+ if not isinstance(replacement, dict):
189
+ raise ValidationError("atomicity-split-bundle requires replacement_source.")
190
+ items = [("replacement_source", replacement)]
191
+ created = bundle.get("created_notes") if isinstance(bundle.get("created_notes"), list) else []
192
+ for item in created:
193
+ if isinstance(item, dict):
194
+ items.append(("created_notes", item))
195
+ return items
196
+
197
+
198
+ def _output_paths(bundle: dict[str, Any], wiki_dir: Path) -> list[dict[str, Any]]:
199
+ outputs: list[dict[str, Any]] = []
200
+ for kind, item in _output_items(bundle):
201
+ title = str(item.get("title") or "").strip()
202
+ target_path = _safe_wiki_path(str(item.get("target_path") or ""), wiki_dir)
203
+ content_path = Path(str(item.get("content_path") or "")).expanduser()
204
+ outputs.append(
205
+ {
206
+ "kind": kind,
207
+ "title": title,
208
+ "target_path": target_path,
209
+ "content_path": content_path,
210
+ }
211
+ )
212
+ return outputs
213
+
214
+
215
+ def _validation_errors(bundle: dict[str, Any], *, wiki_dir: Path) -> list[JsonObject]:
216
+ errors: list[JsonObject] = []
217
+ if not str(bundle.get("work_id") or "").strip():
218
+ errors.append(
219
+ {
220
+ "code": "work_id_missing",
221
+ "message": "Bundle must copy work_id from the official atomicity work item.",
222
+ }
223
+ )
224
+ strategy = str(bundle.get("strategy") or "")
225
+ if not strategy:
226
+ errors.append({"code": "strategy_missing", "message": "atomicity-split-bundle requires strategy."})
227
+ elif strategy not in SUPPORTED_STRATEGIES:
228
+ errors.append({"code": "unsupported_strategy", "message": f"Unsupported atomicity strategy: {strategy}"})
229
+ try:
230
+ source_path = _safe_wiki_path(str(bundle.get("source_path") or ""), wiki_dir)
231
+ outputs = _output_paths(bundle, wiki_dir)
232
+ except ValidationError as exc:
233
+ code = "output_contract_missing" if "requires replacement_source" in str(exc) else "unsafe_path"
234
+ return [{"code": code, "message": str(exc)}]
235
+
236
+ if not source_path.is_file():
237
+ errors.append({"code": "source_missing", "path": str(source_path), "message": "Source note is missing."})
238
+ return errors
239
+ expected_hash = str(bundle.get("source_hash") or "")
240
+ actual_hash = _sha256_bytes(source_path)
241
+ if not expected_hash:
242
+ errors.append(
243
+ {
244
+ "code": "source_hash_missing",
245
+ "path": str(source_path),
246
+ "actual_hash": actual_hash,
247
+ "message": "Bundle must copy source_hash from the official atomicity work item.",
248
+ }
249
+ )
250
+ elif expected_hash != actual_hash:
251
+ errors.append(
252
+ {
253
+ "code": "source_changed",
254
+ "path": str(source_path),
255
+ "expected_hash": expected_hash,
256
+ "actual_hash": actual_hash,
257
+ "message": "Source note changed since atomicity bundle was produced.",
258
+ }
259
+ )
260
+
261
+ source_text = source_path.read_text(encoding="utf-8")
262
+ source_urls = _chat_provenance_urls(source_text)
263
+ combined_output_text = ""
264
+ image_policy = str(bundle.get("image_metadata_policy") or "")
265
+ for output in outputs:
266
+ title = str(output["title"])
267
+ target_path = output["target_path"]
268
+ content_path = output["content_path"]
269
+ if not title:
270
+ errors.append({"code": "title_missing", "path": str(target_path), "message": "Output title is required."})
271
+ if title and target_path.stem != title:
272
+ errors.append(
273
+ {
274
+ "code": "title_path_mismatch",
275
+ "path": str(target_path),
276
+ "title": title,
277
+ "message": "Output title must match target filename stem.",
278
+ }
279
+ )
280
+ if not content_path.is_file():
281
+ errors.append(
282
+ {"code": "content_missing", "path": str(content_path), "message": "Output Markdown content is missing."}
283
+ )
284
+ continue
285
+ text = _canonicalize_output_provenance(
286
+ content_path.read_text(encoding="utf-8"),
287
+ source_path=source_path,
288
+ source_text=source_text,
289
+ )
290
+ combined_output_text += "\n" + text
291
+ inferred = infer_title(text, target_path)
292
+ if title and inferred != title:
293
+ errors.append(
294
+ {
295
+ "code": "content_title_mismatch",
296
+ "path": str(content_path),
297
+ "title": title,
298
+ "actual_title": inferred,
299
+ "message": "Output H1/title must match bundle title.",
300
+ }
301
+ )
302
+ style_report = validate_note_style(text, title=title or target_path.stem, path=str(target_path))
303
+ for issue in style_report.get("errors", []):
304
+ errors.append(
305
+ {
306
+ "code": "style_contract_failed",
307
+ "path": str(content_path),
308
+ "message": str(issue.get("message") or issue.get("code") or "Wiki style validation failed"),
309
+ "issue": issue,
310
+ }
311
+ )
312
+ if output["kind"] == "created_notes" and _image_metadata(text):
313
+ if not image_policy:
314
+ errors.append(
315
+ {
316
+ "code": "created_note_image_metadata_without_policy",
317
+ "path": str(content_path),
318
+ "message": "Created note includes images_* metadata without explicit image metadata policy.",
319
+ }
320
+ )
321
+ elif image_policy == "do_not_copy_images_to_new_notes":
322
+ errors.append(
323
+ {
324
+ "code": "created_note_image_metadata_forbidden",
325
+ "path": str(content_path),
326
+ "message": "Created note includes images_* metadata despite do_not_copy_images_to_new_notes.",
327
+ }
328
+ )
329
+
330
+ for url in source_urls:
331
+ if url not in combined_output_text:
332
+ errors.append({"code": "provenance_url_missing", "url": url, "message": f"Missing source URL: {url}"})
333
+
334
+ replacement_target = outputs[0]["target_path"] if outputs else source_path
335
+ rewrite_same_target = strategy == "rewrite_source_and_create_notes" and replacement_target == source_path
336
+ if replacement_target.exists() and not rewrite_same_target and replacement_target != source_path:
337
+ errors.append(
338
+ {
339
+ "code": "replacement_target_exists",
340
+ "path": str(replacement_target),
341
+ "message": "Replacement target already exists.",
342
+ }
343
+ )
344
+ for output in outputs[1:]:
345
+ target = output["target_path"]
346
+ if target.exists():
347
+ errors.append({"code": "created_target_exists", "path": str(target), "message": "Created target exists."})
348
+ return errors
349
+
350
+
351
+ def _blocked_receipt(
352
+ *,
353
+ bundle_path: Path,
354
+ wiki_dir: Path,
355
+ blocked_reason: str,
356
+ next_action: str,
357
+ validation_errors: list[JsonObject] | None = None,
358
+ ) -> JsonObject:
359
+ return JsonObjectAdapter.validate_python({
360
+ "schema": ATOMICITY_SPLIT_RECEIPT_SCHEMA,
361
+ "phase": "atomicity_split_apply",
362
+ "status": "blocked",
363
+ "blocked_reason": blocked_reason,
364
+ "next_action": next_action,
365
+ "bundle_path": str(bundle_path),
366
+ "wiki_dir": str(wiki_dir),
367
+ "validation_errors": validation_errors or [],
368
+ "written_count": 0,
369
+ "backup_paths": [],
370
+ "linker_status": "skipped",
371
+ })
372
+
373
+
374
+ def _trigger_context_payload(
375
+ *,
376
+ wiki_dir: Path,
377
+ bundle_path: Path,
378
+ source_path: Path,
379
+ replacement_target: Path,
380
+ created_targets: list[Path],
381
+ ) -> dict[str, Any]:
382
+ changed_notes: list[dict[str, Any]] = []
383
+ if replacement_target == source_path:
384
+ changed_notes.append(
385
+ {
386
+ "change_type": "modified",
387
+ "content_change": "text",
388
+ "path": _wiki_relative(replacement_target, wiki_dir),
389
+ "title": replacement_target.stem,
390
+ "after_hash": _sha256_bytes(replacement_target),
391
+ }
392
+ )
393
+ else:
394
+ changed_notes.append(
395
+ {
396
+ "change_type": "renamed",
397
+ "content_change": "text",
398
+ "old_path": _wiki_relative(source_path, wiki_dir),
399
+ "old_title": source_path.stem,
400
+ "path": _wiki_relative(replacement_target, wiki_dir),
401
+ "title": replacement_target.stem,
402
+ "after_hash": _sha256_bytes(replacement_target),
403
+ }
404
+ )
405
+ for path in created_targets:
406
+ changed_notes.append(
407
+ {
408
+ "change_type": "created",
409
+ "content_change": "text",
410
+ "path": _wiki_relative(path, wiki_dir),
411
+ "title": path.stem,
412
+ "after_hash": _sha256_bytes(path),
413
+ }
414
+ )
415
+ return {
416
+ "schema": LINK_TRIGGER_CONTEXT_SCHEMA,
417
+ "source_workflow": "/mednotes:fix-wiki",
418
+ "batch_id": bundle_path.stem,
419
+ "changed_notes": changed_notes,
420
+ }
421
+
422
+
423
+ def _same_path(left: str, right: Path) -> bool:
424
+ try:
425
+ return Path(left).expanduser().resolve(strict=False) == right.resolve(strict=False)
426
+ except OSError:
427
+ return str(Path(left).expanduser()) == str(right)
428
+
429
+
430
+ def _deferred_work_item_preflight(
431
+ *,
432
+ db_path: Path | None,
433
+ bundle: dict[str, Any],
434
+ source_path: Path,
435
+ ) -> dict[str, Any]:
436
+ work_id = str(bundle.get("work_id") or "")
437
+ if db_path is None:
438
+ return {"status": "skipped", "skipped_reason": "vocabulary_db_missing", "work_id": work_id}
439
+ if not db_path.exists():
440
+ return {"status": "skipped", "skipped_reason": "vocabulary_db_not_found", "work_id": work_id}
441
+ if not work_id:
442
+ return {"status": "skipped", "skipped_reason": "work_id_missing", "work_id": ""}
443
+ try:
444
+ with sqlite3.connect(db_path) as conn:
445
+ conn.row_factory = sqlite3.Row
446
+ row = conn.execute(
447
+ """
448
+ SELECT work_id, note_path, content_hash, status
449
+ FROM deferred_work_items
450
+ WHERE work_id = ?
451
+ """,
452
+ (work_id,),
453
+ ).fetchone()
454
+ except sqlite3.Error as exc:
455
+ return {
456
+ "status": "blocked",
457
+ "blocked_reason": "deferred_work_item_read_failed",
458
+ "work_id": work_id,
459
+ "error": str(exc),
460
+ }
461
+ if row is None:
462
+ return {"status": "skipped", "skipped_reason": "deferred_work_item_not_found", "work_id": work_id}
463
+ status = str(row["status"] or "")
464
+ if status not in {"pending", "claimed"}:
465
+ return {
466
+ "status": "blocked",
467
+ "blocked_reason": "deferred_work_item_not_pending",
468
+ "work_id": work_id,
469
+ "current_status": status,
470
+ }
471
+ note_path = str(row["note_path"] or "")
472
+ if note_path and not _same_path(note_path, source_path):
473
+ return {
474
+ "status": "blocked",
475
+ "blocked_reason": "deferred_work_item_source_mismatch",
476
+ "work_id": work_id,
477
+ "expected_note_path": note_path,
478
+ "actual_note_path": str(source_path),
479
+ }
480
+ content_hash = str(row["content_hash"] or "")
481
+ source_hash = str(bundle.get("source_hash") or "")
482
+ if content_hash and source_hash and content_hash != source_hash:
483
+ return {
484
+ "status": "blocked",
485
+ "blocked_reason": "deferred_work_item_stale",
486
+ "work_id": work_id,
487
+ "expected_hash": content_hash,
488
+ "actual_hash": source_hash,
489
+ }
490
+ return {"status": "ready", "work_id": work_id, "previous_status": status}
491
+
492
+
493
+ def _complete_deferred_work_item(
494
+ *,
495
+ db_path: Path | None,
496
+ preflight: dict[str, Any],
497
+ source_path: Path,
498
+ replacement_target: Path,
499
+ created_targets: list[Path],
500
+ receipt_path: Path,
501
+ ) -> dict[str, Any]:
502
+ if preflight.get("status") != "ready":
503
+ return dict(preflight)
504
+ if db_path is None:
505
+ return {"status": "skipped", "skipped_reason": "vocabulary_db_missing"}
506
+ work_id = str(preflight.get("work_id") or "")
507
+ payload = {
508
+ "completed_by": "apply-atomicity-split",
509
+ "source_path": str(source_path),
510
+ "replacement_target_path": str(replacement_target),
511
+ "created_paths": [str(path) for path in created_targets],
512
+ "receipt_path": str(receipt_path),
513
+ }
514
+ try:
515
+ with sqlite3.connect(db_path) as conn:
516
+ cursor = conn.execute(
517
+ """
518
+ UPDATE deferred_work_items
519
+ SET status = 'completed',
520
+ payload_json = ?,
521
+ updated_at = CURRENT_TIMESTAMP
522
+ WHERE work_id = ? AND status IN ('pending', 'claimed')
523
+ """,
524
+ (json.dumps(payload, ensure_ascii=False, sort_keys=True), work_id),
525
+ )
526
+ except sqlite3.Error as exc:
527
+ return {"status": "failed", "blocked_reason": "deferred_work_item_update_failed", "work_id": work_id, "error": str(exc)}
528
+ if cursor.rowcount != 1:
529
+ return {"status": "failed", "blocked_reason": "deferred_work_item_update_missed", "work_id": work_id}
530
+ return {"status": "completed", "work_id": work_id}
531
+
532
+
533
+ def _problem_note_paths(problem: dict[str, Any]) -> list[str]:
534
+ direct_path = str(problem.get("note_path") or "")
535
+ if direct_path:
536
+ return [direct_path]
537
+ evidence = problem.get("evidence") if isinstance(problem.get("evidence"), dict) else {}
538
+ issue_path = str(evidence.get("note_path") or "")
539
+ if issue_path:
540
+ return [issue_path]
541
+ issues = evidence.get("issues") if isinstance(evidence.get("issues"), list) else []
542
+ paths: list[str] = []
543
+ for issue in issues:
544
+ if isinstance(issue, dict) and issue.get("note_path"):
545
+ paths.append(str(issue["note_path"]))
546
+ return paths
547
+
548
+
549
+ def _deferred_payload(item: dict[str, Any]) -> dict[str, Any]:
550
+ payload = item.get("payload")
551
+ if isinstance(payload, dict):
552
+ return payload
553
+ payload_json = item.get("payload_json")
554
+ if isinstance(payload_json, str) and payload_json.strip():
555
+ try:
556
+ parsed = json.loads(payload_json)
557
+ except json.JSONDecodeError:
558
+ return {}
559
+ return parsed if isinstance(parsed, dict) else {}
560
+ return {}
561
+
562
+
563
+ def _atomicity_source_items(*, problems: list[Any], deferred: list[Any]) -> list[dict[str, Any]]:
564
+ seen: set[str] = set()
565
+ items: list[dict[str, Any]] = []
566
+ for item in problems:
567
+ if not isinstance(item, dict) or item.get("code") != ATOMICITY_PROBLEM_CODE:
568
+ continue
569
+ for path in _problem_note_paths(item):
570
+ if path not in seen:
571
+ seen.add(path)
572
+ items.append({"source_path": path})
573
+ for item in deferred:
574
+ if not isinstance(item, dict) or item.get("reason") not in ATOMICITY_REASONS:
575
+ continue
576
+ fields = _AtomicityDeferredItemFields.model_validate(item)
577
+ path = fields.note_path
578
+ if path and path not in seen:
579
+ seen.add(path)
580
+ payload = _deferred_payload(item)
581
+ payload_fields = _AtomicityDeferredItemFields.model_validate(payload)
582
+ signal = fields.semantic_signal or payload_fields.semantic_signal
583
+ atomicity_decision = fields.atomicity_decision or payload_fields.atomicity_decision
584
+ items.append(
585
+ {
586
+ "source_path": path,
587
+ "work_id": fields.work_id,
588
+ "content_hash": fields.content_hash,
589
+ "semantic_signal": signal,
590
+ "atomicity_decision": atomicity_decision,
591
+ }
592
+ )
593
+ return items
594
+
595
+
596
+ def _atomicity_work_item(
597
+ *,
598
+ source_path: str,
599
+ temp_root: Path,
600
+ index: int,
601
+ work_id: str = "",
602
+ content_hash: str = "",
603
+ semantic_signal: dict[str, Any] | None = None,
604
+ atomicity_decision: str = "",
605
+ ) -> dict[str, Any]:
606
+ source = Path(source_path)
607
+ official_work_id = work_id or f"atomicity-split-{index:03d}-{_slug(source.stem)}"
608
+ work_dir_name = _slug(official_work_id)
609
+ source_hash = _sha256_bytes(source) if source.is_file() else content_hash
610
+ return {
611
+ "work_id": official_work_id,
612
+ "agent": "med-knowledge-architect",
613
+ "item_type": "wiki_atomicity_split",
614
+ "mode": "wiki_atomicity_split",
615
+ "source_path": source_path,
616
+ "source_hash": source_hash,
617
+ "owner_key": source_path,
618
+ "bundle_output_path": str(temp_root / work_dir_name / "atomicity-split-bundle.json"),
619
+ "temp_markdown_dir": str(temp_root / work_dir_name / "markdown"),
620
+ "semantic_signal": semantic_signal or {},
621
+ "atomicity_decision": atomicity_decision,
622
+ "allowed_strategies": sorted(SUPPORTED_STRATEGIES),
623
+ "required_bundle_fields": [
624
+ "schema",
625
+ "workflow",
626
+ "phase",
627
+ "agent",
628
+ "source_workflow",
629
+ "work_id",
630
+ "source_path",
631
+ "source_hash",
632
+ "strategy",
633
+ "replacement_source",
634
+ "created_notes",
635
+ ],
636
+ "replacement_source_schema": {"title": "string", "target_path": "wiki-relative-or-absolute", "content_path": "temp markdown path"},
637
+ "created_notes_item_schema": {"title": "string", "target_path": "wiki-relative-or-absolute", "content_path": "temp markdown path"},
638
+ "instructions": [
639
+ "Read exactly the source note and the provided context packet.",
640
+ "Return atomicity-split-bundle.v1 with workflow=/mednotes:fix-wiki, phase=atomicity_split, agent=med-knowledge-architect and source_workflow=/mednotes:fix-wiki.",
641
+ "Copy work_id exactly from this work item.",
642
+ "Copy source_path and source_hash exactly from this work item.",
643
+ "Never compute, shorten, patch, or invent source_hash; block if it is missing.",
644
+ "Use one allowed strategy exactly as listed in allowed_strategies.",
645
+ "replacement_source must be an object with title, target_path and content_path.",
646
+ "created_notes must contain objects with title, target_path and content_path.",
647
+ "Preserve all chat provenance in the resulting notes.",
648
+ "Do not mutate the Wiki, call subagents, or invent aliases.",
649
+ ],
650
+ }
651
+
652
+
653
+ def build_atomicity_split_plan(
654
+ *,
655
+ fix_wiki_plan_path: Path,
656
+ batch_id: str,
657
+ temp_root: Path,
658
+ limit: int = 20,
659
+ ) -> dict[str, Any]:
660
+ payload = _load_fix_wiki_plan(fix_wiki_plan_path)
661
+ problems = payload.get("problems")
662
+ if not isinstance(problems, list):
663
+ problems = payload.get("fix_wiki_problems") if isinstance(payload.get("fix_wiki_problems"), list) else []
664
+ deferred = payload.get("deferred_work_items") if isinstance(payload.get("deferred_work_items"), list) else []
665
+ source_items = _atomicity_source_items(problems=problems, deferred=deferred)
666
+ work_items = [
667
+ _atomicity_work_item(
668
+ source_path=str(source_item.get("source_path") or ""),
669
+ temp_root=temp_root,
670
+ index=index,
671
+ work_id=str(source_item.get("work_id") or ""),
672
+ content_hash=str(source_item.get("content_hash") or ""),
673
+ semantic_signal=source_item.get("semantic_signal") if isinstance(source_item.get("semantic_signal"), dict) else {},
674
+ atomicity_decision=str(source_item.get("atomicity_decision") or ""),
675
+ )
676
+ for index, source_item in enumerate(source_items[:limit], start=1)
677
+ ]
678
+ return {
679
+ "schema": ATOMICITY_SPLIT_PLAN_SCHEMA,
680
+ "phase": "atomicity_split",
681
+ "status": "ready" if work_items else "skipped",
682
+ "skipped_reason": "" if work_items else "no_atomicity_work",
683
+ "batch_id": batch_id,
684
+ "source_fix_wiki_plan_path": str(fix_wiki_plan_path),
685
+ "source_plan_hash": str(payload.get("plan_hash") or ""),
686
+ "source_snapshot_hash": str(payload.get("snapshot_hash") or ""),
687
+ "item_count": len(work_items),
688
+ "work_items": work_items,
689
+ "canonical_parent_commands": [
690
+ f"apply split: {wiki_cli_relative_command('apply-atomicity-split --bundle /tmp/mnw/atomicity-split-bundle.json --json')}"
691
+ ],
692
+ "rules": [
693
+ "Each med-knowledge-architect writes one atomicity-split-bundle.v1.",
694
+ "Subagents may write temporary Markdown outputs only under temp_root.",
695
+ "Subagents do not mutate the Wiki and do not call subagents.",
696
+ ],
697
+ }
698
+
699
+
700
+ def apply_atomicity_split_bundle(
701
+ *,
702
+ bundle_path: Path,
703
+ wiki_dir: Path,
704
+ backup: bool,
705
+ defer_linker: bool = False,
706
+ parent_batch_id: str = "",
707
+ vocabulary_db_path: Path | None = None,
708
+ ) -> dict[str, Any]:
709
+ backup = False
710
+ if defer_linker and not parent_batch_id:
711
+ return {
712
+ "schema": ATOMICITY_SPLIT_RECEIPT_SCHEMA,
713
+ "phase": "atomicity_split_apply",
714
+ "status": "blocked",
715
+ "blocked_reason": "invalid_linker_deferral",
716
+ "next_action": "Passe parent_batch_id ou rode o linker neste apply.",
717
+ "bundle_path": str(bundle_path),
718
+ "wiki_dir": str(wiki_dir),
719
+ "written_count": 0,
720
+ }
721
+
722
+ bundle = _load_bundle(bundle_path)
723
+ contract_errors = subagent_output_contract_errors(
724
+ bundle,
725
+ expected_schema=ATOMICITY_SPLIT_BUNDLE_SCHEMA,
726
+ expected_workflow="/mednotes:fix-wiki",
727
+ expected_phase="atomicity_split",
728
+ allowed_agents={"med-knowledge-architect"},
729
+ source_workflow="/mednotes:fix-wiki",
730
+ )
731
+ if contract_errors:
732
+ return _blocked_receipt(
733
+ bundle_path=bundle_path,
734
+ wiki_dir=wiki_dir,
735
+ blocked_reason=SUBAGENT_OUTPUT_CONTRACT_BLOCKED_REASON,
736
+ next_action=(
737
+ "Regenerar o atomicity-split-bundle com med-knowledge-architect direto a partir do work item oficial; "
738
+ "não use @generalist nem output sem workflow/phase/source_workflow."
739
+ ),
740
+ validation_errors=[
741
+ {
742
+ "code": error["code"],
743
+ "message": (
744
+ f"{SUBAGENT_OUTPUT_CONTRACT_BLOCKED_REASON}: {error['field']} "
745
+ f"expected {error['expected']} got {error['actual']}"
746
+ ),
747
+ }
748
+ for error in contract_errors
749
+ ],
750
+ )
751
+ errors = _validation_errors(bundle, wiki_dir=wiki_dir)
752
+ if errors:
753
+ return _blocked_receipt(
754
+ bundle_path=bundle_path,
755
+ wiki_dir=wiki_dir,
756
+ blocked_reason="validation_failed",
757
+ next_action=(
758
+ "Regenerar o atomicity-split-bundle a partir do work item oficial; não edite "
759
+ "source_hash, strategy, replacement_source ou created_notes manualmente."
760
+ ),
761
+ validation_errors=errors,
762
+ )
763
+
764
+ source_path = _safe_wiki_path(str(bundle["source_path"]), wiki_dir)
765
+ deferred_work_item = _deferred_work_item_preflight(
766
+ db_path=vocabulary_db_path,
767
+ bundle=bundle,
768
+ source_path=source_path,
769
+ )
770
+ if deferred_work_item.get("status") == "blocked":
771
+ return _blocked_receipt(
772
+ bundle_path=bundle_path,
773
+ wiki_dir=wiki_dir,
774
+ blocked_reason=str(deferred_work_item.get("blocked_reason") or "deferred_work_item_blocked"),
775
+ next_action="Regenerar o plano de atomicidade pelo fix-wiki atual antes de aplicar este bundle.",
776
+ validation_errors=[
777
+ {
778
+ "code": str(deferred_work_item.get("blocked_reason") or "deferred_work_item_blocked"),
779
+ "message": json.dumps(deferred_work_item, ensure_ascii=False, sort_keys=True),
780
+ }
781
+ ],
782
+ )
783
+ outputs = _output_paths(bundle, wiki_dir)
784
+ replacement = outputs[0]
785
+ replacement_target = replacement["target_path"]
786
+ created_outputs = outputs[1:]
787
+ created_targets = [item["target_path"] for item in created_outputs]
788
+ receipt_path = bundle_path.with_name("atomicity-split-receipt.json")
789
+ trigger_context_path = bundle_path.with_name("atomicity-link-trigger-context.json")
790
+ backup_paths: list[str] = []
791
+ originals = {source_path: source_path.read_text(encoding="utf-8")}
792
+ if replacement_target.exists():
793
+ originals[replacement_target] = replacement_target.read_text(encoding="utf-8")
794
+ source_text = originals[source_path]
795
+ try:
796
+ replacement_text = _canonicalize_output_provenance(
797
+ replacement["content_path"].read_text(encoding="utf-8"),
798
+ source_path=source_path,
799
+ source_text=source_text,
800
+ )
801
+ atomic_write_text(replacement_target, replacement_text)
802
+ for output in created_outputs:
803
+ output_text = _canonicalize_output_provenance(
804
+ output["content_path"].read_text(encoding="utf-8"),
805
+ source_path=source_path,
806
+ source_text=source_text,
807
+ )
808
+ atomic_write_text(output["target_path"], output_text)
809
+ if str(bundle.get("strategy") or "") == "rename_source_and_create_notes" and replacement_target != source_path:
810
+ source_path.unlink()
811
+ except (FileWriteError, OSError) as exc:
812
+ rollback_errors: list[dict[str, str]] = []
813
+ for path, text in originals.items():
814
+ try:
815
+ atomic_write_text(path, text)
816
+ except (FileWriteError, OSError) as rollback_exc:
817
+ rollback_errors.append({"path": str(path), "error": str(rollback_exc)})
818
+ return {
819
+ "schema": ATOMICITY_SPLIT_RECEIPT_SCHEMA,
820
+ "phase": "atomicity_split_apply",
821
+ "status": "failed",
822
+ "blocked_reason": "io_error_rollback_performed",
823
+ "next_action": "Inspecionar erro de IO/rollback antes de repetir o split.",
824
+ "bundle_path": str(bundle_path),
825
+ "wiki_dir": str(wiki_dir),
826
+ "io_error": str(exc),
827
+ "rollback": {"performed": True, "errors": rollback_errors},
828
+ "written_count": 0,
829
+ "backup_paths": backup_paths,
830
+ }
831
+
832
+ trigger_context = _trigger_context_payload(
833
+ wiki_dir=wiki_dir,
834
+ bundle_path=bundle_path,
835
+ source_path=source_path,
836
+ replacement_target=replacement_target,
837
+ created_targets=created_targets,
838
+ )
839
+ write_trigger_context(trigger_context_path, trigger_context)
840
+ deferred_work_item = _complete_deferred_work_item(
841
+ db_path=vocabulary_db_path,
842
+ preflight=deferred_work_item,
843
+ source_path=source_path,
844
+ replacement_target=replacement_target,
845
+ created_targets=created_targets,
846
+ receipt_path=receipt_path,
847
+ )
848
+ receipt = {
849
+ "schema": ATOMICITY_SPLIT_RECEIPT_SCHEMA,
850
+ "phase": "atomicity_split_apply",
851
+ "status": "completed",
852
+ "blocked_reason": "",
853
+ "next_action": (
854
+ "Acumular trigger contexts do lote e rodar /mednotes:link uma vez."
855
+ if defer_linker
856
+ else "Rodar /mednotes:link com o trigger context emitido."
857
+ ),
858
+ "bundle_path": str(bundle_path),
859
+ "wiki_dir": str(wiki_dir),
860
+ "strategy": str(bundle.get("strategy") or ""),
861
+ "source_path": str(source_path),
862
+ "replacement_target_path": str(replacement_target),
863
+ "created_paths": [str(path) for path in created_targets],
864
+ "written_count": 1 + len(created_targets),
865
+ "backup": backup,
866
+ "backup_paths": backup_paths,
867
+ "receipt_path": str(receipt_path),
868
+ "link_trigger_context": trigger_context,
869
+ "link_trigger_context_path": str(trigger_context_path),
870
+ "linker_trigger_context_path": str(trigger_context_path),
871
+ "linker_status": "deferred" if defer_linker else "not_run",
872
+ "parent_batch_id": parent_batch_id,
873
+ "linker_pending_reason": "parent_batch_will_run_linker_once" if defer_linker else "",
874
+ "deferred_work_item": deferred_work_item,
875
+ }
876
+ atomic_write_text(receipt_path, json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
877
+ return receipt