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,2758 @@
1
+ """Semantic linker and graph-audit orchestration for the Wiki CLI."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sqlite3
6
+ from collections.abc import Callable
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Literal, cast
10
+
11
+ from pydantic import ConfigDict, Field, field_validator
12
+ from pydantic import ValidationError as PydanticValidationError
13
+
14
+ from mednotes.domains.wiki.batch_state import canonical_json_hash, file_sha256
15
+ from mednotes.domains.wiki.capabilities.body_link.body_linker import (
16
+ DEFAULT_LLM_DISAMBIGUATION_MODEL,
17
+ DEFAULT_LLM_TIMEOUT_SECONDS,
18
+ apply_body_linker_plan,
19
+ )
20
+ from mednotes.domains.wiki.capabilities.body_link.body_linker import (
21
+ run_body_linker as run_db_body_linker,
22
+ )
23
+ from mednotes.domains.wiki.capabilities.graph import graph as wiki_graph
24
+ from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
25
+ from mednotes.domains.wiki.capabilities.notes.note_style.frontmatter import infer_title
26
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
27
+ from mednotes.domains.wiki.capabilities.related_notes.related_notes import (
28
+ RELATED_NOTES_SYNC_SCHEMA,
29
+ default_export_path,
30
+ recover_related_notes_export_operation_result,
31
+ sync_related_notes_operation_result,
32
+ )
33
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import extract_aliases, normalize_key
34
+ from mednotes.domains.wiki.capabilities.vocabulary.link_terms import is_index_note as _is_index_note
35
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_bootstrap import planned_vocabulary_bootstrap
36
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_curator_batch import build_vocabulary_curator_batch_plan
37
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_ingestion import apply_semantic_ingestion
38
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_map import (
39
+ initialize_vocabulary_db,
40
+ load_vocabulary_map_diagnosis,
41
+ note_content_hash,
42
+ upsert_note,
43
+ )
44
+ from mednotes.domains.wiki.common import ValidationError, _now_iso, wiki_cli_command
45
+ from mednotes.domains.wiki.config import MedConfig, _path
46
+ from mednotes.domains.wiki.contracts.link_git import LinkGitContext, LinkState
47
+ from mednotes.domains.wiki.contracts.related_notes_runtime import (
48
+ LinkRelatedSyncResult,
49
+ RelatedNotesPassSummary,
50
+ RelatedNotesRecoveryState,
51
+ )
52
+ from mednotes.domains.wiki.contracts.workflow_blockers import blocker_entry, decision_for_code
53
+ from mednotes.domains.wiki.contracts.workflow_guardrails import LINK_REQUIRED_INPUTS
54
+ from mednotes.domains.wiki.flows.link.link_git import (
55
+ collect_git_context,
56
+ load_link_state,
57
+ trigger_context_from_git,
58
+ write_link_state,
59
+ )
60
+ from mednotes.domains.wiki.flows.link.link_retry_governance import (
61
+ build_diagnosis_identity,
62
+ force_diagnose_event,
63
+ record_diagnosis_attempt,
64
+ redundant_diagnosis_payload,
65
+ )
66
+ from mednotes.domains.wiki.flows.link.link_triggers import (
67
+ affected_notes_from_context,
68
+ derive_triggers,
69
+ is_image_only_context,
70
+ load_trigger_context,
71
+ structural_events_from_context,
72
+ )
73
+ from mednotes.domains.wiki.flows.link.reference_repair import apply_reference_repair_plan, plan_reference_repair
74
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
75
+
76
+ LINK_DIAGNOSIS_SCHEMA = "medical-notes-workbench.link-diagnosis.v1"
77
+ LINK_RUN_SCHEMA = "medical-notes-workbench.link-run.v1"
78
+ LINK_RUN_RECEIPT_SCHEMA = "medical-notes-workbench.link-run-receipt.v1"
79
+ RELATED_NOTES_CONVERGENCE_MAX_PASSES = 3
80
+ LINK_PHASE_ORDER = (
81
+ "reference_repair",
82
+ "contextual_alias_disambiguation",
83
+ "body_term_linker",
84
+ "related_notes_sync",
85
+ "graph_validation",
86
+ )
87
+
88
+
89
+ def _json_object(payload: object) -> JsonObject:
90
+ if isinstance(payload, dict):
91
+ return cast(JsonObject, payload)
92
+ return JsonObjectAdapter.validate_python(payload)
93
+
94
+
95
+ def _json_object_or_empty(payload: object | None) -> JsonObject:
96
+ return _json_object(payload) if isinstance(payload, dict) else {}
97
+
98
+
99
+ def _json_field(payload: JsonObject, key: str, default: object = "") -> object:
100
+ return payload[key] if key in payload else default
101
+
102
+
103
+ def _json_text(payload: JsonObject, key: str, default: str = "") -> str:
104
+ """Read a text field from already-validated JSON without loose `str(x or "")` fallback."""
105
+
106
+ value = _json_field(payload, key, default)
107
+ return value if isinstance(value, str) else default
108
+
109
+
110
+ def _json_bool(payload: JsonObject, key: str, default: bool = False) -> bool:
111
+ """Read boolean flags used for linker flow decisions from validated JSON."""
112
+
113
+ value = _json_field(payload, key, default)
114
+ return value if isinstance(value, bool) else default
115
+
116
+
117
+ def _json_int(payload: JsonObject, key: str, default: int = 0) -> int:
118
+ value = _json_field(payload, key, default)
119
+ if isinstance(value, bool) or not isinstance(value, int):
120
+ raise ValueError(f"{key} must be an integer")
121
+ if value < 0:
122
+ raise ValueError(f"{key} must be non-negative")
123
+ return value
124
+
125
+
126
+ _VocabularyMapStatus = Literal[
127
+ "",
128
+ "blocked",
129
+ "blocked_human",
130
+ "blocked_pending",
131
+ "failed",
132
+ "planned",
133
+ "ready",
134
+ "skipped",
135
+ ]
136
+ _VocabularyBootstrapStatus = Literal[
137
+ "",
138
+ "completed",
139
+ "existing",
140
+ "failed",
141
+ "planned",
142
+ "queued_semantic_ingestion",
143
+ "ready",
144
+ "skipped",
145
+ ]
146
+ _VocabularySemanticRepairStatus = Literal["completed", "completed_with_blockers", "skipped"]
147
+
148
+
149
+ class _ContextualAliasDiagnosis(ContractModel):
150
+ """Typed view for contextual alias evidence emitted by the body linker."""
151
+
152
+ model_config = ConfigDict(extra="ignore")
153
+
154
+ status: str = "skipped"
155
+ mode: str = ""
156
+ candidate_count: int = Field(default=0, ge=0, strict=True)
157
+ decision_count: int = Field(default=0, ge=0, strict=True)
158
+ linked_count: int = Field(default=0, ge=0, strict=True)
159
+ deferred_count: int = Field(default=0, ge=0, strict=True)
160
+ no_link_count: int = Field(default=0, ge=0, strict=True)
161
+ rejected_count: int = Field(default=0, ge=0, strict=True)
162
+ skipped_reason: str = ""
163
+ blocked_reason: str = ""
164
+
165
+ @classmethod
166
+ def from_payload(cls, value: object) -> _ContextualAliasDiagnosis:
167
+ return cls.model_validate(_json_object_or_empty(value))
168
+
169
+
170
+ class _GitContextView(ContractModel):
171
+ """Typed projection of git context fields consumed by link apply safety."""
172
+
173
+ model_config = ConfigDict(extra="ignore")
174
+
175
+ available: bool = False
176
+ repo_root: str = ""
177
+ branch: str = ""
178
+ head: str = ""
179
+ status_hash: str = ""
180
+ changed_paths: list[JsonObject] = Field(default_factory=list)
181
+ unavailable_reason: str = ""
182
+
183
+ @field_validator("changed_paths", mode="before")
184
+ @classmethod
185
+ def _changed_paths_or_empty(cls, value: object) -> list[JsonObject]:
186
+ if not isinstance(value, list):
187
+ return []
188
+ return [_json_object(item) for item in value if isinstance(item, dict)]
189
+
190
+ @classmethod
191
+ def from_payload(cls, value: object) -> _GitContextView:
192
+ return cls.model_validate(_json_object_or_empty(value))
193
+
194
+
195
+ class _GraphAuditView(ContractModel):
196
+ """Typed graph audit counts that determine link apply completion."""
197
+
198
+ model_config = ConfigDict(extra="ignore")
199
+
200
+ error_count: int = Field(default=0, ge=0, strict=True)
201
+ warning_count: int = Field(default=0, ge=0, strict=True)
202
+
203
+ @classmethod
204
+ def from_payload(cls, value: object) -> _GraphAuditView:
205
+ return cls.model_validate(_json_object_or_empty(value))
206
+
207
+
208
+ class _VocabularyMapIssueView(ContractModel):
209
+ """Typed vocabulary-map issue used to construct link blockers."""
210
+
211
+ model_config = ConfigDict(extra="ignore")
212
+
213
+ code: str = ""
214
+ message: str = ""
215
+ next_action: str = ""
216
+ required_inputs: list[str] = Field(default_factory=list)
217
+ decision_summary: JsonObject | None = None
218
+ severity: str = ""
219
+
220
+
221
+ class _VocabularyMapDiagnosisView(ContractModel):
222
+ """Typed view of vocabulary diagnosis fields that can block link diagnosis."""
223
+
224
+ model_config = ConfigDict(extra="ignore")
225
+
226
+ status: _VocabularyMapStatus = ""
227
+ pending_semantic_ingestion_count: int = Field(default=0, ge=0, strict=True)
228
+ note_count: int = Field(default=0, ge=0, strict=True)
229
+ meaning_count: int = Field(default=0, ge=0, strict=True)
230
+ issues: list[_VocabularyMapIssueView] = Field(default_factory=list)
231
+
232
+ @classmethod
233
+ def from_payload(cls, value: object) -> _VocabularyMapDiagnosisView:
234
+ return cls.model_validate(_json_object_or_empty(value))
235
+
236
+
237
+ class _VocabularyBootstrapView(ContractModel):
238
+ """Typed vocabulary bootstrap diagnosis before it controls link phases."""
239
+
240
+ model_config = ConfigDict(extra="ignore")
241
+
242
+ status: _VocabularyBootstrapStatus = ""
243
+ note_count: int = Field(default=0, ge=0, strict=True)
244
+
245
+ @classmethod
246
+ def from_payload(cls, value: object) -> _VocabularyBootstrapView:
247
+ return cls.model_validate(_json_object_or_empty(value))
248
+
249
+
250
+ class _VocabularySemanticRepairView(ContractModel):
251
+ """Typed vocabulary repair receipt before link apply can branch on it."""
252
+
253
+ model_config = ConfigDict(extra="ignore")
254
+
255
+ schema_id: Literal["medical-notes-workbench.vocabulary-semantic-repair.v1"] = Field(alias="schema")
256
+ status: _VocabularySemanticRepairStatus
257
+ blocked_reason: str = ""
258
+ next_action: str = ""
259
+ human_decision_required: bool = Field(default=False, strict=True)
260
+ applied_count: int = Field(default=0, ge=0, strict=True)
261
+ blocked_count: int = Field(default=0, ge=0, strict=True)
262
+
263
+ @classmethod
264
+ def from_payload(cls, value: object) -> _VocabularySemanticRepairView:
265
+ return cls.model_validate(_json_object(value))
266
+
267
+
268
+ class _ReferenceRepairView(ContractModel):
269
+ """Typed view of the reference-repair plan used by linker phases and receipts."""
270
+
271
+ model_config = ConfigDict(extra="ignore")
272
+
273
+ status: str = "skipped"
274
+ affected_note_count: int = Field(default=0, ge=0, strict=True)
275
+ action_count: int = Field(default=0, ge=0, strict=True)
276
+ blocking_action_count: int = Field(default=0, ge=0, strict=True)
277
+ human_decision_count: int = Field(default=0, ge=0, strict=True)
278
+ triage_count: int = Field(default=0, ge=0, strict=True)
279
+ human_decision_packets: list[JsonObject] = Field(default_factory=list)
280
+
281
+ @classmethod
282
+ def from_payload(cls, value: object) -> _ReferenceRepairView:
283
+ return cls.model_validate(_json_object_or_empty(value))
284
+
285
+
286
+ class _ReferenceApplyView(ContractModel):
287
+ """Typed reference-repair apply receipt before link receipts consume it."""
288
+
289
+ model_config = ConfigDict(extra="ignore")
290
+
291
+ status: str = ""
292
+ changed_file_count: int = Field(default=0, ge=0, strict=True)
293
+ reports: list[JsonObject] = Field(default_factory=list)
294
+
295
+ @classmethod
296
+ def from_payload(cls, value: object | None) -> _ReferenceApplyView:
297
+ return cls.model_validate(_json_object_or_empty(value))
298
+
299
+
300
+ class _LinkBodyPlanView(ContractModel):
301
+ """Typed body-linker note plan used for changed-file and skip accounting."""
302
+
303
+ model_config = ConfigDict(extra="ignore")
304
+
305
+ file: str = ""
306
+ changed: bool = False
307
+ insertions: list[JsonObject] = Field(default_factory=list)
308
+ rewrites: list[JsonObject] = Field(default_factory=list)
309
+ skipped: list[JsonObject] = Field(default_factory=list)
310
+
311
+ @classmethod
312
+ def from_payload(cls, value: object) -> _LinkBodyPlanView:
313
+ return cls.model_validate(_json_object_or_empty(value))
314
+
315
+
316
+ class _LinkPlanSkipView(ContractModel):
317
+ """Typed skipped occurrence emitted by contextual/body linker planning."""
318
+
319
+ model_config = ConfigDict(extra="ignore")
320
+
321
+ occurrence_id: str = ""
322
+ reason_code: str = ""
323
+ action: str = ""
324
+
325
+ @classmethod
326
+ def from_payload(cls, value: object) -> _LinkPlanSkipView:
327
+ return cls.model_validate(_json_object_or_empty(value))
328
+
329
+
330
+ class _SnapshotNoteView(ContractModel):
331
+ """Typed note entry from a link snapshot."""
332
+
333
+ model_config = ConfigDict(extra="ignore")
334
+
335
+ path: str = ""
336
+ content_hash: str = ""
337
+
338
+ @classmethod
339
+ def from_payload(cls, value: object) -> _SnapshotNoteView:
340
+ return cls.model_validate(_json_object_or_empty(value))
341
+
342
+
343
+ class _SemanticIngestionItemView(ContractModel):
344
+ """Typed subset of semantic-ingestion items used for repair error receipts."""
345
+
346
+ model_config = ConfigDict(extra="ignore")
347
+
348
+ note_path: str = ""
349
+ content_hash: str = ""
350
+
351
+ @classmethod
352
+ def from_payload(cls, value: object) -> _SemanticIngestionItemView:
353
+ return cls.model_validate(_json_object_or_empty(value))
354
+
355
+
356
+ class _BodyLinkerView(ContractModel):
357
+ """Typed view over body-linker output before it is folded into the link FSM."""
358
+
359
+ model_config = ConfigDict(extra="ignore")
360
+
361
+ status: str = "skipped"
362
+ blocked_reason: str = ""
363
+ next_action: str = ""
364
+ returncode: int = Field(default=0, ge=0, strict=True)
365
+ files_changed: int = Field(default=0, ge=0, strict=True)
366
+ links_planned: int = Field(default=0, ge=0, strict=True)
367
+ links_rewritten: int = Field(default=0, ge=0, strict=True)
368
+ blocker_count: int = Field(default=0, ge=0, strict=True)
369
+ error: str = ""
370
+ parse_error: str = ""
371
+ body_linker_mode: str = ""
372
+ contextual_alias_disambiguation: _ContextualAliasDiagnosis = Field(default_factory=_ContextualAliasDiagnosis)
373
+ graph_audit_before: JsonObject = Field(default_factory=dict)
374
+ vocabulary_map_diagnosis: _VocabularyMapDiagnosisView = Field(default_factory=_VocabularyMapDiagnosisView)
375
+ plans: list[_LinkBodyPlanView] = Field(default_factory=list)
376
+ blockers: list[JsonObject] = Field(default_factory=list)
377
+
378
+ @classmethod
379
+ def from_payload(cls, value: object) -> _BodyLinkerView:
380
+ return cls.model_validate(_json_object_or_empty(value))
381
+
382
+
383
+ class _LinkDiagnosisView(ContractModel):
384
+ """Typed view of saved link diagnosis fields consumed by apply preflight."""
385
+
386
+ model_config = ConfigDict(extra="ignore")
387
+
388
+ status: str = ""
389
+ blocked_reason: str = ""
390
+ next_action: str = ""
391
+ human_decision_required: bool = False
392
+ diagnosis_path: str = ""
393
+ wiki_dir: str = ""
394
+ vocabulary_db_path: str = ""
395
+ blocker_count: int = Field(default=0, ge=0)
396
+ blockers: list[JsonObject] = Field(default_factory=list)
397
+ changed_files: list[str] = Field(default_factory=list)
398
+ files_changed: int = Field(default=0, ge=0)
399
+ snapshot_hash: str = ""
400
+ plan_hash: str = ""
401
+ trigger_context: JsonObject = Field(default_factory=dict)
402
+ triggers_detected: list[str] = Field(default_factory=list)
403
+ affected_notes: list[JsonObject] = Field(default_factory=list)
404
+ git: _GitContextView = Field(default_factory=_GitContextView)
405
+ reference_repair: _ReferenceRepairView = Field(default_factory=_ReferenceRepairView)
406
+ body_term_linker: _BodyLinkerView = Field(default_factory=_BodyLinkerView)
407
+ related_notes_sync: JsonObject = Field(default_factory=dict)
408
+ version_control_safety: JsonObject = Field(default_factory=dict)
409
+ receipt: JsonObject = Field(default_factory=dict)
410
+ guard_receipt: JsonObject = Field(default_factory=dict)
411
+ vocabulary_bootstrap: JsonObject = Field(default_factory=dict)
412
+
413
+ @field_validator(
414
+ "trigger_context",
415
+ "related_notes_sync",
416
+ "version_control_safety",
417
+ "receipt",
418
+ "guard_receipt",
419
+ "vocabulary_bootstrap",
420
+ mode="before",
421
+ )
422
+ @classmethod
423
+ def _object_or_empty(cls, value: object) -> JsonObject:
424
+ return _json_object_or_empty(value)
425
+
426
+ @field_validator("affected_notes", mode="before")
427
+ @classmethod
428
+ def _affected_notes_or_empty(cls, value: object) -> list[JsonObject]:
429
+ if not isinstance(value, list):
430
+ return []
431
+ return [_json_object(item) for item in value if isinstance(item, dict)]
432
+
433
+ @field_validator("changed_files", "triggers_detected", mode="before")
434
+ @classmethod
435
+ def _string_list_or_empty(cls, value: object) -> list[str]:
436
+ if not isinstance(value, list):
437
+ return []
438
+ return [item for item in value if isinstance(item, str)]
439
+
440
+ @classmethod
441
+ def from_payload(cls, value: object) -> _LinkDiagnosisView:
442
+ return cls.model_validate(_json_object_or_empty(value))
443
+
444
+ def version_control_safety_payload(self) -> JsonObject:
445
+ if self.version_control_safety:
446
+ return self.version_control_safety
447
+ receipt = _json_object_or_empty(self.receipt)
448
+ nested = _json_object_or_empty(_json_field(receipt, "version_control_safety"))
449
+ if nested:
450
+ return nested
451
+ guard_receipt = _json_object_or_empty(self.guard_receipt)
452
+ return _json_object_or_empty(_json_field(guard_receipt, "version_control_safety"))
453
+
454
+
455
+ def related_notes_sync_blocked(result: object) -> bool:
456
+ typed = LinkRelatedSyncResult.from_payload(result)
457
+ return typed.status == "blocked" or bool(typed.blocked_reason)
458
+
459
+
460
+ def _related_notes_apply_blocked(result: LinkRelatedSyncResult | None, *, required: bool) -> bool:
461
+ """Treat skipped Related Notes as blocking only when the parent made it required."""
462
+
463
+ if result is None:
464
+ return False
465
+ return related_notes_sync_blocked(result) or (
466
+ required and result.status == "skipped" and bool(result.skipped_reason)
467
+ )
468
+
469
+
470
+ def _related_notes_required_for_apply(diagnosis: JsonObject) -> bool:
471
+ """Process-chats publishes new notes, so its link package must close Related Notes too."""
472
+
473
+ context = _json_object_or_empty(_json_field(diagnosis, "trigger_context"))
474
+ return _json_field(context, "source_workflow") == "/mednotes:process-chats"
475
+
476
+
477
+ def _related_notes_waiting_external(result: LinkRelatedSyncResult | None) -> bool:
478
+ if result is None:
479
+ return False
480
+ recovery = result.related_notes_recovery_state
481
+ return (
482
+ recovery.status == "waiting_for_retry"
483
+ and recovery.blocked_reason
484
+ in {
485
+ "related_notes_headless_quota_exhausted",
486
+ "related_notes_headless_time_budget_exhausted",
487
+ }
488
+ )
489
+
490
+
491
+ def _related_notes_recovery_payload(result: LinkRelatedSyncResult | None) -> JsonObject:
492
+ if result is None:
493
+ return {}
494
+ recovery = result.related_notes_recovery_state
495
+ return recovery.to_payload() if recovery else {}
496
+
497
+
498
+ def _link_diagnosis_contract_invalid_payload(
499
+ *,
500
+ diagnosis_path: Path,
501
+ detail: str,
502
+ source_payload: JsonObject,
503
+ extra: JsonObject | None = None,
504
+ ) -> JsonObject:
505
+ """Block apply when a saved/refreshed link artifact is not FSM-complete."""
506
+
507
+ return _json_object(
508
+ {
509
+ "schema": LINK_RUN_SCHEMA,
510
+ "phase": "link_apply_preflight",
511
+ "status": "blocked",
512
+ "blocked_reason": "link_diagnosis_contract_invalid",
513
+ "next_action": "Reexecutar /mednotes:link --diagnose para gerar diagnóstico FSM válido.",
514
+ "required_inputs": ["diagnosis"],
515
+ "human_decision_required": False,
516
+ "diagnosis_path": str(diagnosis_path),
517
+ "error_context": {
518
+ "root_cause": "effect_payload_contract_invalid",
519
+ "detail": detail,
520
+ },
521
+ "invalid_diagnosis": source_payload,
522
+ "returncode": 3,
523
+ **(extra or {}),
524
+ }
525
+ )
526
+
527
+
528
+ def _link_apply_blocked_reason(
529
+ *,
530
+ body_or_graph_blocked: bool,
531
+ related_notes_blocked: bool,
532
+ ) -> str:
533
+ if related_notes_blocked:
534
+ return "related_notes_blocked"
535
+ if body_or_graph_blocked:
536
+ return "graph_blockers"
537
+ return ""
538
+
539
+
540
+ def _link_apply_next_action(
541
+ *,
542
+ blocked_reason: str,
543
+ related_notes: LinkRelatedSyncResult | None,
544
+ ) -> str:
545
+ if blocked_reason == "related_notes_blocked":
546
+ return related_notes.next_action if related_notes is not None and related_notes.next_action else (
547
+ "Atualizar o export do Related Notes ou aguardar a cota externa e repetir /mednotes:link."
548
+ )
549
+ if blocked_reason == "graph_blockers":
550
+ return "Rodar /mednotes:fix-wiki --dry-run para resolver blockers semânticos."
551
+ return ""
552
+
553
+
554
+ def run_related_notes_sync(
555
+ config: MedConfig,
556
+ *,
557
+ apply: bool = False,
558
+ backup: bool = False,
559
+ receipt_path: Path | None = None,
560
+ max_age_hours: float = 168.0,
561
+ allow_stale_note_hashes: bool = False,
562
+ ) -> LinkRelatedSyncResult:
563
+ export_path = default_export_path(config.wiki_dir)
564
+ if not export_path.is_file():
565
+ return LinkRelatedSyncResult.from_payload(
566
+ {
567
+ "schema": RELATED_NOTES_SYNC_SCHEMA,
568
+ "phase": "related_notes_skipped",
569
+ "status": "skipped",
570
+ "blocked_reason": "",
571
+ "skipped_reason": "related_notes_export_missing",
572
+ "next_action": (
573
+ "Exportar medical-notes-export.json pelo plugin Related Notes para sincronizar "
574
+ "a seção gerenciada Notas Relacionadas."
575
+ ),
576
+ "required_inputs": ["wiki_dir", "related_notes_export"],
577
+ "human_decision_required": False,
578
+ "wiki_dir": str(config.wiki_dir),
579
+ "export_path": "",
580
+ "default_export_name": "medical-notes-export.json",
581
+ "applied_note_count": 0,
582
+ "planned_note_count": 0,
583
+ "proposed_link_count": 0,
584
+ "cleared_link_count": 0,
585
+ }
586
+ )
587
+ return LinkRelatedSyncResult.from_payload(
588
+ sync_related_notes_operation_result(
589
+ config,
590
+ export_path=export_path,
591
+ apply=apply,
592
+ backup=backup,
593
+ receipt_path=receipt_path,
594
+ max_age_hours=max_age_hours,
595
+ allow_stale_note_hashes=allow_stale_note_hashes,
596
+ )
597
+ )
598
+
599
+
600
+ def _related_notes_payload(result: LinkRelatedSyncResult | None) -> JsonObject | None:
601
+ return _json_object(result.operation_payload) if result is not None else None
602
+
603
+
604
+ def _related_notes_required_inputs(result: LinkRelatedSyncResult) -> list[str]:
605
+ return result.required_inputs or ["wiki_dir", "related_notes_export"]
606
+
607
+
608
+ def _related_notes_pass_summary(kind: str, result: LinkRelatedSyncResult) -> RelatedNotesPassSummary:
609
+ return RelatedNotesPassSummary.from_sync_result(kind, result)
610
+
611
+
612
+ def _combined_related_notes_updates(results: list[LinkRelatedSyncResult]) -> list[JsonObject]:
613
+ combined: list[JsonObject] = []
614
+ seen: set[tuple[str, str]] = set()
615
+ for result in results:
616
+ for update in result.updates:
617
+ payload = _json_object(update.operation_payload)
618
+ path = update.path or _json_text(payload, "file")
619
+ key = (path, json.dumps(payload, ensure_ascii=False, sort_keys=True))
620
+ if key in seen:
621
+ continue
622
+ seen.add(key)
623
+ combined.append(payload)
624
+ return combined
625
+
626
+
627
+ def _convergence_state_from_blocked(result: LinkRelatedSyncResult) -> str:
628
+ if result.related_notes_recovery_state.status == "waiting_for_retry":
629
+ return "waiting_external"
630
+ if result.blocked_reason in {
631
+ "related_notes_headless_quota_exhausted",
632
+ "related_notes_headless_time_budget_exhausted",
633
+ }:
634
+ return "waiting_external"
635
+ return "blocked"
636
+
637
+
638
+ def _related_notes_convergence_result(
639
+ *,
640
+ base: LinkRelatedSyncResult,
641
+ status: str,
642
+ blocked_reason: str = "",
643
+ next_action: str = "",
644
+ applied_results: list[LinkRelatedSyncResult],
645
+ passes: list[RelatedNotesPassSummary],
646
+ final_planned_note_count: int,
647
+ max_passes: int,
648
+ extra: JsonObject | None = None,
649
+ ) -> LinkRelatedSyncResult:
650
+ result = _json_object(base.operation_payload)
651
+ if extra:
652
+ result.update(extra)
653
+ total_applied = sum(payload.applied_note_count for payload in applied_results)
654
+ operation_count = len(passes)
655
+ cycle_count = sum(1 for item in passes if item.kind == "apply")
656
+ blocked_probe = LinkRelatedSyncResult.from_payload({**result, "status": status, "blocked_reason": blocked_reason})
657
+ result.update(
658
+ {
659
+ "schema": RELATED_NOTES_SYNC_SCHEMA,
660
+ "phase": "related_notes_apply_convergence",
661
+ "status": status,
662
+ "blocked_reason": blocked_reason,
663
+ "next_action": next_action,
664
+ "planned_note_count": final_planned_note_count,
665
+ "applied_note_count": total_applied,
666
+ "updates": _combined_related_notes_updates(applied_results),
667
+ "convergence": {
668
+ "schema": "medical-notes-workbench.related-notes-convergence.v1",
669
+ "status": "stable" if status == "completed" else _convergence_state_from_blocked(blocked_probe),
670
+ "pass_count": cycle_count,
671
+ "max_passes": max_passes,
672
+ "cycle_count": cycle_count,
673
+ "max_cycles": max_passes,
674
+ "operation_count": operation_count,
675
+ "final_planned_note_count": final_planned_note_count,
676
+ "applied_note_count": total_applied,
677
+ "passes": [item.to_payload() for item in passes],
678
+ },
679
+ }
680
+ )
681
+ return LinkRelatedSyncResult.from_payload(result)
682
+
683
+
684
+ def _related_notes_convergence_base(config: MedConfig) -> LinkRelatedSyncResult:
685
+ export_path = default_export_path(config.wiki_dir)
686
+ return LinkRelatedSyncResult.from_payload(
687
+ {
688
+ "schema": RELATED_NOTES_SYNC_SCHEMA,
689
+ "phase": "related_notes_apply_convergence",
690
+ "status": "blocked",
691
+ "blocked_reason": "",
692
+ "next_action": "",
693
+ "required_inputs": ["wiki_dir", "related_notes_export"],
694
+ "human_decision_required": False,
695
+ "manual_instruction_allowed": False,
696
+ "wiki_dir": str(config.wiki_dir),
697
+ "export_path": str(export_path),
698
+ "planned_note_count": 0,
699
+ "proposed_link_count": 0,
700
+ "cleared_link_count": 0,
701
+ "skipped_edge_count": 0,
702
+ "applied_note_count": 0,
703
+ "updates": [],
704
+ }
705
+ )
706
+
707
+
708
+ def _related_notes_recovery_sync_result(value: object) -> LinkRelatedSyncResult:
709
+ """Project export recovery into the sync-result stream without erasing its type."""
710
+
711
+ raw = _json_object_or_empty(value)
712
+ nested_recovery = raw["related_notes_recovery_state"] if "related_notes_recovery_state" in raw else value
713
+ recovery = RelatedNotesRecoveryState.from_payload(nested_recovery)
714
+ # Keep the adapter's recovery evidence at the operation boundary. The typed
715
+ # recovery state drives resumability; stale-note/API evidence remains
716
+ # audit-only, but fix-wiki must still be able to prove what was recovered.
717
+ payload: JsonObject = {
718
+ **raw,
719
+ "schema": RELATED_NOTES_SYNC_SCHEMA,
720
+ "phase": "related_notes_export_recovery",
721
+ "status": recovery.status or _json_text(raw, "status"),
722
+ "blocked_reason": recovery.blocked_reason or _json_text(raw, "blocked_reason"),
723
+ "next_action": recovery.next_action or _json_text(raw, "next_action"),
724
+ "related_notes_recovery_state": recovery.to_payload(),
725
+ }
726
+ return LinkRelatedSyncResult.from_payload(
727
+ payload
728
+ )
729
+
730
+
731
+ def _converge_related_notes_sync(
732
+ config: MedConfig,
733
+ *,
734
+ backup: bool,
735
+ max_passes: int = RELATED_NOTES_CONVERGENCE_MAX_PASSES,
736
+ ) -> LinkRelatedSyncResult:
737
+ if not default_export_path(config.wiki_dir).is_file():
738
+ return run_related_notes_sync(config, apply=True, backup=backup)
739
+ applied_results: list[LinkRelatedSyncResult] = []
740
+ passes: list[RelatedNotesPassSummary] = []
741
+ last_payload = _related_notes_convergence_base(config)
742
+
743
+ for pass_index in range(1, max_passes + 1):
744
+ recovery = _related_notes_recovery_sync_result(
745
+ recover_related_notes_export_operation_result(
746
+ config,
747
+ mode="auto",
748
+ workflow="/mednotes:link",
749
+ run_id=f"related-notes-convergence-{pass_index}",
750
+ )
751
+ )
752
+ passes.append(_related_notes_pass_summary("recover_export", recovery))
753
+ if related_notes_sync_blocked(recovery):
754
+ return _related_notes_convergence_result(
755
+ base=last_payload,
756
+ status="blocked",
757
+ blocked_reason=recovery.blocked_reason or "related_notes_export_recovery_blocked",
758
+ next_action=recovery.next_action,
759
+ applied_results=applied_results,
760
+ passes=passes,
761
+ final_planned_note_count=last_payload.planned_note_count,
762
+ max_passes=max_passes,
763
+ extra={
764
+ "related_notes_export_recovery": recovery.operation_payload,
765
+ "related_notes_recovery_state": recovery.related_notes_recovery_state.operation_payload,
766
+ },
767
+ )
768
+
769
+ preview = run_related_notes_sync(config, apply=False, backup=False)
770
+ passes.append(_related_notes_pass_summary("dry_run", preview))
771
+ if preview.status == "skipped":
772
+ return preview
773
+ planned_note_count = preview.planned_note_count
774
+ if related_notes_sync_blocked(preview):
775
+ return _related_notes_convergence_result(
776
+ base=preview,
777
+ status="blocked",
778
+ blocked_reason=preview.blocked_reason or "related_notes_preview_blocked",
779
+ next_action=preview.next_action,
780
+ applied_results=applied_results,
781
+ passes=passes,
782
+ final_planned_note_count=planned_note_count,
783
+ max_passes=max_passes,
784
+ extra={"related_notes_export_recovery": recovery.operation_payload},
785
+ )
786
+ if planned_note_count == 0:
787
+ return _related_notes_convergence_result(
788
+ base=preview,
789
+ status="completed",
790
+ applied_results=applied_results,
791
+ passes=passes,
792
+ final_planned_note_count=0,
793
+ max_passes=max_passes,
794
+ extra={"related_notes_export_recovery": recovery.operation_payload},
795
+ )
796
+
797
+ last_payload = run_related_notes_sync(config, apply=True, backup=backup)
798
+ passes.append(_related_notes_pass_summary("apply", last_payload))
799
+ if related_notes_sync_blocked(last_payload):
800
+ return _related_notes_convergence_result(
801
+ base=last_payload,
802
+ status="blocked",
803
+ blocked_reason=last_payload.blocked_reason or "related_notes_apply_blocked",
804
+ next_action=last_payload.next_action,
805
+ applied_results=applied_results,
806
+ passes=passes,
807
+ final_planned_note_count=planned_note_count,
808
+ max_passes=max_passes,
809
+ extra={"related_notes_export_recovery": recovery.operation_payload},
810
+ )
811
+ applied_results.append(last_payload)
812
+ if last_payload.applied_note_count >= planned_note_count:
813
+ return _related_notes_convergence_result(
814
+ base=last_payload,
815
+ status="completed",
816
+ applied_results=applied_results,
817
+ passes=passes,
818
+ final_planned_note_count=0,
819
+ max_passes=max_passes,
820
+ extra={"related_notes_export_recovery": recovery.operation_payload},
821
+ )
822
+
823
+ return _related_notes_convergence_result(
824
+ base=last_payload,
825
+ status="blocked",
826
+ blocked_reason="related_notes_convergence_not_reached",
827
+ next_action="A sincronização de Notas Relacionadas ainda mudou após várias passadas; repetir pela rota oficial depois de revisar o relatório.",
828
+ applied_results=applied_results,
829
+ passes=passes,
830
+ final_planned_note_count=last_payload.planned_note_count,
831
+ max_passes=max_passes,
832
+ )
833
+
834
+
835
+ def _run_id() -> str:
836
+ return datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
837
+
838
+
839
+ def default_link_diagnosis_path() -> Path:
840
+ return _path(f"~/.mednotes/runs/{_run_id()}/link-diagnosis.json")
841
+
842
+
843
+ def default_link_receipt_path() -> Path:
844
+ return _path(f"~/.mednotes/runs/{_run_id()}/link-run-receipt.json")
845
+
846
+
847
+ def _first_heading_or_stem(text: str, path: Path) -> str:
848
+ for line in text.splitlines():
849
+ stripped = line.strip()
850
+ if stripped.startswith("# "):
851
+ return stripped[2:].strip() or path.stem
852
+ return path.stem
853
+
854
+
855
+ def _file_hash(path: Path | None) -> str:
856
+ if not path or not path.is_file():
857
+ return ""
858
+ return "sha256:" + file_sha256(path)
859
+
860
+
861
+ def _collect_snapshot(config: MedConfig) -> JsonObject:
862
+ notes: list[JsonObject] = []
863
+ if config.wiki_dir.is_dir():
864
+ for path in iter_notes(config.wiki_dir):
865
+ try:
866
+ text = path.read_text(encoding="utf-8")
867
+ except OSError as exc:
868
+ notes.append(
869
+ _json_object({
870
+ "path": path.relative_to(config.wiki_dir).as_posix(),
871
+ "read_error": str(exc),
872
+ })
873
+ )
874
+ continue
875
+ if _is_index_note(path, text):
876
+ continue
877
+ notes.append(
878
+ _json_object({
879
+ "path": path.relative_to(config.wiki_dir).as_posix(),
880
+ "stem": path.stem,
881
+ "title": _first_heading_or_stem(text, path),
882
+ "aliases": extract_aliases(text),
883
+ "content_hash": "sha256:" + file_sha256(path),
884
+ })
885
+ )
886
+ export_path = default_export_path(config.wiki_dir)
887
+ snapshot = JsonObjectAdapter.validate_python({
888
+ "wiki_dir": str(config.wiki_dir),
889
+ "wiki_dir_exists": config.wiki_dir.is_dir(),
890
+ "catalog_path": str(config.catalog_path) if config.catalog_path else "",
891
+ "catalog_hash": _file_hash(config.catalog_path),
892
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
893
+ "vocabulary_db_hash": _file_hash(config.vocabulary_db_path),
894
+ "related_notes_export_path": str(export_path) if export_path.is_file() else "",
895
+ "related_notes_export_hash": _file_hash(export_path),
896
+ "note_count": len(notes),
897
+ "notes": notes,
898
+ })
899
+ snapshot["snapshot_hash"] = "sha256:" + canonical_json_hash(snapshot)
900
+ return snapshot
901
+
902
+
903
+ def _git_context_for(config: MedConfig, link_state: LinkState | None = None) -> LinkGitContext:
904
+ """Collect typed Git context before any linker branch reads its fields."""
905
+
906
+ return collect_git_context(
907
+ config.wiki_dir,
908
+ previous_state=link_state if link_state is not None else load_link_state(),
909
+ )
910
+
911
+
912
+ def _git_trigger_context_for(
913
+ git_context: LinkGitContext,
914
+ *,
915
+ snapshot: JsonObject,
916
+ link_state: LinkState | None,
917
+ ) -> JsonObject | None:
918
+ if link_state is not None and link_state.snapshot_hash == _json_field(snapshot, "snapshot_hash"):
919
+ return None
920
+ trigger_context = trigger_context_from_git(git_context)
921
+ return trigger_context.to_payload() if trigger_context is not None else None
922
+
923
+
924
+ def _phase_status_from_linker(payload: JsonObject, *, phase: str) -> str:
925
+ if _json_field(payload, "error") or _json_field(payload, "parse_error"):
926
+ return "failed"
927
+ if _json_int(payload, "blocker_count"):
928
+ return "blocked"
929
+ if phase == "body_term_linker":
930
+ return (
931
+ "planned"
932
+ if _json_int(payload, "links_planned")
933
+ or _json_int(payload, "links_rewritten")
934
+ else "skipped"
935
+ )
936
+ return "planned"
937
+
938
+
939
+ def _graph_issues_from(body_linker: JsonObject) -> list[JsonObject]:
940
+ body_view = _BodyLinkerView.from_payload(body_linker)
941
+ graph = body_view.graph_audit_before
942
+ issues: list[JsonObject] = []
943
+ for key in ("errors", "warnings"):
944
+ values = graph.get(key)
945
+ if isinstance(values, list):
946
+ issues.extend(_json_object(item) for item in values if isinstance(item, dict))
947
+ return issues
948
+
949
+
950
+ def _diagnosis_phases(
951
+ body_linker: JsonObject,
952
+ related_notes: LinkRelatedSyncResult | None,
953
+ reference_repair: JsonObject,
954
+ ) -> JsonObject:
955
+ body_view = _BodyLinkerView.from_payload(body_linker)
956
+ repair_view = _ReferenceRepairView.from_payload(reference_repair)
957
+ related_status = "skipped"
958
+ if related_notes is not None:
959
+ if related_notes_sync_blocked(related_notes):
960
+ related_status = "blocked"
961
+ elif related_notes.planned_note_count:
962
+ related_status = "planned"
963
+ else:
964
+ related_status = related_notes.status or "skipped"
965
+ contextual = body_view.contextual_alias_disambiguation
966
+ return _json_object({
967
+ "reference_repair": {
968
+ "status": repair_view.status,
969
+ "affected_note_count": repair_view.affected_note_count,
970
+ "action_count": repair_view.action_count,
971
+ "blocking_action_count": repair_view.blocking_action_count,
972
+ "human_decision_count": repair_view.human_decision_count,
973
+ "triage_count": repair_view.triage_count,
974
+ },
975
+ "contextual_alias_disambiguation": {
976
+ "status": contextual.status,
977
+ "mode": contextual.mode,
978
+ "candidate_count": contextual.candidate_count,
979
+ "linked_count": contextual.linked_count,
980
+ "deferred_count": contextual.deferred_count,
981
+ "no_link_count": contextual.no_link_count,
982
+ "rejected_count": contextual.rejected_count,
983
+ "skipped_reason": contextual.skipped_reason,
984
+ "blocked_reason": contextual.blocked_reason,
985
+ },
986
+ "body_term_linker": {
987
+ "status": _phase_status_from_linker(body_linker, phase="body_term_linker"),
988
+ "blocked_reason": body_view.blocked_reason,
989
+ "links_planned": body_view.links_planned,
990
+ "links_rewritten": body_view.links_rewritten,
991
+ },
992
+ "related_notes_sync": {
993
+ "status": related_status,
994
+ "planned_note_count": related_notes.planned_note_count if related_notes is not None else 0,
995
+ "skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
996
+ "blocked_reason": related_notes.blocked_reason if related_notes is not None else "",
997
+ },
998
+ "graph_validation": {
999
+ "status": "blocked" if body_view.blocker_count else "planned",
1000
+ "blocker_count": body_view.blocker_count,
1001
+ },
1002
+ })
1003
+
1004
+
1005
+ def _collect_blockers(
1006
+ body_linker: JsonObject,
1007
+ related_notes: LinkRelatedSyncResult | None,
1008
+ reference_repair: JsonObject | None = None,
1009
+ ) -> list[JsonObject]:
1010
+ repair_view = _ReferenceRepairView.from_payload(reference_repair) if reference_repair is not None else None
1011
+ body_view = _BodyLinkerView.from_payload(body_linker)
1012
+ blocker_list = [_json_object(item) for item in body_view.blockers if isinstance(item, dict)]
1013
+ if repair_view is not None and repair_view.blocking_action_count:
1014
+ blocker_list.append(
1015
+ _json_object({
1016
+ "code": "reference_repair_blocked",
1017
+ "message": "Há WikiLinks ausentes/ambíguos ou alvos estruturais que exigem reparo antes do apply.",
1018
+ "blocking_action_count": repair_view.blocking_action_count,
1019
+ "human_decision_count": repair_view.human_decision_count,
1020
+ })
1021
+ )
1022
+ if related_notes is not None and related_notes_sync_blocked(related_notes):
1023
+ blocker_list.append(
1024
+ _json_object({
1025
+ "code": related_notes.blocked_reason or "related_notes_blocked",
1026
+ "message": related_notes.next_action,
1027
+ })
1028
+ )
1029
+ return blocker_list
1030
+
1031
+
1032
+ def _diagnosis_next_action(
1033
+ *,
1034
+ blockers: list[JsonObject],
1035
+ body_linker: JsonObject,
1036
+ related_notes: LinkRelatedSyncResult | None,
1037
+ ) -> str:
1038
+ if related_notes is not None and related_notes_sync_blocked(related_notes):
1039
+ return related_notes.next_action or "Corrigir o export Related Notes antes de aplicar."
1040
+ if blockers:
1041
+ body_view = _BodyLinkerView.from_payload(body_linker)
1042
+ return body_view.next_action or "Rodar /mednotes:fix-wiki --dry-run para resolver blockers semânticos antes de aplicar."
1043
+ return "Aplicar com run-linker --apply --diagnosis <link-diagnosis.json>."
1044
+
1045
+
1046
+ def _diagnosis_required_inputs(*, related_notes: LinkRelatedSyncResult | None) -> list[str]:
1047
+ if related_notes is not None and related_notes_sync_blocked(related_notes):
1048
+ return _related_notes_required_inputs(related_notes)
1049
+ return LINK_REQUIRED_INPUTS
1050
+
1051
+
1052
+ def _curator_batch_next_action(plan_path: Path) -> str:
1053
+ return (
1054
+ "/mednotes:link deve continuar a curadoria do grafo: lançar um "
1055
+ f"med-link-graph-curator por work_items[] em {plan_path}, escrever um "
1056
+ "vocabulary-curator-batch-output-manifest.v1, rodar "
1057
+ f"eval-curator-batch --plan {plan_path} --outputs <manifest.json> "
1058
+ "--report <curator-prompt-eval.json> --json, validar com "
1059
+ f"apply-curator-batch --plan {plan_path} --outputs <manifest.json> "
1060
+ "--validate-only --json e aplicar com --prompt-eval antes de repetir "
1061
+ "run-linker --diagnose."
1062
+ )
1063
+
1064
+
1065
+ def _link_vocabulary_curator_batch(
1066
+ config: MedConfig,
1067
+ *,
1068
+ diagnosis_path: Path,
1069
+ body_linker: JsonObject,
1070
+ ) -> tuple[JsonObject, str, str]:
1071
+ body_view = _BodyLinkerView.from_payload(body_linker)
1072
+ if body_view.blocked_reason != "vocabulary_semantic_ingestion_pending":
1073
+ return {}, "", ""
1074
+ if config.vocabulary_db_path is None:
1075
+ return {}, "", ""
1076
+ run_dir = diagnosis_path.parent
1077
+ plan_path = run_dir / "vocabulary-curator-batch-plan.json"
1078
+ plan = build_vocabulary_curator_batch_plan(
1079
+ db_path=config.vocabulary_db_path,
1080
+ batch_id=f"link:{diagnosis_path.stem}",
1081
+ output_dir=run_dir / "vocabulary-curator-outputs",
1082
+ )
1083
+ atomic_write_text(plan_path, json.dumps(plan, ensure_ascii=False, indent=2) + "\n")
1084
+ return _json_object(plan), str(plan_path), _curator_batch_next_action(plan_path)
1085
+
1086
+
1087
+ def _dedupe_texts(values: list[str]) -> list[str]:
1088
+ seen: set[str] = set()
1089
+ result: list[str] = []
1090
+ for value in values:
1091
+ text = str(value).strip()
1092
+ normalized = normalize_key(text)
1093
+ if not text or not normalized or normalized in seen:
1094
+ continue
1095
+ seen.add(normalized)
1096
+ result.append(text)
1097
+ return result
1098
+
1099
+
1100
+ def _sync_vocabulary_notes_from_wiki(config: MedConfig) -> None:
1101
+ if config.vocabulary_db_path is None:
1102
+ return
1103
+ initialize_vocabulary_db(config.vocabulary_db_path)
1104
+ with sqlite3.connect(config.vocabulary_db_path) as conn:
1105
+ for path in iter_notes(config.wiki_dir) if config.wiki_dir.exists() else []:
1106
+ text = path.read_text(encoding="utf-8")
1107
+ if _is_index_note(path, text):
1108
+ continue
1109
+ upsert_note(conn, path=path, title=infer_title(text, path), content_hash=note_content_hash(path))
1110
+
1111
+
1112
+ def _yaml_aliases_by_note_id(conn: sqlite3.Connection) -> dict[int, list[str]]:
1113
+ aliases: dict[int, list[str]] = {}
1114
+ rows = conn.execute(
1115
+ """
1116
+ SELECT note_id, alias_text
1117
+ FROM yaml_alias_claims
1118
+ WHERE visible_in_yaml = 1
1119
+ AND claim_status != 'conflicting_alias'
1120
+ ORDER BY note_id, normalized_surface
1121
+ """
1122
+ ).fetchall()
1123
+ for note_id, alias_text in rows:
1124
+ aliases.setdefault(int(note_id), []).append(str(alias_text))
1125
+ return aliases
1126
+
1127
+
1128
+ def _baseline_semantic_ingestion_items(config: MedConfig) -> list[JsonObject]:
1129
+ if config.vocabulary_db_path is None or not config.vocabulary_db_path.exists():
1130
+ return []
1131
+ _sync_vocabulary_notes_from_wiki(config)
1132
+ with sqlite3.connect(config.vocabulary_db_path) as conn:
1133
+ conn.row_factory = sqlite3.Row
1134
+ yaml_aliases = _yaml_aliases_by_note_id(conn)
1135
+ rows = conn.execute(
1136
+ """
1137
+ SELECT id, path, title, content_hash
1138
+ FROM notes
1139
+ WHERE status = 'active'
1140
+ ORDER BY path
1141
+ """
1142
+ ).fetchall()
1143
+ items: list[JsonObject] = []
1144
+ for row in rows:
1145
+ note_path = Path(str(row["path"]))
1146
+ if not note_path.is_file():
1147
+ continue
1148
+ text = note_path.read_text(encoding="utf-8")
1149
+ title = infer_title(text, note_path)
1150
+ title_norm = normalize_key(title)
1151
+ aliases = _dedupe_texts([title, note_path.stem, *extract_aliases(text), *yaml_aliases.get(int(row["id"]), [])])
1152
+ item_aliases: list[JsonObject] = []
1153
+ for alias in aliases:
1154
+ alias_norm = normalize_key(alias)
1155
+ item_aliases.append(
1156
+ _json_object({
1157
+ "text": alias,
1158
+ "kind": "canonical_title" if alias_norm == title_norm else "alias",
1159
+ "link_policy": "direct" if alias_norm == title_norm else "requires_context",
1160
+ "visible_in_yaml": True,
1161
+ "intrinsically_ambiguous": alias_norm != title_norm,
1162
+ "source": "system",
1163
+ })
1164
+ )
1165
+ items.append(
1166
+ _json_object({
1167
+ "schema": "medical-notes-workbench.note-semantic-ingestion.v1",
1168
+ "workflow": "/mednotes:link",
1169
+ "phase": "vocabulary_curation",
1170
+ "agent": "med-link-graph-curator",
1171
+ "source_workflow": "/mednotes:link",
1172
+ "note_path": str(note_path),
1173
+ "content_hash": note_content_hash(note_path),
1174
+ "primary_meaning": {
1175
+ "label": title,
1176
+ "semantic_type": "medical_concept",
1177
+ "atomic_status": "unknown",
1178
+ },
1179
+ "aliases": item_aliases,
1180
+ "deferred_work_items": [],
1181
+ "confidence": 0.72,
1182
+ "source": "system",
1183
+ })
1184
+ )
1185
+ return items
1186
+
1187
+
1188
+ def _drop_unresolved_surfaces(db_path: Path) -> int:
1189
+ initialize_vocabulary_db(db_path)
1190
+ with sqlite3.connect(db_path) as conn:
1191
+ rows = conn.execute(
1192
+ """
1193
+ SELECT s.id
1194
+ FROM surfaces s
1195
+ LEFT JOIN surface_meaning_policy p ON p.surface_id = s.id
1196
+ WHERE p.id IS NULL
1197
+ """
1198
+ ).fetchall()
1199
+ conn.executemany("DELETE FROM surfaces WHERE id = ?", [(int(row[0]),) for row in rows])
1200
+ return len(rows)
1201
+
1202
+
1203
+ def _direct_ambiguous_surface_repair_needed(diagnosis: JsonObject) -> bool:
1204
+ issue_payload = _json_field(diagnosis, "issues")
1205
+ issues = issue_payload if isinstance(issue_payload, list) else []
1206
+ return any(
1207
+ isinstance(issue, dict) and issue.get("code") == "vocabulary_map.direct_policy_on_ambiguous_surface"
1208
+ for issue in issues
1209
+ )
1210
+
1211
+
1212
+ def _human_vocabulary_issues_are_auto_contextualizable(diagnosis: JsonObject) -> bool:
1213
+ issue_payload = _json_field(diagnosis, "issues", [])
1214
+ issues = [_json_object(issue) for issue in issue_payload if isinstance(issue, dict)] if isinstance(issue_payload, list) else []
1215
+ human_issues = [
1216
+ issue
1217
+ for issue in issues
1218
+ if issue.get("severity") == "human_decision"
1219
+ or issue.get("code")
1220
+ in {
1221
+ "vocabulary_map.duplicate_meaning",
1222
+ "vocabulary_map.non_atomic_note",
1223
+ "vocabulary_map.conflicting_alias",
1224
+ "vocabulary_map.direct_policy_on_ambiguous_surface",
1225
+ }
1226
+ ]
1227
+ return bool(human_issues) and all(
1228
+ issue.get("code") == "vocabulary_map.direct_policy_on_ambiguous_surface"
1229
+ for issue in human_issues
1230
+ )
1231
+
1232
+
1233
+ def _contextualize_direct_policies_for_ambiguous_surfaces(db_path: Path) -> int:
1234
+ initialize_vocabulary_db(db_path)
1235
+ with sqlite3.connect(db_path) as conn:
1236
+ rows = conn.execute(
1237
+ """
1238
+ SELECT p.id
1239
+ FROM surface_meaning_policy p
1240
+ JOIN surfaces s ON s.id = p.surface_id
1241
+ WHERE p.link_policy = 'direct'
1242
+ AND p.surface_id IN (
1243
+ SELECT s2.id
1244
+ FROM surfaces s2
1245
+ JOIN surface_meaning_policy p2 ON p2.surface_id = s2.id
1246
+ GROUP BY s2.id
1247
+ HAVING COUNT(DISTINCT p2.meaning_id) > 1
1248
+ OR MAX(s2.intrinsically_ambiguous) = 1
1249
+ )
1250
+ ORDER BY p.id
1251
+ """
1252
+ ).fetchall()
1253
+ policy_ids = [(int(row[0]),) for row in rows]
1254
+ conn.executemany(
1255
+ """
1256
+ UPDATE surface_meaning_policy
1257
+ SET link_policy='requires_context', updated_at=CURRENT_TIMESTAMP
1258
+ WHERE id = ?
1259
+ """,
1260
+ policy_ids,
1261
+ )
1262
+ return len(policy_ids)
1263
+
1264
+
1265
+ def _vocabulary_repair_needed(diagnosis: JsonObject) -> bool:
1266
+ diagnosis_view = _VocabularyMapDiagnosisView.from_payload(diagnosis)
1267
+ if diagnosis_view.status == "blocked_human" and not _human_vocabulary_issues_are_auto_contextualizable(diagnosis):
1268
+ return False
1269
+ if diagnosis_view.pending_semantic_ingestion_count > 0:
1270
+ return True
1271
+ if _direct_ambiguous_surface_repair_needed(diagnosis):
1272
+ return True
1273
+ if any(
1274
+ issue.code == "vocabulary_map.unresolved_surfaces_without_meanings"
1275
+ for issue in diagnosis_view.issues
1276
+ ):
1277
+ return True
1278
+ return diagnosis_view.note_count > 0 and diagnosis_view.meaning_count == 0
1279
+
1280
+
1281
+ def _registered_blocker_requires_human(code: str, *, fallback: bool) -> bool:
1282
+ try:
1283
+ return blocker_entry(code).requires_human_packet
1284
+ except Exception:
1285
+ return fallback
1286
+
1287
+
1288
+ def _attach_registered_decision_summary(
1289
+ item: JsonObject,
1290
+ *,
1291
+ phase: str,
1292
+ fallback_code: str,
1293
+ ) -> JsonObject:
1294
+ issue = _VocabularyMapIssueView.model_validate(item)
1295
+ if issue.decision_summary is not None:
1296
+ return item
1297
+ code = issue.code or fallback_code
1298
+ try:
1299
+ decision = decision_for_code(
1300
+ code,
1301
+ phase=phase,
1302
+ public_summary=issue.message or "Bloqueio recuperavel no vocabulario.",
1303
+ developer_summary=issue.message or code,
1304
+ next_action=issue.next_action or "Continuar pelo fluxo oficial do workflow.",
1305
+ )
1306
+ except Exception:
1307
+ return item
1308
+ enriched = _json_object(item)
1309
+ enriched["decision_summary"] = decision.decision_summary()
1310
+ return _json_object(enriched)
1311
+
1312
+
1313
+ def _diagnosis_requests_vocabulary_repair(diagnosis: JsonObject) -> bool:
1314
+ body = _json_field(diagnosis, "body_term_linker")
1315
+ body_reason = _BodyLinkerView.from_payload(body).blocked_reason
1316
+ if body_reason in {"vocabulary_semantic_ingestion_pending", "vocabulary_map_blocked"}:
1317
+ return True
1318
+ vocabulary_map = _json_field(diagnosis, "vocabulary_map_diagnosis")
1319
+ if isinstance(vocabulary_map, dict) and _vocabulary_repair_needed(_json_object(vocabulary_map)):
1320
+ return True
1321
+ blocker_payload = _json_field(diagnosis, "blockers")
1322
+ blockers = blocker_payload if isinstance(blocker_payload, list) else []
1323
+ return any(
1324
+ _json_text(_json_object(blocker), "code")
1325
+ in {"vocabulary_semantic_ingestion_pending", "vocabulary_map.unresolved_surfaces_without_meanings"}
1326
+ if isinstance(blocker, dict)
1327
+ else False
1328
+ for blocker in blockers
1329
+ )
1330
+
1331
+
1332
+ def repair_vocabulary_semantics_for_link(
1333
+ config: MedConfig,
1334
+ *,
1335
+ run_dir: Path | None = None,
1336
+ trigger: str = "link_apply",
1337
+ ) -> JsonObject:
1338
+ if config.vocabulary_db_path is None:
1339
+ return _json_object({
1340
+ "schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
1341
+ "status": "skipped",
1342
+ "skipped_reason": "vocabulary_db_unconfigured",
1343
+ "applied_count": 0,
1344
+ "blocked_count": 0,
1345
+ })
1346
+ db_path = config.vocabulary_db_path
1347
+ if not db_path.exists():
1348
+ return _json_object({
1349
+ "schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
1350
+ "status": "skipped",
1351
+ "skipped_reason": "vocabulary_db_missing",
1352
+ "db_path": str(db_path),
1353
+ "applied_count": 0,
1354
+ "blocked_count": 0,
1355
+ })
1356
+ before = _json_object(load_vocabulary_map_diagnosis(db_path).as_diagnosis_dict())
1357
+ before_view = _VocabularyMapDiagnosisView.from_payload(before)
1358
+ if not _vocabulary_repair_needed(before):
1359
+ return _json_object({
1360
+ "schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
1361
+ "status": "skipped",
1362
+ "skipped_reason": "vocabulary_already_ready"
1363
+ if before_view.status == "ready"
1364
+ else "human_decision_required",
1365
+ "trigger": trigger,
1366
+ "db_path": str(db_path),
1367
+ "diagnosis_before": before,
1368
+ "applied_count": 0,
1369
+ "blocked_count": 0,
1370
+ })
1371
+ items = _baseline_semantic_ingestion_items(config)
1372
+ receipts: list[JsonObject] = []
1373
+ applied_count = 0
1374
+ blocked_count = 0
1375
+ with sqlite3.connect(db_path) as conn:
1376
+ for item in items:
1377
+ try:
1378
+ receipt = apply_semantic_ingestion(db_path=db_path, item=item, conn=conn)
1379
+ except ValidationError as exc:
1380
+ item_view = _SemanticIngestionItemView.from_payload(item)
1381
+ receipt = _json_object({
1382
+ "schema": "medical-notes-workbench.note-semantic-ingestion-apply-receipt.v1",
1383
+ "status": "blocked",
1384
+ "blocked_reason": "semantic_ingestion.validation_error",
1385
+ "error": str(exc),
1386
+ "note_path": item_view.note_path,
1387
+ "content_hash": item_view.content_hash,
1388
+ })
1389
+ receipt = _json_object(receipt)
1390
+ if _json_field(receipt, "status") == "applied":
1391
+ applied_count += 1
1392
+ else:
1393
+ blocked_count += 1
1394
+ receipts.append(receipt)
1395
+ dropped_orphan_surface_count = _drop_unresolved_surfaces(db_path)
1396
+ contextualized_direct_policy_count = _contextualize_direct_policies_for_ambiguous_surfaces(db_path)
1397
+ after = _json_object(load_vocabulary_map_diagnosis(db_path).as_diagnosis_dict())
1398
+ after_view = _VocabularyMapDiagnosisView.from_payload(after)
1399
+ status = "completed" if after_view.status == "ready" else "completed_with_blockers"
1400
+ payload = _json_object({
1401
+ "schema": "medical-notes-workbench.vocabulary-semantic-repair.v1",
1402
+ "phase": "vocabulary_semantic_repair",
1403
+ "status": status,
1404
+ "trigger": trigger,
1405
+ "db_path": str(db_path),
1406
+ "diagnosis_before": before,
1407
+ "diagnosis_after": after,
1408
+ "item_count": len(items),
1409
+ "applied_count": applied_count,
1410
+ "blocked_count": blocked_count,
1411
+ "dropped_orphan_surface_count": dropped_orphan_surface_count,
1412
+ "contextualized_direct_policy_count": contextualized_direct_policy_count,
1413
+ "receipts": receipts,
1414
+ })
1415
+ if status != "completed":
1416
+ payload = _json_object(
1417
+ {
1418
+ **payload,
1419
+ "blocked_reason": _json_text(after, "status", "vocabulary_semantic_repair_blocked"),
1420
+ "next_action": "Resolver decisões humanas ou erros de ingestão restantes pelo workflow /mednotes:link.",
1421
+ }
1422
+ )
1423
+ if run_dir is not None:
1424
+ run_dir.mkdir(parents=True, exist_ok=True)
1425
+ receipt_path = run_dir / "vocabulary-semantic-repair-receipt.json"
1426
+ atomic_write_text(receipt_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1427
+ payload["receipt_path"] = str(receipt_path)
1428
+ return _json_object(payload)
1429
+
1430
+
1431
+ def _body_only_fallback(
1432
+ *,
1433
+ path: Path,
1434
+ body_linker: JsonObject,
1435
+ related_notes: LinkRelatedSyncResult | None,
1436
+ reference_repair: JsonObject,
1437
+ ) -> JsonObject | None:
1438
+ if related_notes is None or not related_notes_sync_blocked(related_notes):
1439
+ return None
1440
+ body_view = _BodyLinkerView.from_payload(body_linker)
1441
+ repair_view = _ReferenceRepairView.from_payload(reference_repair)
1442
+ body_blocked = bool(body_view.error or body_view.parse_error or body_view.blocker_count)
1443
+ reference_blocked = bool(repair_view.blocking_action_count)
1444
+ blocked_phases: list[str] = []
1445
+ if body_blocked:
1446
+ blocked_phases.append("body_term_linker")
1447
+ if reference_blocked:
1448
+ blocked_phases.append("reference_repair")
1449
+ recovery_command = wiki_cli_command("related-notes-sync", "--recover-export", "--mode", "auto", "--json")
1450
+ if blocked_phases:
1451
+ return _json_object({
1452
+ "safe": False,
1453
+ "command": "",
1454
+ "diagnosis_path": str(path),
1455
+ "allowed_scope": [],
1456
+ "excluded_scope": ["related_notes_sync"],
1457
+ "blocked_phases": blocked_phases,
1458
+ "expected_changed_counts": {
1459
+ "modified": 0,
1460
+ "deleted": 0,
1461
+ "created": 0,
1462
+ "links_planned": body_view.links_planned,
1463
+ "reference_actions": repair_view.action_count,
1464
+ },
1465
+ "reason": "Related Notes está bloqueado e pelo menos uma fase body/reference também não está segura.",
1466
+ "next_action": recovery_command,
1467
+ })
1468
+ return _json_object({
1469
+ "safe": True,
1470
+ "command": "/mednotes:link-body",
1471
+ "cli_command": wiki_cli_command("run-linker", "--apply", "--no-related-notes", "--diagnosis", str(path), "--json"),
1472
+ "diagnosis_path": str(path),
1473
+ "allowed_scope": ["reference_repair", "body_term_linker"],
1474
+ "excluded_scope": ["related_notes_sync"],
1475
+ "blocked_phases": [],
1476
+ "expected_changed_counts": {
1477
+ "modified": max(
1478
+ body_view.files_changed,
1479
+ repair_view.affected_note_count,
1480
+ ),
1481
+ "deleted": 0,
1482
+ "created": 0,
1483
+ "links_planned": body_view.links_planned,
1484
+ "reference_actions": repair_view.action_count,
1485
+ },
1486
+ "reason": "Related Notes export está stale, mas as fases body/reference não dependem do export.",
1487
+ "next_action": "/mednotes:link-body",
1488
+ })
1489
+
1490
+
1491
+ def _skipped_image_only_diagnosis(
1492
+ config: MedConfig,
1493
+ *,
1494
+ path: Path,
1495
+ trigger_context: JsonObject,
1496
+ git_context: LinkGitContext | None = None,
1497
+ ) -> JsonObject:
1498
+ snapshot = _collect_snapshot(config)
1499
+ git_context = git_context or _git_context_for(config)
1500
+ git_payload = git_context.to_payload()
1501
+ git_view = _GitContextView.from_payload(git_payload)
1502
+ phases = JsonObjectAdapter.validate_python({
1503
+ phase: {"status": "skipped"}
1504
+ for phase in LINK_PHASE_ORDER
1505
+ })
1506
+ plan_payload = JsonObjectAdapter.validate_python({"phase_order": list(LINK_PHASE_ORDER), "phases": phases})
1507
+ payload = _json_object({
1508
+ "schema": LINK_DIAGNOSIS_SCHEMA,
1509
+ "generated_at": _now_iso(),
1510
+ "phase": "link_diagnosis",
1511
+ "status": "skipped",
1512
+ "blocked_reason": "",
1513
+ "skipped_reason": "image_only_changes",
1514
+ "next_action": "",
1515
+ "required_inputs": LINK_REQUIRED_INPUTS,
1516
+ "human_decision_required": False,
1517
+ "diagnosis_path": str(path),
1518
+ "wiki_dir": str(config.wiki_dir),
1519
+ "catalog_path": str(config.catalog_path) if config.catalog_path else None,
1520
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
1521
+ "trigger_context": trigger_context,
1522
+ "triggers_detected": derive_triggers(trigger_context),
1523
+ "affected_notes": affected_notes_from_context(trigger_context),
1524
+ "git": git_payload,
1525
+ "git_status_hash": git_view.status_hash,
1526
+ "snapshot": snapshot,
1527
+ "snapshot_hash": snapshot["snapshot_hash"],
1528
+ "plan": plan_payload,
1529
+ "plan_hash": "sha256:" + canonical_json_hash(plan_payload),
1530
+ "phases": phases,
1531
+ "reference_repair": {
1532
+ "schema": "medical-notes-workbench.reference-repair-plan.v1",
1533
+ "phase": "reference_repair",
1534
+ "status": "skipped",
1535
+ "package_mode": "diagnosis_bound",
1536
+ "manual_script_allowed": False,
1537
+ "requires_backup": False,
1538
+ "requires_receipt": True,
1539
+ "action_count": 0,
1540
+ "affected_note_count": 0,
1541
+ "blocking_action_count": 0,
1542
+ "human_decision_count": 0,
1543
+ "triage_count": 0,
1544
+ "human_decision_required": False,
1545
+ "triage_required": False,
1546
+ "note_actions": [],
1547
+ "structural_actions": [],
1548
+ "catalog_actions": [],
1549
+ "human_decision_packets": [],
1550
+ },
1551
+ "human_decision_packets": [],
1552
+ "links_planned": 0,
1553
+ "links_rewritten": 0,
1554
+ "blocker_count": 0,
1555
+ "blockers": [],
1556
+ "body_term_linker": None,
1557
+ "related_notes_sync": None,
1558
+ "related_notes_skipped_reason": "",
1559
+ "returncode": 0,
1560
+ })
1561
+ atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1562
+ return payload
1563
+
1564
+
1565
+ def _run_body_linker(
1566
+ config: MedConfig,
1567
+ *,
1568
+ dry_run: bool,
1569
+ llm_disambiguation: str = "off",
1570
+ llm_model: str | None = None,
1571
+ llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
1572
+ llm_disambiguator: Callable[..., object] | None = None,
1573
+ ) -> JsonObject:
1574
+ if config.vocabulary_db_path is None:
1575
+ graph_before = graph_audit(config) if config.wiki_dir.exists() else {}
1576
+ return _json_object({
1577
+ "ok": False,
1578
+ "blocked": True,
1579
+ "dry_run": dry_run,
1580
+ "phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
1581
+ "status": "blocked",
1582
+ "blocked_reason": "vocabulary_db_unconfigured",
1583
+ "next_action": "Configure vocabulary_db_path e rode /mednotes:fix-wiki --apply para instanciar o DB.",
1584
+ "required_inputs": ["vocabulary_db_path"],
1585
+ "human_decision_required": False,
1586
+ "body_linker_mode": "vocabulary_db",
1587
+ "body_linker_skipped_reason": "vocabulary_db_unconfigured",
1588
+ "vocabulary_db_path": "",
1589
+ "returncode": 3,
1590
+ "files_scanned": 0,
1591
+ "files_changed": 0,
1592
+ "links_planned": 0,
1593
+ "links_rewritten": 0,
1594
+ "blocker_count": 1,
1595
+ "blockers": [
1596
+ {
1597
+ "code": "vocabulary_db_unconfigured",
1598
+ "message": "O linker atual exige vocabulary DB configurado antes de aplicar body links.",
1599
+ }
1600
+ ],
1601
+ "graph_audit_before": graph_before,
1602
+ "plans": [],
1603
+ })
1604
+ if config.vocabulary_db_path and config.vocabulary_db_path.exists():
1605
+ graph_before = graph_audit(config)
1606
+ vocabulary_map_diagnosis = load_vocabulary_map_diagnosis(config.vocabulary_db_path).as_diagnosis_dict()
1607
+ vocabulary_view = _VocabularyMapDiagnosisView.from_payload(vocabulary_map_diagnosis)
1608
+ vocabulary_status = vocabulary_view.status
1609
+ if vocabulary_status in {"blocked_pending", "blocked_human"}:
1610
+ pending_count = vocabulary_view.pending_semantic_ingestion_count
1611
+ blocked_reason = "vocabulary_semantic_ingestion_pending" if pending_count else "vocabulary_map_blocked"
1612
+ blockers: list[JsonObject] = []
1613
+ for issue in vocabulary_view.issues:
1614
+ item = _json_object({
1615
+ "code": issue.code or blocked_reason,
1616
+ "message": issue.message,
1617
+ "next_action": issue.next_action,
1618
+ "required_inputs": issue.required_inputs,
1619
+ })
1620
+ if issue.decision_summary is not None:
1621
+ item = _json_object({**item, "decision_summary": issue.decision_summary})
1622
+ blockers.append(
1623
+ _attach_registered_decision_summary(
1624
+ item,
1625
+ phase="link_diagnosis",
1626
+ fallback_code=blocked_reason,
1627
+ )
1628
+ )
1629
+ if not blockers:
1630
+ blockers = [
1631
+ _attach_registered_decision_summary(
1632
+ _json_object({
1633
+ "code": blocked_reason,
1634
+ "message": "Vocabulary DB is not ready for body linker.",
1635
+ }),
1636
+ phase="link_diagnosis",
1637
+ fallback_code=blocked_reason,
1638
+ )
1639
+ ]
1640
+ human_decision_required = any(
1641
+ _registered_blocker_requires_human(
1642
+ _json_text(item, "code", blocked_reason),
1643
+ fallback=vocabulary_status == "blocked_human",
1644
+ )
1645
+ for item in blockers
1646
+ )
1647
+ return _json_object({
1648
+ "ok": False,
1649
+ "blocked": True,
1650
+ "dry_run": dry_run,
1651
+ "phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
1652
+ "status": "blocked",
1653
+ "blocked_reason": blocked_reason,
1654
+ "next_action": (
1655
+ "Continuar a curadoria semântica dentro de /mednotes:link antes de linkar o corpo."
1656
+ if blocked_reason == "vocabulary_semantic_ingestion_pending"
1657
+ else "Reconciliar o vocabulary DB pelo fluxo oficial de /mednotes:link antes de projetar aliases ou linkar o corpo."
1658
+ ),
1659
+ "required_inputs": ["vocabulary_semantic_ingestion"] if pending_count else ["vocabulary_recovery"],
1660
+ "human_decision_required": human_decision_required,
1661
+ "body_linker_mode": "vocabulary_db",
1662
+ "body_linker_skipped_reason": blocked_reason,
1663
+ "vocabulary_db_path": str(config.vocabulary_db_path),
1664
+ "vocabulary_map_diagnosis": vocabulary_map_diagnosis,
1665
+ "pending_semantic_ingestion_count": pending_count,
1666
+ "returncode": 3,
1667
+ "files_scanned": 0,
1668
+ "files_changed": 0,
1669
+ "links_planned": 0,
1670
+ "links_rewritten": 0,
1671
+ "blocker_count": len(blockers),
1672
+ "blockers": blockers,
1673
+ "graph_audit_before": graph_before,
1674
+ "plans": [],
1675
+ })
1676
+ payload = _json_object(run_db_body_linker(
1677
+ wiki_dir=config.wiki_dir,
1678
+ db_path=config.vocabulary_db_path,
1679
+ dry_run=dry_run,
1680
+ llm_mode=llm_disambiguation if dry_run else "off",
1681
+ llm_model=llm_model,
1682
+ llm_timeout=llm_timeout,
1683
+ llm_disambiguator=llm_disambiguator,
1684
+ ))
1685
+ payload["returncode"] = 3 if _json_field(payload, "blocked") else 0
1686
+ payload["graph_audit_before"] = graph_before
1687
+ payload["vocabulary_map_diagnosis"] = vocabulary_map_diagnosis
1688
+ return _json_object(payload)
1689
+
1690
+ return _body_linker_blocked_for_vocabulary_bootstrap(
1691
+ config,
1692
+ dry_run=dry_run,
1693
+ vocabulary_bootstrap=_json_object(planned_vocabulary_bootstrap(config)),
1694
+ )
1695
+
1696
+
1697
+ def _body_linker_blocked_for_vocabulary_bootstrap(
1698
+ config: MedConfig,
1699
+ *,
1700
+ dry_run: bool,
1701
+ vocabulary_bootstrap: JsonObject,
1702
+ ) -> JsonObject:
1703
+ graph_before = graph_audit(config) if config.wiki_dir.exists() else {}
1704
+ bootstrap_view = _VocabularyBootstrapView.from_payload(vocabulary_bootstrap)
1705
+ if bootstrap_view.note_count == 0:
1706
+ return _json_object({
1707
+ "ok": True,
1708
+ "blocked": False,
1709
+ "dry_run": dry_run,
1710
+ "phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
1711
+ "status": "skipped",
1712
+ "blocked_reason": "",
1713
+ "skipped_reason": "vocabulary_bootstrap_empty_wiki",
1714
+ "next_action": "",
1715
+ "required_inputs": [],
1716
+ "human_decision_required": False,
1717
+ "body_linker_mode": "vocabulary_db",
1718
+ "body_linker_skipped_reason": "vocabulary_bootstrap_empty_wiki",
1719
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
1720
+ "vocabulary_bootstrap": vocabulary_bootstrap,
1721
+ "returncode": 0,
1722
+ "files_scanned": 0,
1723
+ "files_changed": 0,
1724
+ "links_planned": 0,
1725
+ "links_rewritten": 0,
1726
+ "blocker_count": 0,
1727
+ "blockers": [],
1728
+ "graph_audit_before": graph_before,
1729
+ "plans": [],
1730
+ })
1731
+ return _json_object({
1732
+ "ok": False,
1733
+ "blocked": True,
1734
+ "dry_run": dry_run,
1735
+ "phase": "run_linker_dry_run" if dry_run else "run_linker_apply",
1736
+ "status": "blocked",
1737
+ "blocked_reason": "vocabulary_bootstrap_required",
1738
+ "next_action": (
1739
+ "Resolver pendências do linker/grafo: instanciar o vocabulary DB via workflow apply, "
1740
+ "processar a fila com med-link-graph-curator e repetir o diagnóstico de links."
1741
+ ),
1742
+ "required_inputs": ["vocabulary_bootstrap", "vocabulary_semantic_ingestion"],
1743
+ "human_decision_required": False,
1744
+ "body_linker_mode": "vocabulary_db",
1745
+ "body_linker_skipped_reason": "vocabulary_bootstrap_required",
1746
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
1747
+ "vocabulary_bootstrap": vocabulary_bootstrap,
1748
+ "returncode": 3,
1749
+ "files_scanned": 0,
1750
+ "files_changed": 0,
1751
+ "links_planned": 0,
1752
+ "links_rewritten": 0,
1753
+ "blocker_count": 1,
1754
+ "blockers": [
1755
+ {
1756
+ "code": "vocabulary_bootstrap_required",
1757
+ "message": "O DB de vocabulário ainda não existe; diagnóstico não instancia nem limpa notas.",
1758
+ }
1759
+ ],
1760
+ "graph_audit_before": graph_before,
1761
+ "plans": [],
1762
+ })
1763
+
1764
+
1765
+ def diagnose_links(
1766
+ config: MedConfig,
1767
+ *,
1768
+ diagnosis_path: Path | None = None,
1769
+ include_related_notes: bool = True,
1770
+ force_diagnose: bool = False,
1771
+ trigger_context: JsonObject | None = None,
1772
+ llm_disambiguation: str = "auto",
1773
+ llm_model: str | None = None,
1774
+ llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
1775
+ llm_disambiguator: Callable[..., object] | None = None,
1776
+ ) -> JsonObject:
1777
+ path = diagnosis_path or default_link_diagnosis_path()
1778
+ link_state = load_link_state()
1779
+ git_context = _git_context_for(config, link_state)
1780
+ git_payload = git_context.to_payload()
1781
+ if not config.wiki_dir.exists():
1782
+ message = f"Wiki dir não encontrado: {config.wiki_dir}"
1783
+ git_view = _GitContextView.from_payload(git_payload)
1784
+ payload = _json_object({
1785
+ "schema": LINK_DIAGNOSIS_SCHEMA,
1786
+ "generated_at": _now_iso(),
1787
+ "phase": "link_diagnosis",
1788
+ "status": "failed",
1789
+ "blocked_reason": "linker_error",
1790
+ "next_action": "Corrigir --wiki-dir ou [paths].wiki_dir e rodar o diagnóstico novamente.",
1791
+ "required_inputs": LINK_REQUIRED_INPUTS,
1792
+ "human_decision_required": False,
1793
+ "diagnosis_path": str(path),
1794
+ "wiki_dir": str(config.wiki_dir),
1795
+ "catalog_path": str(config.catalog_path) if config.catalog_path else None,
1796
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
1797
+ "git": git_payload,
1798
+ "git_status_hash": git_view.status_hash,
1799
+ "error": message,
1800
+ "returncode": 4,
1801
+ })
1802
+ atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1803
+ return payload
1804
+ if trigger_context is not None and is_image_only_context(trigger_context):
1805
+ return _skipped_image_only_diagnosis(config, path=path, trigger_context=trigger_context, git_context=git_context)
1806
+ vocabulary_bootstrap = _json_object(planned_vocabulary_bootstrap(config))
1807
+ trigger_snapshot = _collect_snapshot(config)
1808
+ effective_trigger_context = trigger_context or _git_trigger_context_for(git_context, snapshot=trigger_snapshot, link_state=link_state)
1809
+ identity = build_diagnosis_identity(
1810
+ snapshot=trigger_snapshot,
1811
+ git_context=git_payload,
1812
+ trigger_context=effective_trigger_context,
1813
+ include_related_notes=include_related_notes,
1814
+ llm_disambiguation=llm_disambiguation,
1815
+ llm_model=llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
1816
+ llm_timeout=llm_timeout,
1817
+ )
1818
+ if not force_diagnose:
1819
+ redundant = redundant_diagnosis_payload(link_state.to_payload() if link_state is not None else {}, identity)
1820
+ if redundant is not None:
1821
+ return redundant
1822
+ bootstrap_view = _VocabularyBootstrapView.from_payload(vocabulary_bootstrap)
1823
+ body_linker = (
1824
+ _body_linker_blocked_for_vocabulary_bootstrap(
1825
+ config,
1826
+ dry_run=True,
1827
+ vocabulary_bootstrap=vocabulary_bootstrap,
1828
+ )
1829
+ if bootstrap_view.status == "planned"
1830
+ else _run_body_linker(
1831
+ config,
1832
+ dry_run=True,
1833
+ llm_disambiguation=llm_disambiguation,
1834
+ llm_model=llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
1835
+ llm_timeout=llm_timeout,
1836
+ llm_disambiguator=llm_disambiguator,
1837
+ )
1838
+ )
1839
+ vocabulary_curator_batch_plan, vocabulary_curator_batch_plan_path, vocabulary_curator_next_action = (
1840
+ _link_vocabulary_curator_batch(
1841
+ config,
1842
+ diagnosis_path=path,
1843
+ body_linker=body_linker,
1844
+ )
1845
+ )
1846
+ if vocabulary_curator_next_action:
1847
+ body_linker = _json_object({**body_linker, "next_action": vocabulary_curator_next_action})
1848
+ related_notes = run_related_notes_sync(config, apply=False, backup=False) if include_related_notes else None
1849
+ related_notes_payload = _related_notes_payload(related_notes)
1850
+ body_view = _BodyLinkerView.from_payload(body_linker)
1851
+ contextual_diagnosis = body_view.contextual_alias_disambiguation
1852
+ # The body linker can persist contextual LLM decisions into the vocabulary
1853
+ # DB during diagnosis. Persist the post-diagnosis snapshot only when that
1854
+ # happened so ordinary diagnosis still reuses the trigger snapshot.
1855
+ snapshot = _collect_snapshot(config) if contextual_diagnosis.decision_count else trigger_snapshot
1856
+ reference_repair = _json_object(plan_reference_repair(
1857
+ _graph_issues_from(body_linker),
1858
+ structural_events=structural_events_from_context(effective_trigger_context),
1859
+ ))
1860
+ repair_view = _ReferenceRepairView.from_payload(reference_repair)
1861
+ blockers = _collect_blockers(body_linker, related_notes, reference_repair)
1862
+ phases = _diagnosis_phases(body_linker, related_notes, reference_repair)
1863
+ human_decision_packets = list(repair_view.human_decision_packets)
1864
+ failed = bool(body_view.error or body_view.parse_error)
1865
+ status = "failed" if failed else "blocked" if blockers else "diagnosis_ready"
1866
+ blocked_reason = (
1867
+ "linker_error"
1868
+ if failed
1869
+ else "link_plan_blocked"
1870
+ if blockers
1871
+ else ""
1872
+ )
1873
+ plan = _json_object({
1874
+ "phase_order": list(LINK_PHASE_ORDER),
1875
+ "phases": phases,
1876
+ "reference_repair": reference_repair,
1877
+ "body_term_linker": body_linker,
1878
+ "related_notes_sync": related_notes_payload,
1879
+ "llm_disambiguation": {
1880
+ "mode": llm_disambiguation,
1881
+ "model": llm_model or DEFAULT_LLM_DISAMBIGUATION_MODEL,
1882
+ "timeout_seconds": llm_timeout,
1883
+ },
1884
+ })
1885
+ if vocabulary_curator_batch_plan:
1886
+ plan["vocabulary_curation"] = {
1887
+ "status": _json_text(vocabulary_curator_batch_plan, "status"),
1888
+ "item_count": _json_int(vocabulary_curator_batch_plan, "item_count"),
1889
+ "plan_path": vocabulary_curator_batch_plan_path,
1890
+ }
1891
+ plan_hash = "sha256:" + canonical_json_hash(plan)
1892
+ payload = _json_object({
1893
+ "schema": LINK_DIAGNOSIS_SCHEMA,
1894
+ "generated_at": _now_iso(),
1895
+ "phase": "link_diagnosis",
1896
+ "status": status,
1897
+ "blocked_reason": blocked_reason,
1898
+ "next_action": _diagnosis_next_action(blockers=blockers, body_linker=body_linker, related_notes=related_notes),
1899
+ "required_inputs": _diagnosis_required_inputs(related_notes=related_notes),
1900
+ "human_decision_required": bool(human_decision_packets),
1901
+ "diagnosis_path": str(path),
1902
+ "wiki_dir": str(config.wiki_dir),
1903
+ "catalog_path": str(config.catalog_path) if config.catalog_path else None,
1904
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
1905
+ "vocabulary_bootstrap": vocabulary_bootstrap,
1906
+ "vocabulary_map_diagnosis": body_view.vocabulary_map_diagnosis.to_payload(),
1907
+ "vocabulary_curator_batch_plan": vocabulary_curator_batch_plan,
1908
+ "vocabulary_curator_batch_plan_path": vocabulary_curator_batch_plan_path,
1909
+ "vocabulary_curator_next_action": vocabulary_curator_next_action,
1910
+ "trigger_context": effective_trigger_context,
1911
+ "triggers_detected": derive_triggers(effective_trigger_context),
1912
+ "affected_notes": affected_notes_from_context(effective_trigger_context),
1913
+ "git": git_payload,
1914
+ "git_status_hash": _GitContextView.from_payload(git_payload).status_hash,
1915
+ "snapshot": snapshot,
1916
+ "snapshot_hash": snapshot["snapshot_hash"],
1917
+ "plan": plan,
1918
+ "plan_hash": plan_hash,
1919
+ "phases": phases,
1920
+ "reference_repair": reference_repair,
1921
+ "human_decision_packets": human_decision_packets,
1922
+ "links_planned": body_view.links_planned,
1923
+ "links_rewritten": body_view.links_rewritten,
1924
+ "blocker_count": len(blockers),
1925
+ "blockers": blockers,
1926
+ "body_term_linker": body_linker,
1927
+ "contextual_alias_disambiguation": body_view.contextual_alias_disambiguation.to_payload(),
1928
+ "related_notes_sync": related_notes_payload,
1929
+ "related_notes_skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
1930
+ "body_only_fallback": _body_only_fallback(
1931
+ path=path,
1932
+ body_linker=body_linker,
1933
+ related_notes=related_notes,
1934
+ reference_repair=reference_repair,
1935
+ ),
1936
+ "agent_events": [force_diagnose_event(diagnosis_path=path)] if force_diagnose else [],
1937
+ "returncode": body_view.returncode,
1938
+ })
1939
+ if body_view.error:
1940
+ payload["error"] = body_view.error
1941
+ if body_view.parse_error:
1942
+ payload["parse_error"] = body_view.parse_error
1943
+ atomic_write_text(path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
1944
+ record_diagnosis_attempt(payload, identity=identity)
1945
+ return _json_object(payload)
1946
+
1947
+
1948
+ def _load_diagnosis(path: Path) -> JsonObject:
1949
+ try:
1950
+ data = json.loads(path.read_text(encoding="utf-8"))
1951
+ except FileNotFoundError as exc:
1952
+ raise ValidationError(f"Diagnóstico de links não encontrado: {path}") from exc
1953
+ except json.JSONDecodeError as exc:
1954
+ raise ValidationError(f"Diagnóstico de links inválido: {path}: {exc}") from exc
1955
+ if not isinstance(data, dict) or data.get("schema") != LINK_DIAGNOSIS_SCHEMA:
1956
+ raise ValidationError(f"Diagnóstico de links precisa usar schema {LINK_DIAGNOSIS_SCHEMA}.")
1957
+ return _json_object(data)
1958
+
1959
+
1960
+ def _changed_files_from(*payloads: JsonObject | None) -> list[str]:
1961
+ changed: set[str] = set()
1962
+ for payload in payloads:
1963
+ if not payload:
1964
+ continue
1965
+ plans = _json_field(payload, "plans")
1966
+ if isinstance(plans, list):
1967
+ for plan in plans:
1968
+ plan_view = _LinkBodyPlanView.from_payload(plan)
1969
+ if plan_view.changed and plan_view.file:
1970
+ changed.add(plan_view.file)
1971
+ updates = _json_field(payload, "updates")
1972
+ if isinstance(updates, list):
1973
+ for update in updates:
1974
+ if not isinstance(update, dict) or update.get("changed") is False:
1975
+ continue
1976
+ file_path = update.get("file") or update.get("path")
1977
+ if isinstance(file_path, str):
1978
+ changed.add(file_path)
1979
+ changed_files = _json_field(payload, "changed_files")
1980
+ if isinstance(changed_files, list):
1981
+ for item in changed_files:
1982
+ if isinstance(item, str):
1983
+ changed.add(item)
1984
+ return sorted(changed)
1985
+
1986
+
1987
+ def _snapshot_note_hashes(snapshot: JsonObject) -> dict[str, str]:
1988
+ notes = snapshot.get("notes")
1989
+ if not isinstance(notes, list):
1990
+ return {}
1991
+ hashes: dict[str, str] = {}
1992
+ for note in notes:
1993
+ note_view = _SnapshotNoteView.from_payload(note)
1994
+ if note_view.path:
1995
+ hashes[note_view.path] = note_view.content_hash
1996
+ return hashes
1997
+
1998
+
1999
+ def _relative_receipt_path(config: MedConfig, value: str) -> str:
2000
+ path = Path(value)
2001
+ if path.is_absolute():
2002
+ try:
2003
+ return path.resolve().relative_to(config.wiki_dir.resolve()).as_posix()
2004
+ except (OSError, ValueError):
2005
+ return path.as_posix()
2006
+ return path.as_posix()
2007
+
2008
+
2009
+ def _safe_link_action(action: JsonObject, *, phase: str) -> JsonObject:
2010
+ allowed = (
2011
+ "action",
2012
+ "target",
2013
+ "old_target",
2014
+ "new_target",
2015
+ "replacement",
2016
+ "receipt_code",
2017
+ "line",
2018
+ "term",
2019
+ "matched_text",
2020
+ "display_text",
2021
+ "start",
2022
+ "end",
2023
+ "source",
2024
+ "occurrence_id",
2025
+ "context_hash",
2026
+ "confidence",
2027
+ "reason_code",
2028
+ )
2029
+ clean: JsonObject = {"phase": phase}
2030
+ for key in allowed:
2031
+ value = action.get(key)
2032
+ if value not in (None, ""):
2033
+ clean[key] = value
2034
+ return _json_object(clean)
2035
+
2036
+
2037
+ def _phase_file_actions(
2038
+ config: MedConfig,
2039
+ *,
2040
+ reference_apply: JsonObject | None,
2041
+ body_linker: JsonObject,
2042
+ related_notes: LinkRelatedSyncResult | None,
2043
+ ) -> dict[str, JsonObject]:
2044
+ by_path: dict[str, JsonObject] = {}
2045
+
2046
+ def entry(path_value: str) -> JsonObject:
2047
+ rel = _relative_receipt_path(config, path_value)
2048
+ return by_path.setdefault(rel, {"path": rel, "phases": [], "actions": [], "backup_paths": []})
2049
+
2050
+ reference_apply_view = _ReferenceApplyView.from_payload(reference_apply)
2051
+ for report in reference_apply_view.reports:
2052
+ report_payload = _json_object(report)
2053
+ report_path = _json_field(report_payload, "path")
2054
+ if not _json_field(report_payload, "changed") or not isinstance(report_path, str):
2055
+ continue
2056
+ item = entry(report_path)
2057
+ item["phases"].append("reference_repair")
2058
+ backup_path = _json_text(report_payload, "backup_path")
2059
+ if backup_path:
2060
+ item["backup_paths"].append(backup_path)
2061
+ actions = _json_field(report_payload, "actions")
2062
+ if isinstance(actions, list):
2063
+ item["actions"].extend(
2064
+ _safe_link_action(action, phase="reference_repair")
2065
+ for action in actions
2066
+ if isinstance(action, dict)
2067
+ )
2068
+
2069
+ body_view = _BodyLinkerView.from_payload(body_linker)
2070
+ for plan_view in body_view.plans:
2071
+ if not plan_view.changed or not plan_view.file:
2072
+ continue
2073
+ item = entry(plan_view.file)
2074
+ item["phases"].append("body_term_linker")
2075
+ for insertion in plan_view.insertions:
2076
+ if isinstance(insertion, dict):
2077
+ item["actions"].append(
2078
+ _json_object({
2079
+ "phase": "body_term_linker",
2080
+ "action": "insert_body_wikilink",
2081
+ **_safe_link_action(insertion, phase="body_term_linker"),
2082
+ })
2083
+ )
2084
+ for rewrite in plan_view.rewrites:
2085
+ if isinstance(rewrite, dict):
2086
+ item["actions"].append(
2087
+ _json_object({
2088
+ "phase": "body_term_linker",
2089
+ "action": "rewrite_body_wikilink",
2090
+ **_safe_link_action(rewrite, phase="body_term_linker"),
2091
+ })
2092
+ )
2093
+
2094
+ if related_notes is not None:
2095
+ for update_model in related_notes.updates:
2096
+ path_value = update_model.relative_path or update_model.path or update_model.file
2097
+ if not path_value:
2098
+ continue
2099
+ item = entry(path_value)
2100
+ item["phases"].append("related_notes_sync")
2101
+ if update_model.backup_path:
2102
+ item["backup_paths"].append(update_model.backup_path)
2103
+ item["actions"].append(
2104
+ _json_object({
2105
+ "phase": "related_notes_sync",
2106
+ "action": "rewrite_related_notes_section",
2107
+ "cleared_link_count": update_model.cleared_link_count,
2108
+ "proposed_link_count": len(update_model.proposed_links),
2109
+ })
2110
+ )
2111
+ for value in by_path.values():
2112
+ value["phases"] = sorted(set(value["phases"]))
2113
+ value["backup_paths"] = sorted(set(value["backup_paths"]))
2114
+ return by_path
2115
+
2116
+
2117
+ def _file_changes(
2118
+ *,
2119
+ config: MedConfig,
2120
+ before_snapshot: JsonObject,
2121
+ after_snapshot: JsonObject,
2122
+ reference_apply: JsonObject | None,
2123
+ body_linker: JsonObject,
2124
+ related_notes: LinkRelatedSyncResult | None,
2125
+ ) -> list[JsonObject]:
2126
+ before_hashes = _snapshot_note_hashes(before_snapshot)
2127
+ after_hashes = _snapshot_note_hashes(after_snapshot)
2128
+ action_map = _phase_file_actions(config, reference_apply=reference_apply, body_linker=body_linker, related_notes=related_notes)
2129
+ changed_paths = set(action_map)
2130
+ for raw_path in _changed_files_from(reference_apply, body_linker, _related_notes_payload(related_notes)):
2131
+ changed_paths.add(_relative_receipt_path(config, raw_path))
2132
+ changes: list[JsonObject] = []
2133
+ for rel in sorted(path for path in changed_paths if path):
2134
+ detail = action_map.get(rel, {"path": rel, "phases": [], "actions": [], "backup_paths": []})
2135
+ changes.append(
2136
+ _json_object({
2137
+ "path": rel,
2138
+ "phases": detail.get("phases", []),
2139
+ "before_hash": before_hashes.get(rel, ""),
2140
+ "after_hash": after_hashes.get(rel, ""),
2141
+ "actions": detail.get("actions", []),
2142
+ "backup_paths": detail.get("backup_paths", []),
2143
+ })
2144
+ )
2145
+ return changes
2146
+
2147
+
2148
+ def _trigger_context_summary(diagnosis: JsonObject) -> JsonObject:
2149
+ context = _json_object_or_empty(_json_field(diagnosis, "trigger_context"))
2150
+ source = _json_field(context, "source_workflow", "manual")
2151
+ return _json_object({
2152
+ "source_workflow": source or "manual",
2153
+ "triggers_detected": _json_field(diagnosis, "triggers_detected", []),
2154
+ "affected_notes": _json_field(diagnosis, "affected_notes", []),
2155
+ })
2156
+
2157
+
2158
+ def _receipt_skips(diagnosis: JsonObject, related_notes: LinkRelatedSyncResult | None) -> list[JsonObject]:
2159
+ skips: list[JsonObject] = []
2160
+ phases = _json_field(diagnosis, "phases")
2161
+ for phase, details in phases.items() if isinstance(phases, dict) else []:
2162
+ if isinstance(details, dict) and details.get("status") == "skipped":
2163
+ skips.append(_json_object({"phase": phase, "reason": details.get("skipped_reason") or "skipped"}))
2164
+ if related_notes is not None and related_notes.skipped_reason:
2165
+ skips.append(_json_object({"phase": "related_notes_sync", "reason": related_notes.skipped_reason}))
2166
+ body_view = _BodyLinkerView.from_payload(_json_field(diagnosis, "body_term_linker"))
2167
+ for plan_view in body_view.plans:
2168
+ for item in plan_view.skipped:
2169
+ skip_view = _LinkPlanSkipView.from_payload(item)
2170
+ skips.append(
2171
+ _json_object({
2172
+ "phase": "contextual_alias_disambiguation",
2173
+ "path": _relative_receipt_path_from_diagnosis(diagnosis, plan_view.file),
2174
+ "occurrence_id": skip_view.occurrence_id,
2175
+ "reason": skip_view.reason_code or skip_view.action or "contextual_alias_skipped",
2176
+ "action": skip_view.action,
2177
+ })
2178
+ )
2179
+ return skips
2180
+
2181
+
2182
+ def _link_apply_safety_preflight_block(
2183
+ *,
2184
+ diagnosis_path: Path,
2185
+ diagnosis: JsonObject,
2186
+ version_control_guard_active: bool,
2187
+ vocabulary_repair_requested: bool,
2188
+ ) -> JsonObject | None:
2189
+ """Stop mutating apply routes after stale checks and before resource writes."""
2190
+
2191
+ if _diagnosis_has_mutating_guard_safety(diagnosis):
2192
+ return None
2193
+ planned_change_count = _diagnosis_planned_change_count(diagnosis)
2194
+ if planned_change_count <= 0 and not vocabulary_repair_requested:
2195
+ return None
2196
+ if version_control_guard_active:
2197
+ return None
2198
+ return _json_object({
2199
+ "schema": LINK_RUN_SCHEMA,
2200
+ "phase": "link_apply_preflight",
2201
+ "status": "blocked",
2202
+ "blocked_reason": "version_control_safety_evidence_missing",
2203
+ "next_action": (
2204
+ "Abrir o ponto de restauração do vault pela rota oficial, repetir a conferência se o diagnóstico "
2205
+ "ficar obsoleto e só então aplicar."
2206
+ ),
2207
+ "required_inputs": ["version_control_safety"],
2208
+ "human_decision_required": False,
2209
+ "diagnosis_path": str(diagnosis_path),
2210
+ "planned_changed_file_count": planned_change_count,
2211
+ "vocabulary_repair_requested": vocabulary_repair_requested,
2212
+ "changed_files": [],
2213
+ "files_changed": 0,
2214
+ "returncode": 3,
2215
+ })
2216
+
2217
+
2218
+ def _diagnosis_has_mutating_guard_safety(diagnosis: JsonObject) -> bool:
2219
+ """Only real guard evidence can authorize a mutating apply attempt."""
2220
+
2221
+ payload = _diagnosis_version_control_safety_payload(diagnosis)
2222
+ if not payload:
2223
+ return False
2224
+ return bool(
2225
+ _json_field(payload, "resource_guard_active")
2226
+ and _json_field(payload, "run_start_seen")
2227
+ and _json_field(payload, "run_finish_seen")
2228
+ and not _json_field(payload, "no_resource_mutation")
2229
+ )
2230
+
2231
+
2232
+ def _diagnosis_version_control_safety_payload(diagnosis: JsonObject) -> JsonObject:
2233
+ direct = _json_object_or_empty(_json_field(diagnosis, "version_control_safety"))
2234
+ if direct:
2235
+ return direct
2236
+ receipt = _json_object_or_empty(_json_field(diagnosis, "receipt"))
2237
+ nested = _json_object_or_empty(_json_field(receipt, "version_control_safety"))
2238
+ if nested:
2239
+ return nested
2240
+ guard_receipt = _json_object_or_empty(_json_field(diagnosis, "guard_receipt"))
2241
+ return _json_object_or_empty(_json_field(guard_receipt, "version_control_safety"))
2242
+
2243
+
2244
+ def _diagnosis_planned_change_count(diagnosis: JsonObject) -> int:
2245
+ changed_files = _json_field(diagnosis, "changed_files")
2246
+ return max(
2247
+ _strict_non_negative_int(_json_field(diagnosis, "files_changed")),
2248
+ len(changed_files) if isinstance(changed_files, list) else 0,
2249
+ _body_linker_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "body_term_linker"))),
2250
+ _reference_repair_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "reference_repair"))),
2251
+ _related_notes_planned_change_count(_json_object_or_empty(_json_field(diagnosis, "related_notes_sync"))),
2252
+ )
2253
+
2254
+
2255
+ def _body_linker_planned_change_count(body_linker: JsonObject) -> int:
2256
+ plans = _json_field(body_linker, "plans")
2257
+ if isinstance(plans, list):
2258
+ return sum(1 for plan in plans if _json_field(_json_object_or_empty(plan), "changed") is True)
2259
+ return max(
2260
+ _strict_non_negative_int(_json_field(body_linker, "files_changed")),
2261
+ _strict_non_negative_int(_json_field(body_linker, "links_planned")),
2262
+ _strict_non_negative_int(_json_field(body_linker, "links_rewritten")),
2263
+ )
2264
+
2265
+
2266
+ def _reference_repair_planned_change_count(reference_repair: JsonObject) -> int:
2267
+ return max(
2268
+ _strict_non_negative_int(_json_field(reference_repair, "changed_file_count")),
2269
+ _strict_non_negative_int(_json_field(reference_repair, "affected_note_count")),
2270
+ _strict_non_negative_int(_json_field(reference_repair, "action_count")),
2271
+ )
2272
+
2273
+
2274
+ def _related_notes_planned_change_count(related_notes: JsonObject) -> int:
2275
+ updates = _json_field(related_notes, "updates")
2276
+ if isinstance(updates, list):
2277
+ return sum(1 for update in updates if _json_field(_json_object_or_empty(update), "changed") is True)
2278
+ return max(
2279
+ _strict_non_negative_int(_json_field(related_notes, "applied_note_count")),
2280
+ _strict_non_negative_int(_json_field(related_notes, "update_count")),
2281
+ )
2282
+
2283
+
2284
+ def _strict_non_negative_int(value: object) -> int:
2285
+ if isinstance(value, bool):
2286
+ return 0
2287
+ if isinstance(value, int) and value >= 0:
2288
+ return value
2289
+ return 0
2290
+
2291
+
2292
+ def _relative_receipt_path_from_diagnosis(diagnosis: JsonObject, value: str) -> str:
2293
+ if not value:
2294
+ return ""
2295
+ path = Path(value)
2296
+ wiki_dir = Path(_LinkDiagnosisView.from_payload(diagnosis).wiki_dir)
2297
+ if path.is_absolute() and str(wiki_dir):
2298
+ try:
2299
+ return path.resolve().relative_to(wiki_dir.resolve()).as_posix()
2300
+ except (OSError, ValueError):
2301
+ return path.as_posix()
2302
+ return path.as_posix()
2303
+
2304
+
2305
+ def _write_link_receipt(
2306
+ path: Path,
2307
+ *,
2308
+ config: MedConfig,
2309
+ diagnosis: JsonObject,
2310
+ snapshot_before: JsonObject,
2311
+ snapshot_after: JsonObject,
2312
+ git_before: JsonObject,
2313
+ git_after: JsonObject,
2314
+ body_linker: JsonObject,
2315
+ related_notes: LinkRelatedSyncResult | None,
2316
+ reference_apply: JsonObject | None,
2317
+ graph_after: JsonObject,
2318
+ ) -> JsonObject:
2319
+ diagnosis_view = _LinkDiagnosisView.from_payload(diagnosis)
2320
+ body_view = _BodyLinkerView.from_payload(body_linker)
2321
+ graph_view = _GraphAuditView.from_payload(graph_after)
2322
+ git_before_view = _GitContextView.from_payload(git_before)
2323
+ git_after_view = _GitContextView.from_payload(git_after)
2324
+ related_notes_payload = _related_notes_payload(related_notes)
2325
+ changed_files = _changed_files_from(reference_apply, body_linker, related_notes_payload)
2326
+ file_changes = _file_changes(
2327
+ config=config,
2328
+ before_snapshot=snapshot_before,
2329
+ after_snapshot=snapshot_after,
2330
+ reference_apply=reference_apply,
2331
+ body_linker=body_linker,
2332
+ related_notes=related_notes,
2333
+ )
2334
+ reference_apply_view = _ReferenceApplyView.from_payload(reference_apply)
2335
+ reference_repair_payload = diagnosis_view.reference_repair.to_payload()
2336
+ contextual = body_view.contextual_alias_disambiguation
2337
+ phase_receipts = _json_object({
2338
+ "reference_repair": {
2339
+ "status": reference_apply_view.status or diagnosis_view.reference_repair.status or "skipped",
2340
+ "affected_note_count": diagnosis_view.reference_repair.affected_note_count,
2341
+ "action_count": diagnosis_view.reference_repair.action_count,
2342
+ "blocking_action_count": diagnosis_view.reference_repair.blocking_action_count,
2343
+ "human_decision_count": diagnosis_view.reference_repair.human_decision_count,
2344
+ "triage_count": diagnosis_view.reference_repair.triage_count,
2345
+ "changed_file_count": reference_apply_view.changed_file_count,
2346
+ },
2347
+ "contextual_alias_disambiguation": {
2348
+ "status": contextual.status,
2349
+ "mode": contextual.mode,
2350
+ "candidate_count": contextual.candidate_count,
2351
+ "decision_count": contextual.decision_count,
2352
+ "linked_count": contextual.linked_count,
2353
+ "deferred_count": contextual.deferred_count,
2354
+ "no_link_count": contextual.no_link_count,
2355
+ "rejected_count": contextual.rejected_count,
2356
+ "skipped_reason": contextual.skipped_reason,
2357
+ "blocked_reason": contextual.blocked_reason,
2358
+ },
2359
+ "body_term_linker": {
2360
+ "status": "completed" if body_view.returncode == 0 else "blocked",
2361
+ "links_planned": body_view.links_planned,
2362
+ "links_rewritten": body_view.links_rewritten,
2363
+ },
2364
+ "related_notes_sync": {
2365
+ "status": related_notes.status if related_notes is not None else "skipped",
2366
+ "applied_note_count": related_notes.applied_note_count if related_notes is not None else 0,
2367
+ "skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
2368
+ },
2369
+ "graph_validation": {
2370
+ "status": "completed" if not graph_view.error_count else "blocked",
2371
+ "error_count": graph_view.error_count,
2372
+ "warning_count": graph_view.warning_count,
2373
+ },
2374
+ })
2375
+ body_or_graph_blocked = bool(
2376
+ body_view.blocker_count or graph_view.error_count
2377
+ )
2378
+ related_notes_required = _related_notes_required_for_apply(diagnosis)
2379
+ related_notes_blocked = _related_notes_apply_blocked(related_notes, required=related_notes_required)
2380
+ blocked = bool(body_or_graph_blocked or related_notes_blocked)
2381
+ blocked_reason = _link_apply_blocked_reason(
2382
+ body_or_graph_blocked=body_or_graph_blocked,
2383
+ related_notes_blocked=related_notes_blocked,
2384
+ )
2385
+ # The apply receipt carries the same guard evidence that authorized the
2386
+ # diagnosis, preserving a single audit trail for mutating linker work.
2387
+ version_control_safety = _diagnosis_version_control_safety_payload(diagnosis)
2388
+ receipt = _json_object({
2389
+ "schema": LINK_RUN_RECEIPT_SCHEMA,
2390
+ "generated_at": _now_iso(),
2391
+ "phase": "link_apply",
2392
+ "status": "completed_with_link_blockers" if blocked else "completed",
2393
+ "blocked_reason": blocked_reason,
2394
+ "next_action": _link_apply_next_action(blocked_reason=blocked_reason, related_notes=related_notes),
2395
+ "required_inputs": [*LINK_REQUIRED_INPUTS, "diagnosis"],
2396
+ "human_decision_required": False,
2397
+ "receipt_path": str(path),
2398
+ "wiki_dir": str(config.wiki_dir),
2399
+ "catalog_path": str(config.catalog_path) if config.catalog_path else None,
2400
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
2401
+ "vocabulary_bootstrap": _json_field(diagnosis, "vocabulary_bootstrap", {}),
2402
+ "diagnosis_path": diagnosis_view.diagnosis_path,
2403
+ "diagnosis_hash": "sha256:" + canonical_json_hash(diagnosis),
2404
+ "plan_hash": _json_field(diagnosis, "plan_hash", ""),
2405
+ "snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
2406
+ "trigger_context_summary": _trigger_context_summary(diagnosis),
2407
+ "git": {
2408
+ "available": git_before_view.available,
2409
+ "repo_root": git_before_view.repo_root,
2410
+ "branch": git_before_view.branch,
2411
+ "head_before": git_before_view.head,
2412
+ "head_after": git_after_view.head,
2413
+ "status_hash_before": git_before_view.status_hash,
2414
+ "status_hash_after": git_after_view.status_hash,
2415
+ "changed_paths_before": git_before_view.changed_paths,
2416
+ "changed_paths_after": git_after_view.changed_paths,
2417
+ "unavailable_reason": git_before_view.unavailable_reason,
2418
+ },
2419
+ "snapshots": {
2420
+ "diagnosis_snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
2421
+ "before_hash": snapshot_before.get("snapshot_hash", ""),
2422
+ "after_hash": snapshot_after.get("snapshot_hash", ""),
2423
+ "note_count_before": int(snapshot_before.get("note_count", 0) or 0),
2424
+ "note_count_after": int(snapshot_after.get("note_count", 0) or 0),
2425
+ },
2426
+ "phases": phase_receipts,
2427
+ "phase_receipts": phase_receipts,
2428
+ "changed_files": changed_files,
2429
+ "files_changed": len(changed_files),
2430
+ "file_changes": file_changes,
2431
+ "version_control_safety": version_control_safety,
2432
+ "protected_zone_checks": {
2433
+ "status": "completed",
2434
+ "strategy": "Fases de apply usam spans protegidos para YAML, headings, code, tabelas, footer e seção Notas Relacionadas quando aplicável.",
2435
+ },
2436
+ "blockers": _json_field(diagnosis, "blockers", []),
2437
+ "skips": _receipt_skips(diagnosis, related_notes),
2438
+ "rollback": {
2439
+ "type": "git" if git_before.get("available") else "backup",
2440
+ "details": "Use os pontos de restauração/version control do vault para rollback.",
2441
+ },
2442
+ "reference_repair": reference_repair_payload,
2443
+ "reference_repair_apply": reference_apply,
2444
+ "body_term_linker": body_linker,
2445
+ "related_notes_sync": related_notes_payload,
2446
+ "graph_audit_after": graph_after,
2447
+ })
2448
+ atomic_write_text(path, json.dumps(receipt, ensure_ascii=False, indent=2) + "\n")
2449
+ return _json_object(receipt)
2450
+
2451
+
2452
+ def apply_link_diagnosis(
2453
+ config: MedConfig,
2454
+ *,
2455
+ diagnosis_path: Path,
2456
+ receipt_path: Path | None = None,
2457
+ include_related_notes: bool = True,
2458
+ backup: bool = False,
2459
+ version_control_guard_active: bool = False,
2460
+ ) -> JsonObject:
2461
+ diagnosis = _load_diagnosis(diagnosis_path)
2462
+ diagnosis_view = _LinkDiagnosisView.from_payload(diagnosis)
2463
+ if receipt_path is not None and receipt_path.exists():
2464
+ return _json_object({
2465
+ "schema": LINK_RUN_SCHEMA,
2466
+ "phase": "link_apply_preflight",
2467
+ "status": "blocked",
2468
+ "blocked_reason": "receipt_path_exists",
2469
+ "next_action": "Escolha um novo --receipt para preservar a evidência da tentativa anterior.",
2470
+ "required_inputs": ["receipt"],
2471
+ "human_decision_required": False,
2472
+ "diagnosis_path": str(diagnosis_path),
2473
+ "receipt_path": str(receipt_path),
2474
+ "returncode": 3,
2475
+ })
2476
+ current_snapshot = _collect_snapshot(config)
2477
+ current_git = _git_context_for(config)
2478
+ current_git_payload = current_git.to_payload()
2479
+ current_git_view = _GitContextView.from_payload(current_git_payload)
2480
+ expected_db = diagnosis_view.vocabulary_db_path
2481
+ actual_db = str(config.vocabulary_db_path) if config.vocabulary_db_path else ""
2482
+ if expected_db != actual_db:
2483
+ return _json_object({
2484
+ "schema": LINK_RUN_SCHEMA,
2485
+ "phase": "link_apply_preflight",
2486
+ "status": "blocked",
2487
+ "blocked_reason": "vocabulary_db_mismatch",
2488
+ "next_action": "Rodar run-linker --diagnose novamente usando o mesmo vocabulary DB do apply.",
2489
+ "required_inputs": ["diagnosis", "vocabulary_db"],
2490
+ "human_decision_required": False,
2491
+ "diagnosis_path": str(diagnosis_path),
2492
+ "expected_vocabulary_db_path": expected_db,
2493
+ "actual_vocabulary_db_path": actual_db,
2494
+ "returncode": 3,
2495
+ })
2496
+ if current_snapshot["snapshot_hash"] != _json_field(diagnosis, "snapshot_hash"):
2497
+ return _json_object({
2498
+ "schema": LINK_RUN_SCHEMA,
2499
+ "phase": "link_apply_preflight",
2500
+ "status": "blocked",
2501
+ "blocked_reason": "stale_diagnosis",
2502
+ "next_action": "Rodar run-linker --diagnose novamente; a Wiki mudou desde o diagnóstico.",
2503
+ "required_inputs": ["diagnosis"],
2504
+ "human_decision_required": False,
2505
+ "diagnosis_path": str(diagnosis_path),
2506
+ "expected_snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
2507
+ "actual_snapshot_hash": current_snapshot["snapshot_hash"],
2508
+ "returncode": 3,
2509
+ })
2510
+ expected_git = diagnosis_view.git
2511
+ if expected_git.available and expected_git.status_hash:
2512
+ if current_git_view.status_hash != expected_git.status_hash:
2513
+ return _json_object({
2514
+ "schema": LINK_RUN_SCHEMA,
2515
+ "phase": "link_apply_preflight",
2516
+ "status": "blocked",
2517
+ "blocked_reason": "stale_diagnosis",
2518
+ "stale_reason": "git_status_changed",
2519
+ "next_action": "Rodar run-linker --diagnose novamente; o estado Git da Wiki mudou desde o diagnóstico.",
2520
+ "required_inputs": ["diagnosis"],
2521
+ "human_decision_required": False,
2522
+ "diagnosis_path": str(diagnosis_path),
2523
+ "expected_git_status_hash": expected_git.status_hash,
2524
+ "actual_git_status_hash": current_git_view.status_hash,
2525
+ "expected_git_head": expected_git.head,
2526
+ "actual_git_head": current_git_view.head,
2527
+ "returncode": 3,
2528
+ })
2529
+ if diagnosis_view.status != "diagnosis_ready" or diagnosis_view.blocker_count:
2530
+ if _diagnosis_requests_vocabulary_repair(diagnosis):
2531
+ safety_block = _link_apply_safety_preflight_block(
2532
+ diagnosis_path=diagnosis_path,
2533
+ diagnosis=diagnosis,
2534
+ version_control_guard_active=version_control_guard_active,
2535
+ vocabulary_repair_requested=True,
2536
+ )
2537
+ if safety_block is not None:
2538
+ return safety_block
2539
+ vocabulary_repair = repair_vocabulary_semantics_for_link(
2540
+ config,
2541
+ run_dir=diagnosis_path.parent,
2542
+ trigger="run_linker_apply",
2543
+ )
2544
+ try:
2545
+ vocabulary_repair_view = _VocabularySemanticRepairView.from_payload(vocabulary_repair)
2546
+ except (PydanticValidationError, ValueError) as exc:
2547
+ return _link_diagnosis_contract_invalid_payload(
2548
+ diagnosis_path=diagnosis_path,
2549
+ detail=str(exc),
2550
+ source_payload=vocabulary_repair,
2551
+ extra=_json_object({"contract": "vocabulary_semantic_repair"}),
2552
+ )
2553
+ if vocabulary_repair_view.status == "completed":
2554
+ refreshed = diagnose_links(
2555
+ config,
2556
+ diagnosis_path=diagnosis_path,
2557
+ include_related_notes=include_related_notes,
2558
+ force_diagnose=True,
2559
+ trigger_context=_json_object(_json_field(diagnosis, "trigger_context"))
2560
+ if isinstance(_json_field(diagnosis, "trigger_context"), dict)
2561
+ else None,
2562
+ )
2563
+ refreshed_view = _LinkDiagnosisView.from_payload(refreshed)
2564
+ if refreshed_view.status == "diagnosis_ready" and not refreshed_view.blocker_count:
2565
+ applied = apply_link_diagnosis(
2566
+ config,
2567
+ diagnosis_path=diagnosis_path,
2568
+ receipt_path=receipt_path,
2569
+ include_related_notes=include_related_notes,
2570
+ backup=backup,
2571
+ version_control_guard_active=version_control_guard_active,
2572
+ )
2573
+ return _json_object({
2574
+ **applied,
2575
+ "vocabulary_semantic_repair": vocabulary_repair,
2576
+ "vocabulary_repaired_diagnosis": refreshed,
2577
+ })
2578
+ return _json_object({
2579
+ "schema": LINK_RUN_SCHEMA,
2580
+ "phase": "link_apply_preflight",
2581
+ "status": "blocked",
2582
+ "blocked_reason": refreshed_view.blocked_reason or "diagnosis_blocked_after_vocabulary_repair",
2583
+ "next_action": refreshed_view.next_action or "Resolver blockers restantes do diagnóstico.",
2584
+ "required_inputs": ["diagnosis"],
2585
+ "human_decision_required": refreshed_view.human_decision_required,
2586
+ "diagnosis_path": str(diagnosis_path),
2587
+ "vocabulary_semantic_repair": vocabulary_repair,
2588
+ "refreshed_diagnosis": refreshed,
2589
+ "returncode": 3,
2590
+ })
2591
+ return _json_object({
2592
+ "schema": LINK_RUN_SCHEMA,
2593
+ "phase": "link_apply_preflight",
2594
+ "status": "blocked",
2595
+ "blocked_reason": vocabulary_repair_view.blocked_reason or "vocabulary_semantic_repair_blocked",
2596
+ "next_action": vocabulary_repair_view.next_action or "Resolver vocabulary DB pelo workflow /mednotes:link.",
2597
+ "required_inputs": ["vocabulary_semantic_repair"],
2598
+ "human_decision_required": vocabulary_repair_view.human_decision_required,
2599
+ "diagnosis_path": str(diagnosis_path),
2600
+ "vocabulary_semantic_repair": vocabulary_repair,
2601
+ "returncode": 3,
2602
+ })
2603
+ return _json_object({
2604
+ "schema": LINK_RUN_SCHEMA,
2605
+ "phase": "link_apply_preflight",
2606
+ "status": "blocked",
2607
+ "blocked_reason": _json_field(diagnosis, "blocked_reason") or "diagnosis_blocked",
2608
+ "next_action": _json_field(diagnosis, "next_action") or "Resolver blockers do diagnóstico antes de aplicar.",
2609
+ "required_inputs": ["diagnosis"],
2610
+ "human_decision_required": bool(_json_field(diagnosis, "human_decision_required")),
2611
+ "diagnosis_path": str(diagnosis_path),
2612
+ "blocker_count": _json_int(diagnosis, "blocker_count"),
2613
+ "blockers": _json_field(diagnosis, "blockers", []),
2614
+ "reference_repair": _json_field(diagnosis, "reference_repair", {}),
2615
+ "human_decision_packets": _json_field(diagnosis, "human_decision_packets", []),
2616
+ "returncode": 3,
2617
+ })
2618
+
2619
+ safety_block = _link_apply_safety_preflight_block(
2620
+ diagnosis_path=diagnosis_path,
2621
+ diagnosis=diagnosis,
2622
+ version_control_guard_active=version_control_guard_active,
2623
+ vocabulary_repair_requested=False,
2624
+ )
2625
+ if safety_block is not None:
2626
+ return safety_block
2627
+
2628
+ reference_repair_plan = _json_object_or_empty(_json_field(diagnosis, "reference_repair"))
2629
+ reference_apply = _json_object(apply_reference_repair_plan(config.wiki_dir, reference_repair_plan))
2630
+ diagnosis_body_linker = _json_object_or_empty(_json_field(diagnosis, "body_term_linker"))
2631
+ diagnosis_body_view = _BodyLinkerView.from_payload(diagnosis_body_linker)
2632
+ if diagnosis_body_view.body_linker_mode == "vocabulary_db":
2633
+ body_linker = _json_object(apply_body_linker_plan(
2634
+ wiki_dir=config.wiki_dir,
2635
+ body_linker_payload=diagnosis_body_linker,
2636
+ ))
2637
+ else:
2638
+ body_linker = _run_body_linker(config, dry_run=False)
2639
+ related_notes = (
2640
+ _converge_related_notes_sync(config, backup=backup)
2641
+ if include_related_notes
2642
+ else None
2643
+ )
2644
+ related_notes_payload = _related_notes_payload(related_notes)
2645
+ graph_after = graph_audit(config)
2646
+ snapshot_after = _collect_snapshot(config)
2647
+ git_after = _git_context_for(config)
2648
+ git_after_payload = git_after.to_payload()
2649
+ actual_receipt_path = receipt_path or default_link_receipt_path()
2650
+ receipt = _write_link_receipt(
2651
+ actual_receipt_path,
2652
+ config=config,
2653
+ diagnosis=diagnosis,
2654
+ snapshot_before=current_snapshot,
2655
+ snapshot_after=snapshot_after,
2656
+ git_before=current_git_payload,
2657
+ git_after=git_after_payload,
2658
+ body_linker=body_linker,
2659
+ related_notes=related_notes,
2660
+ reference_apply=reference_apply,
2661
+ graph_after=graph_after,
2662
+ )
2663
+ write_link_state(
2664
+ snapshot_hash=_json_text(snapshot_after, "snapshot_hash"),
2665
+ git_context=git_after,
2666
+ receipt_path=actual_receipt_path,
2667
+ )
2668
+ body_view = _BodyLinkerView.from_payload(body_linker)
2669
+ graph_view = _GraphAuditView.from_payload(graph_after)
2670
+ body_or_graph_blocked = bool(
2671
+ body_view.blocker_count or graph_view.error_count
2672
+ )
2673
+ related_notes_required = _related_notes_required_for_apply(diagnosis)
2674
+ related_notes_blocked = _related_notes_apply_blocked(related_notes, required=related_notes_required)
2675
+ blocked = bool(body_or_graph_blocked or related_notes_blocked)
2676
+ blocked_reason = _link_apply_blocked_reason(
2677
+ body_or_graph_blocked=body_or_graph_blocked,
2678
+ related_notes_blocked=related_notes_blocked,
2679
+ )
2680
+ return _json_object({
2681
+ "schema": LINK_RUN_SCHEMA,
2682
+ "phase": "link_apply",
2683
+ "status": "completed_with_link_blockers" if blocked else "completed",
2684
+ "blocked_reason": blocked_reason,
2685
+ "next_action": _link_apply_next_action(blocked_reason=blocked_reason, related_notes=related_notes),
2686
+ "required_inputs": [*LINK_REQUIRED_INPUTS, "diagnosis"],
2687
+ "human_decision_required": False,
2688
+ "wiki_dir": str(config.wiki_dir),
2689
+ "catalog_path": str(config.catalog_path) if config.catalog_path else None,
2690
+ "vocabulary_db_path": str(config.vocabulary_db_path) if config.vocabulary_db_path else "",
2691
+ "diagnosis_path": str(diagnosis_path),
2692
+ "receipt_path": str(actual_receipt_path),
2693
+ "plan_hash": _json_field(diagnosis, "plan_hash", ""),
2694
+ "snapshot_hash": _json_field(diagnosis, "snapshot_hash", ""),
2695
+ "phases": receipt["phases"],
2696
+ "changed_files": receipt["changed_files"],
2697
+ "files_changed": len(receipt["changed_files"]),
2698
+ "version_control_safety": _json_object_or_empty(_json_field(receipt, "version_control_safety")),
2699
+ "body_term_linker": body_linker,
2700
+ "reference_repair_apply": reference_apply,
2701
+ "related_notes_sync": related_notes_payload,
2702
+ "related_notes_recovery_state": _related_notes_recovery_payload(related_notes),
2703
+ "related_notes_applied": bool(related_notes.applied_note_count) if related_notes is not None else False,
2704
+ "related_notes_skipped_reason": related_notes.skipped_reason if related_notes is not None else "",
2705
+ "graph_audit_after": graph_after,
2706
+ "blocker_count": body_view.blocker_count,
2707
+ "returncode": 3 if blocked else body_view.returncode,
2708
+ })
2709
+
2710
+
2711
+ def run_linker(
2712
+ config: MedConfig,
2713
+ *,
2714
+ diagnose: bool = False,
2715
+ apply: bool = False,
2716
+ diagnosis_path: Path | None = None,
2717
+ receipt_path: Path | None = None,
2718
+ include_related_notes: bool = True,
2719
+ backup: bool = False,
2720
+ force_diagnose: bool = False,
2721
+ trigger_context_path: Path | None = None,
2722
+ llm_disambiguation: str = "auto",
2723
+ llm_model: str | None = None,
2724
+ llm_timeout: int = DEFAULT_LLM_TIMEOUT_SECONDS,
2725
+ llm_disambiguator: Callable[..., object] | None = None,
2726
+ version_control_guard_active: bool = False,
2727
+ ) -> JsonObject:
2728
+ if diagnose == apply:
2729
+ raise ValidationError("run-linker requires exactly one of diagnose=True or apply=True.")
2730
+ if apply and trigger_context_path is not None:
2731
+ raise ValidationError("run-linker --apply does not accept --trigger-context; pass the saved --diagnosis only.")
2732
+ if diagnose:
2733
+ trigger_context = load_trigger_context(trigger_context_path)
2734
+ return diagnose_links(
2735
+ config,
2736
+ diagnosis_path=diagnosis_path,
2737
+ include_related_notes=include_related_notes,
2738
+ force_diagnose=force_diagnose,
2739
+ trigger_context=trigger_context,
2740
+ llm_disambiguation=llm_disambiguation,
2741
+ llm_model=llm_model,
2742
+ llm_timeout=llm_timeout,
2743
+ llm_disambiguator=llm_disambiguator,
2744
+ )
2745
+ if diagnosis_path is None:
2746
+ raise ValidationError("run-linker --apply requires --diagnosis <link-diagnosis.json>.")
2747
+ return apply_link_diagnosis(
2748
+ config,
2749
+ diagnosis_path=diagnosis_path,
2750
+ receipt_path=receipt_path,
2751
+ include_related_notes=include_related_notes,
2752
+ backup=backup,
2753
+ version_control_guard_active=version_control_guard_active,
2754
+ )
2755
+
2756
+
2757
+ def graph_audit(config: MedConfig) -> JsonObject:
2758
+ return _json_object(wiki_graph.audit_wiki_graph(config.wiki_dir, catalog_path=config.catalog_path))