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,1388 @@
1
+ """Official recovery helpers for the vocabulary SQLite state."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import shutil
7
+ import sqlite3
8
+ import time
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from pydantic import Field
14
+ from pydantic import ValidationError as PydanticValidationError
15
+
16
+ from mednotes.domains.wiki.capabilities.notes.note_iter import iter_notes
17
+ from mednotes.domains.wiki.capabilities.notes.raw_chats import atomic_write_text
18
+ from mednotes.domains.wiki.capabilities.vocabulary.vocabulary_map import (
19
+ initialize_vocabulary_db,
20
+ note_content_hash,
21
+ upsert_note,
22
+ )
23
+ from mednotes.domains.wiki.common import FileWriteError, ValidationError, _now_iso, wiki_cli_command
24
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter, JsonValue
25
+
26
+ VOCABULARY_STATUS_SCHEMA = "medical-notes-workbench.vocabulary-status.v1"
27
+ VOCABULARY_RECOVERY_PLAN_SCHEMA = "medical-notes-workbench.vocabulary-recovery-plan.v1"
28
+ VOCABULARY_RECOVERY_RECEIPT_SCHEMA = "medical-notes-workbench.vocabulary-recovery-receipt.v1"
29
+
30
+ QUEUE_STATUSES = ("pending", "claimed", "applied", "stale", "blocked")
31
+ REQUIRED_TABLE_COLUMNS = {
32
+ "notes": {"id", "path", "title", "stem", "content_hash", "status"},
33
+ "meanings": {"id", "label", "normalized_label", "semantic_type", "atomic_status", "status"},
34
+ "surfaces": {"id", "normalized_surface", "best_display_text", "intrinsically_ambiguous"},
35
+ "meaning_note_links": {"id", "meaning_id", "note_id", "role", "status", "confidence"},
36
+ "surface_meaning_policy": {"id", "surface_id", "meaning_id", "link_policy", "source"},
37
+ "note_semantic_ingestion_queue": {"id", "note_id", "note_path", "content_hash", "status"},
38
+ }
39
+
40
+
41
+ def _json_object(payload: object) -> JsonObject:
42
+ return JsonObjectAdapter.validate_python(payload)
43
+
44
+
45
+ class _QueueCounts(ContractModel):
46
+ pending: int = 0
47
+ claimed: int = 0
48
+ applied: int = 0
49
+ stale: int = 0
50
+ blocked: int = 0
51
+ orphan: int = 0
52
+
53
+
54
+ class _SchemaDriftIssue(ContractModel):
55
+ code: str = ""
56
+ message: str = ""
57
+ table: str = ""
58
+ column: str = ""
59
+
60
+
61
+ class _QueueIssue(ContractModel):
62
+ code: str = ""
63
+ message: str = ""
64
+ queue_id: int = 0
65
+ note_id: int = 0
66
+ note_path: str = ""
67
+ actual_path: str = ""
68
+ content_hash: str = ""
69
+ expected_hash: str = ""
70
+ actual_hash: str = ""
71
+ status: str = ""
72
+
73
+
74
+ class _VocabularyStatusPayload(ContractModel):
75
+ schema_: Literal["medical-notes-workbench.vocabulary-status.v1"] = Field(
76
+ alias="schema",
77
+ serialization_alias="schema",
78
+ )
79
+ status: Literal["ready", "degraded", "blocked"]
80
+ schema_status: Literal["ready", "blocked"]
81
+ blocked_reason: str = ""
82
+ db_path: str = ""
83
+ db_exists: bool = False
84
+ queue_counts: _QueueCounts = Field(default_factory=_QueueCounts)
85
+ object_counts: JsonObject = Field(default_factory=dict)
86
+ schema_drift: list[_SchemaDriftIssue] = Field(default_factory=list)
87
+ queue_issues: list[_QueueIssue] = Field(default_factory=list)
88
+ queue_issue_count: int = 0
89
+ mutated: bool = False
90
+ recovery_command: str = ""
91
+ next_action: str = ""
92
+ degraded_reasons: list[str] = Field(default_factory=list)
93
+
94
+
95
+ class _CatalogHint(ContractModel):
96
+ available: bool = False
97
+ title: str = ""
98
+ alias_count: int = 0
99
+
100
+
101
+ class _RecoveryActionFields(ContractModel):
102
+ action: str = ""
103
+ code: str = ""
104
+ db_path: str = ""
105
+ queue_id: int = 0
106
+ note_id: int = 0
107
+ note_path: str = ""
108
+ actual_path: str = ""
109
+ title: str = ""
110
+ content_hash: str = ""
111
+ expected_hash: str = ""
112
+ actual_hash: str = ""
113
+ from_status: str = ""
114
+ to_status: str = ""
115
+ reason: str = ""
116
+ queue_flags: list[str] = Field(default_factory=list)
117
+ assigned_agent: str = ""
118
+ catalog_hint: _CatalogHint = Field(default_factory=_CatalogHint)
119
+ skipped_reason: str = ""
120
+ applied_count: int = 0
121
+
122
+
123
+ class _RecoveryPlanPayload(ContractModel):
124
+ schema_: Literal["medical-notes-workbench.vocabulary-recovery-plan.v1"] = Field(
125
+ alias="schema",
126
+ serialization_alias="schema",
127
+ )
128
+ status: Literal["planned", "blocked", "skipped"]
129
+ blocked_reason: str = ""
130
+ mode: str = ""
131
+ db_path: str = ""
132
+ backup_path: str = ""
133
+ actions: list[JsonObject] = Field(default_factory=list)
134
+ blocked_items: list[JsonObject] = Field(default_factory=list)
135
+ created_at: str = ""
136
+ next_action: str = ""
137
+ required_inputs: list[str] = Field(default_factory=list)
138
+ human_decision_required: bool = False
139
+ plan_id: str = ""
140
+
141
+
142
+ class _RecoveryReceiptPayload(ContractModel):
143
+ schema_: Literal["medical-notes-workbench.vocabulary-recovery-receipt.v1"] = Field(
144
+ alias="schema",
145
+ serialization_alias="schema",
146
+ )
147
+ status: Literal["pending", "completed", "applied", "blocked"]
148
+ blocked_reason: str = ""
149
+ mode: str = ""
150
+ plan_id: str = ""
151
+ db_path: str = ""
152
+ backup_path: str = ""
153
+ applied_actions: list[JsonObject] = Field(default_factory=list)
154
+ actions: list[JsonObject] = Field(default_factory=list)
155
+ skipped_items: list[JsonObject] = Field(default_factory=list)
156
+ error_items: list[JsonObject] = Field(default_factory=list)
157
+ diagnosis_after: JsonObject = Field(default_factory=dict)
158
+ next_action: str = ""
159
+ required_inputs: list[str] = Field(default_factory=list)
160
+ created_at: str = ""
161
+
162
+
163
+ def _status_payload(payload: object) -> _VocabularyStatusPayload:
164
+ return _VocabularyStatusPayload.model_validate(_json_object(payload))
165
+
166
+
167
+ def _plan_payload(payload: object) -> _RecoveryPlanPayload:
168
+ return _RecoveryPlanPayload.model_validate(_json_object(payload))
169
+
170
+
171
+ def _receipt_payload(payload: object) -> JsonObject:
172
+ return _RecoveryReceiptPayload.model_validate(_json_object(payload)).to_payload()
173
+
174
+
175
+ def _action_fields(raw: JsonObject) -> _RecoveryActionFields | None:
176
+ try:
177
+ return _RecoveryActionFields.model_validate(raw)
178
+ except PydanticValidationError:
179
+ return None
180
+
181
+
182
+ def _action_name(raw: JsonObject) -> str:
183
+ action = _action_fields(raw)
184
+ return action.action if action is not None else ""
185
+
186
+
187
+ def _action_with(raw: JsonObject, key: str, value: JsonValue) -> JsonObject:
188
+ return _json_object({**raw, key: value})
189
+
190
+
191
+ def _action_payload(action: _RecoveryActionFields) -> JsonObject:
192
+ payload = action.model_dump(
193
+ mode="json",
194
+ exclude_defaults=True,
195
+ exclude_none=True,
196
+ )
197
+ return _json_object(payload)
198
+
199
+
200
+ def _connect_readonly(db_path: Path) -> sqlite3.Connection:
201
+ return sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
202
+
203
+
204
+ def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
205
+ try:
206
+ return {str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
207
+ except sqlite3.DatabaseError:
208
+ return set()
209
+
210
+
211
+ def _queue_counts(conn: sqlite3.Connection, *, has_queue: bool) -> dict[str, int]:
212
+ counts = dict.fromkeys(QUEUE_STATUSES, 0)
213
+ counts["orphan"] = 0
214
+ if not has_queue:
215
+ return counts
216
+ for status, count in conn.execute(
217
+ "SELECT status, COUNT(*) FROM note_semantic_ingestion_queue GROUP BY status"
218
+ ).fetchall():
219
+ if str(status) in counts:
220
+ counts[str(status)] = int(count)
221
+ try:
222
+ counts["orphan"] = int(
223
+ conn.execute(
224
+ """
225
+ SELECT COUNT(*)
226
+ FROM note_semantic_ingestion_queue AS q
227
+ LEFT JOIN notes AS n ON n.id = q.note_id
228
+ WHERE n.id IS NULL AND q.status IN ('pending', 'claimed', 'stale')
229
+ """
230
+ ).fetchone()[0]
231
+ )
232
+ except sqlite3.DatabaseError:
233
+ counts["orphan"] = 0
234
+ return counts
235
+
236
+
237
+ def _object_counts(conn: sqlite3.Connection, *, tables: set[str]) -> dict[str, int]:
238
+ counts: dict[str, int] = {}
239
+ for table in ("notes", "meanings", "surfaces", "surface_meaning_policy", "note_semantic_ingestion_queue"):
240
+ if table not in tables:
241
+ continue
242
+ try:
243
+ counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
244
+ except sqlite3.DatabaseError:
245
+ counts[table] = 0
246
+ return counts
247
+
248
+
249
+ def _case_only_match(path: Path) -> Path | None:
250
+ parent = path.parent
251
+ if not parent.is_dir():
252
+ return None
253
+ wanted = path.name.casefold()
254
+ try:
255
+ for child in parent.iterdir():
256
+ if child.name.casefold() == wanted and child.name != path.name:
257
+ return child
258
+ except OSError:
259
+ return None
260
+ return None
261
+
262
+
263
+ def _queue_integrity_issues(conn: sqlite3.Connection, *, has_queue: bool) -> list[JsonObject]:
264
+ if not has_queue:
265
+ return []
266
+ issues: list[JsonObject] = []
267
+ try:
268
+ rows = conn.execute(
269
+ """
270
+ SELECT id, note_path, content_hash, status
271
+ FROM note_semantic_ingestion_queue
272
+ WHERE status IN ('pending', 'claimed', 'stale')
273
+ ORDER BY id ASC
274
+ """
275
+ ).fetchall()
276
+ except sqlite3.DatabaseError as exc:
277
+ return [_QueueIssue(code="queue_unreadable", message=str(exc)).to_payload()]
278
+ for row in rows:
279
+ queue_id = int(row[0])
280
+ note_path = Path(str(row[1] or ""))
281
+ expected_hash = str(row[2] or "")
282
+ status = str(row[3] or "")
283
+ actual_case_path = _case_only_match(note_path)
284
+ if actual_case_path is not None:
285
+ issues.append(
286
+ _QueueIssue(
287
+ code="queue_note_path_case_mismatch",
288
+ queue_id=queue_id,
289
+ note_path=str(note_path),
290
+ actual_path=str(actual_case_path),
291
+ status=status,
292
+ ).to_payload()
293
+ )
294
+ continue
295
+ if not note_path.is_file():
296
+ issues.append(
297
+ _QueueIssue(
298
+ code="queue_note_path_missing",
299
+ queue_id=queue_id,
300
+ note_path=str(note_path),
301
+ status=status,
302
+ ).to_payload()
303
+ )
304
+ continue
305
+ actual_hash = note_content_hash(note_path)
306
+ if expected_hash and actual_hash != expected_hash:
307
+ issues.append(
308
+ _QueueIssue(
309
+ code="queue_note_hash_stale",
310
+ queue_id=queue_id,
311
+ note_path=str(note_path),
312
+ content_hash=expected_hash,
313
+ expected_hash=expected_hash,
314
+ actual_hash=actual_hash,
315
+ status=status,
316
+ ).to_payload()
317
+ )
318
+ elif status == "stale":
319
+ issues.append(
320
+ _QueueIssue(
321
+ code="queue_stale_ready_for_retry",
322
+ queue_id=queue_id,
323
+ note_path=str(note_path),
324
+ content_hash=expected_hash,
325
+ actual_hash=actual_hash,
326
+ status=status,
327
+ ).to_payload()
328
+ )
329
+ try:
330
+ orphan_rows = conn.execute(
331
+ """
332
+ SELECT q.id, q.note_id, q.note_path, q.content_hash, q.status
333
+ FROM note_semantic_ingestion_queue AS q
334
+ LEFT JOIN notes AS n ON n.id = q.note_id
335
+ WHERE n.id IS NULL AND q.status IN ('pending', 'claimed', 'stale')
336
+ ORDER BY q.id ASC
337
+ """
338
+ ).fetchall()
339
+ except sqlite3.DatabaseError:
340
+ orphan_rows = []
341
+ for row in orphan_rows:
342
+ issues.append(
343
+ _QueueIssue(
344
+ code="queue_orphan_note_id",
345
+ queue_id=int(row[0]),
346
+ note_id=int(row[1] or 0),
347
+ note_path=str(row[2] or ""),
348
+ content_hash=str(row[3] or ""),
349
+ status=str(row[4] or ""),
350
+ ).to_payload()
351
+ )
352
+ try:
353
+ claimed_rows = conn.execute(
354
+ """
355
+ SELECT id, note_path, content_hash, status
356
+ FROM note_semantic_ingestion_queue
357
+ WHERE status='claimed'
358
+ ORDER BY id ASC
359
+ """
360
+ ).fetchall()
361
+ except sqlite3.DatabaseError:
362
+ claimed_rows = []
363
+ for row in claimed_rows:
364
+ issues.append(
365
+ _QueueIssue(
366
+ code="queue_claimed_without_active_agent",
367
+ queue_id=int(row[0]),
368
+ note_path=str(row[1] or ""),
369
+ content_hash=str(row[2] or ""),
370
+ status=str(row[3] or ""),
371
+ ).to_payload()
372
+ )
373
+ return issues
374
+
375
+
376
+ def vocabulary_status(db_path: Path) -> JsonObject:
377
+ db_path = Path(db_path)
378
+ recovery = wiki_cli_command("vocabulary-recover", "--mode", "reconcile-queue", "--dry-run", "--json")
379
+ if not db_path.exists():
380
+ recovery = wiki_cli_command("vocabulary-recover", "--mode", "rebuild-db", "--dry-run", "--json")
381
+ return _VocabularyStatusPayload(
382
+ schema=VOCABULARY_STATUS_SCHEMA,
383
+ status="blocked",
384
+ schema_status="blocked",
385
+ blocked_reason="vocabulary_db_missing",
386
+ db_path=str(db_path),
387
+ db_exists=False,
388
+ queue_counts=_QueueCounts(),
389
+ object_counts={},
390
+ schema_drift=[
391
+ _SchemaDriftIssue(code="db_missing", message="vocabulary DB does not exist")
392
+ ],
393
+ queue_issues=[],
394
+ queue_issue_count=0,
395
+ recovery_command=recovery,
396
+ next_action=recovery,
397
+ ).to_payload()
398
+
399
+ schema_drift: list[JsonObject] = []
400
+ queue_issues: list[JsonObject] = []
401
+ object_counts: dict[str, int] = {}
402
+ try:
403
+ with _connect_readonly(db_path) as conn:
404
+ tables = {str(row[0]) for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
405
+ for table, required_columns in REQUIRED_TABLE_COLUMNS.items():
406
+ if table not in tables:
407
+ schema_drift.append(
408
+ _SchemaDriftIssue(code="missing_table", table=table, message=f"missing table {table}").to_payload()
409
+ )
410
+ continue
411
+ columns = _table_columns(conn, table)
412
+ missing = sorted(required_columns - columns)
413
+ for column in missing:
414
+ schema_drift.append(
415
+ _SchemaDriftIssue(
416
+ code="missing_column",
417
+ table=table,
418
+ column=column,
419
+ message=f"missing {table}.{column}",
420
+ ).to_payload()
421
+ )
422
+ if table == "meanings" and "type" in columns:
423
+ schema_drift.append(
424
+ _SchemaDriftIssue(
425
+ code="legacy_column",
426
+ table=table,
427
+ column="type",
428
+ message="legacy meanings.type column detected",
429
+ ).to_payload()
430
+ )
431
+ if table == "meanings" and "source" in columns:
432
+ schema_drift.append(
433
+ _SchemaDriftIssue(
434
+ code="legacy_column",
435
+ table=table,
436
+ column="source",
437
+ message="legacy meanings.source column detected",
438
+ ).to_payload()
439
+ )
440
+ if "aliases" in tables:
441
+ schema_drift.append(
442
+ _SchemaDriftIssue(code="legacy_table", table="aliases", message="legacy aliases table detected").to_payload()
443
+ )
444
+ has_queue = "note_semantic_ingestion_queue" in tables
445
+ counts = _queue_counts(conn, has_queue=has_queue)
446
+ object_counts = _object_counts(conn, tables=tables)
447
+ if not schema_drift:
448
+ queue_issues = _queue_integrity_issues(conn, has_queue=has_queue)
449
+ except sqlite3.DatabaseError as exc:
450
+ return _VocabularyStatusPayload(
451
+ schema=VOCABULARY_STATUS_SCHEMA,
452
+ status="blocked",
453
+ schema_status="blocked",
454
+ blocked_reason="vocabulary_sqlite_integrity_error",
455
+ db_path=str(db_path),
456
+ db_exists=True,
457
+ queue_counts=_QueueCounts(),
458
+ object_counts={},
459
+ schema_drift=[_SchemaDriftIssue(code="database_unreadable", message=str(exc))],
460
+ queue_issues=[],
461
+ queue_issue_count=0,
462
+ recovery_command=recovery,
463
+ next_action=recovery,
464
+ ).to_payload()
465
+ unsupported_schema_drift = any(
466
+ _SchemaDriftIssue.model_validate(issue).code in {"missing_column", "legacy_column", "legacy_table"}
467
+ for issue in schema_drift
468
+ )
469
+ queue_blocking_codes = {"queue_orphan_note_id", "queue_claimed_without_active_agent"}
470
+ queue_blocked = any(_QueueIssue.model_validate(issue).code in queue_blocking_codes for issue in queue_issues)
471
+ blocked = bool(schema_drift) or queue_blocked
472
+ queue_counts = _QueueCounts.model_validate(counts)
473
+ degraded = (bool(queue_issues) and not queue_blocked) or queue_counts.stale > 0
474
+ blocked_reason = ""
475
+ if schema_drift:
476
+ blocked_reason = (
477
+ "vocabulary_schema_drift_unsupported"
478
+ if unsupported_schema_drift
479
+ else "vocabulary_schema_drift"
480
+ )
481
+ elif queue_blocked:
482
+ blocked_reason = "vocabulary_queue_inconsistent"
483
+ return _VocabularyStatusPayload(
484
+ schema=VOCABULARY_STATUS_SCHEMA,
485
+ status="blocked" if blocked else "degraded" if degraded else "ready",
486
+ schema_status="blocked" if blocked else "ready",
487
+ blocked_reason=blocked_reason,
488
+ db_path=str(db_path),
489
+ db_exists=True,
490
+ queue_counts=queue_counts,
491
+ object_counts=_json_object(object_counts),
492
+ schema_drift=[_SchemaDriftIssue.model_validate(issue) for issue in schema_drift],
493
+ queue_issues=[_QueueIssue.model_validate(issue) for issue in queue_issues],
494
+ queue_issue_count=len(queue_issues),
495
+ mutated=False,
496
+ recovery_command=recovery if blocked or degraded else "",
497
+ next_action=recovery if blocked or degraded else "",
498
+ ).to_payload()
499
+
500
+
501
+ def diagnose_vocabulary_status(db_path: Path) -> JsonObject:
502
+ typed = _status_payload(vocabulary_status(db_path))
503
+ payload = typed.to_payload()
504
+ if typed.status == "ready" and typed.queue_counts.pending > 0:
505
+ payload.update(
506
+ {
507
+ "status": "degraded",
508
+ "degraded_reasons": ["pending_semantic_ingestion"],
509
+ "recovery_command": "",
510
+ "next_action": "",
511
+ }
512
+ )
513
+ elif typed.status == "degraded" and typed.queue_counts.pending > 0:
514
+ payload.setdefault("degraded_reasons", ["pending_semantic_ingestion"])
515
+ else:
516
+ payload.setdefault("degraded_reasons", [])
517
+ payload.update({"mutated": False})
518
+ return _status_payload(payload).to_payload()
519
+
520
+
521
+ def _with_plan_id(plan: JsonObject) -> JsonObject:
522
+ data = {key: value for key, value in plan.items() if key != "plan_id"}
523
+ digest = hashlib.sha256(json.dumps(data, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
524
+ return _json_object({"plan_id": f"sha256:{digest}", **data})
525
+
526
+
527
+ def _first_heading_or_stem(path: Path) -> str:
528
+ try:
529
+ for line in path.read_text(encoding="utf-8").splitlines():
530
+ stripped = line.strip()
531
+ if stripped.startswith("# "):
532
+ return stripped[2:].strip() or path.stem
533
+ except OSError:
534
+ return path.stem
535
+ return path.stem
536
+
537
+
538
+ def _catalog_hints(catalog_path: Path | None) -> dict[str, _CatalogHint]:
539
+ if not catalog_path or not catalog_path.is_file():
540
+ return {}
541
+ try:
542
+ payload = json.loads(catalog_path.read_text(encoding="utf-8"))
543
+ except (OSError, json.JSONDecodeError):
544
+ return {}
545
+ items: object = payload
546
+ if isinstance(payload, dict):
547
+ catalog = _json_object(payload)
548
+ items = catalog["notes"] if "notes" in catalog else []
549
+ hints: dict[str, _CatalogHint] = {}
550
+ if isinstance(items, list):
551
+ for raw_item in items:
552
+ if not isinstance(raw_item, dict):
553
+ continue
554
+ item = _json_object(raw_item)
555
+ path_value = item["path"] if "path" in item else item["file"] if "file" in item else ""
556
+ raw_path = str(path_value or "")
557
+ if not raw_path:
558
+ continue
559
+ aliases = item["aliases"] if "aliases" in item else []
560
+ hints[Path(raw_path).name] = _CatalogHint(
561
+ available=True,
562
+ title=str(item["title"] if "title" in item else ""),
563
+ alias_count=len(aliases if isinstance(aliases, list) else []),
564
+ )
565
+ return hints
566
+
567
+
568
+ def _schema_drift_is_schema_bootstrapable(schema_drift: Sequence[_SchemaDriftIssue]) -> bool:
569
+ return bool(schema_drift) and all(issue.code == "missing_table" for issue in schema_drift)
570
+
571
+
572
+ def build_vocabulary_recovery_plan(
573
+ db_path: Path | None = None,
574
+ *,
575
+ mode: str,
576
+ wiki_dir: Path | None = None,
577
+ catalog_path: Path | None = None,
578
+ ) -> JsonObject:
579
+ if db_path is None:
580
+ raise TypeError("build_vocabulary_recovery_plan() missing required db_path")
581
+ db_path = Path(db_path)
582
+ actions: list[JsonObject] = []
583
+ blocked_items: list[JsonObject] = []
584
+ backup_path = str(db_path.with_name(f"{db_path.name}.{time.time_ns()}.bak")) if db_path.exists() else ""
585
+ if mode == "reconcile-queue":
586
+ if not db_path.exists():
587
+ next_action = wiki_cli_command("vocabulary-recover", "--mode", "rebuild-db", "--dry-run", "--json")
588
+ return _with_plan_id(
589
+ _json_object(
590
+ {
591
+ "schema": VOCABULARY_RECOVERY_PLAN_SCHEMA,
592
+ "status": "blocked",
593
+ "blocked_reason": "vocabulary_db_missing",
594
+ "mode": mode,
595
+ "db_path": str(db_path),
596
+ "backup_path": backup_path,
597
+ "actions": [],
598
+ "blocked_items": [
599
+ {
600
+ "code": "db_missing",
601
+ "message": "vocabulary DB does not exist; rebuild-db is required before queue reconciliation.",
602
+ }
603
+ ],
604
+ "created_at": _now_iso(),
605
+ "next_action": next_action,
606
+ "required_inputs": ["wiki_dir"],
607
+ "human_decision_required": False,
608
+ }
609
+ )
610
+ )
611
+ diagnosis = diagnose_vocabulary_status(db_path)
612
+ diagnosis_model = _status_payload(diagnosis)
613
+ schema_drift = diagnosis_model.schema_drift
614
+ if schema_drift:
615
+ if _schema_drift_is_schema_bootstrapable(schema_drift):
616
+ actions.append(
617
+ _action_payload(
618
+ _RecoveryActionFields(
619
+ action="create_missing_schema",
620
+ code="create_missing_schema",
621
+ db_path=str(db_path),
622
+ reason="official_recovery_create_missing_vocabulary_schema",
623
+ )
624
+ )
625
+ )
626
+ else:
627
+ blocked_items.append(
628
+ _SchemaDriftIssue(
629
+ code=diagnosis_model.blocked_reason or "vocabulary_schema_drift_unsupported",
630
+ message="Existing vocabulary DB schema drift is not safe to reconcile in place.",
631
+ ).to_payload()
632
+ )
633
+ return _with_plan_id(
634
+ _json_object({
635
+ "schema": VOCABULARY_RECOVERY_PLAN_SCHEMA,
636
+ "status": "blocked",
637
+ "mode": mode,
638
+ "db_path": str(db_path),
639
+ "backup_path": backup_path,
640
+ "actions": [],
641
+ "blocked_items": blocked_items,
642
+ "created_at": _now_iso(),
643
+ "next_action": "Não altere SQLite manualmente; rode rebuild-db com plano/recibo ou restaure backup válido.",
644
+ })
645
+ )
646
+ return _with_plan_id(
647
+ _json_object({
648
+ "schema": VOCABULARY_RECOVERY_PLAN_SCHEMA,
649
+ "status": "planned",
650
+ "mode": mode,
651
+ "db_path": str(db_path),
652
+ "backup_path": backup_path,
653
+ "actions": actions,
654
+ "blocked_items": blocked_items,
655
+ "created_at": _now_iso(),
656
+ })
657
+ )
658
+ with sqlite3.connect(db_path) as conn:
659
+ conn.row_factory = sqlite3.Row
660
+ try:
661
+ rows = list(
662
+ conn.execute(
663
+ """
664
+ SELECT
665
+ q.id,
666
+ q.note_id,
667
+ q.note_path,
668
+ q.content_hash,
669
+ q.status,
670
+ n.id AS resolved_note_id
671
+ FROM note_semantic_ingestion_queue AS q
672
+ LEFT JOIN notes AS n ON n.id = q.note_id
673
+ WHERE q.status IN ('pending', 'claimed', 'stale')
674
+ ORDER BY q.id ASC
675
+ """
676
+ )
677
+ )
678
+ except sqlite3.DatabaseError as exc:
679
+ blocked_items.append(_SchemaDriftIssue(code="schema_drift", message=str(exc)).to_payload())
680
+ rows = []
681
+ for row in rows:
682
+ note_path = Path(str(row["note_path"]))
683
+ content_hash = str(row["content_hash"])
684
+ if row["resolved_note_id"] is None:
685
+ actions.append(
686
+ _action_payload(
687
+ _RecoveryActionFields(
688
+ action="mark_orphan_queue_blocked",
689
+ code="mark_orphan_queue_blocked",
690
+ queue_id=int(row["id"]),
691
+ note_id=int(row["note_id"] or 0),
692
+ note_path=str(note_path),
693
+ content_hash=content_hash,
694
+ from_status=str(row["status"]),
695
+ to_status="blocked",
696
+ reason="orphan_queue_note_id",
697
+ )
698
+ )
699
+ )
700
+ continue
701
+ if str(row["status"]) == "claimed":
702
+ actions.append(
703
+ _action_payload(
704
+ _RecoveryActionFields(
705
+ action="reset_claimed_to_pending",
706
+ code="reset_claimed_to_pending",
707
+ queue_id=int(row["id"]),
708
+ note_path=str(note_path),
709
+ content_hash=content_hash,
710
+ from_status="claimed",
711
+ to_status="pending",
712
+ reason="claimed_without_active_agent",
713
+ )
714
+ )
715
+ )
716
+ continue
717
+ actual_case_path = _case_only_match(note_path)
718
+ if actual_case_path is not None:
719
+ actions.append(
720
+ _action_payload(
721
+ _RecoveryActionFields(
722
+ action="fix_case_path",
723
+ queue_id=int(row["id"]),
724
+ note_path=str(note_path),
725
+ actual_path=str(actual_case_path),
726
+ content_hash=content_hash,
727
+ from_status=str(row["status"]),
728
+ to_status=str(row["status"]),
729
+ reason="note_path_case_mismatch",
730
+ )
731
+ )
732
+ )
733
+ elif not note_path.exists():
734
+ actions.append(
735
+ _action_payload(
736
+ _RecoveryActionFields(
737
+ action="mark_blocked",
738
+ queue_id=int(row["id"]),
739
+ note_path=str(note_path),
740
+ content_hash=content_hash,
741
+ from_status=str(row["status"]),
742
+ to_status="blocked",
743
+ reason="note_path_missing",
744
+ )
745
+ )
746
+ )
747
+ elif str(row["status"]) == "stale":
748
+ actual_hash = note_content_hash(note_path)
749
+ actions.append(
750
+ _action_payload(
751
+ _RecoveryActionFields(
752
+ action="refresh_stale_to_pending",
753
+ code="refresh_stale_to_pending",
754
+ queue_id=int(row["id"]),
755
+ note_path=str(note_path),
756
+ content_hash=content_hash,
757
+ actual_hash=actual_hash,
758
+ from_status="stale",
759
+ to_status="pending",
760
+ reason="stale_queue_ready_for_recuration",
761
+ )
762
+ )
763
+ )
764
+ elif note_content_hash(note_path) != content_hash:
765
+ actions.append(
766
+ _action_payload(
767
+ _RecoveryActionFields(
768
+ action="mark_stale",
769
+ queue_id=int(row["id"]),
770
+ note_path=str(note_path),
771
+ content_hash=content_hash,
772
+ from_status=str(row["status"]),
773
+ to_status="stale",
774
+ reason="content_hash_mismatch",
775
+ )
776
+ )
777
+ )
778
+ elif mode in {"rebuild-db", "catalog-assisted"}:
779
+ if wiki_dir and Path(wiki_dir).is_dir():
780
+ actions.append(
781
+ _action_payload(
782
+ _RecoveryActionFields(action="reset_db", db_path=str(db_path), reason="official_recovery_rebuild")
783
+ )
784
+ )
785
+ hints = _catalog_hints(catalog_path) if mode == "catalog-assisted" else {}
786
+ for note in iter_notes(Path(wiki_dir)):
787
+ note_hash = note_content_hash(note)
788
+ hint = hints.get(note.name, _CatalogHint(available=False))
789
+ actions.append(
790
+ _action_payload(
791
+ _RecoveryActionFields(
792
+ action="enqueue_semantic_work",
793
+ note_path=str(note),
794
+ title=_first_heading_or_stem(note),
795
+ content_hash=note_hash,
796
+ queue_flags=["needs_semantic_ingestion"],
797
+ assigned_agent="med-link-graph-curator",
798
+ to_status="pending",
799
+ catalog_hint=hint,
800
+ reason="curator_must_read_note_before_semantic_quality_claim",
801
+ )
802
+ )
803
+ )
804
+ else:
805
+ blocked_items.append(_SchemaDriftIssue(code="wiki_dir_missing", message="wiki_dir required to enqueue semantic work").to_payload())
806
+ else:
807
+ raise ValidationError(f"unsupported vocabulary recovery mode: {mode}")
808
+
809
+ return _with_plan_id(
810
+ _json_object({
811
+ "schema": VOCABULARY_RECOVERY_PLAN_SCHEMA,
812
+ "status": "blocked" if blocked_items else "planned" if actions else "skipped",
813
+ "mode": mode,
814
+ "db_path": str(db_path),
815
+ "backup_path": backup_path,
816
+ "actions": actions,
817
+ "blocked_items": blocked_items,
818
+ "created_at": _now_iso(),
819
+ })
820
+ )
821
+
822
+
823
+ def _write_recovery_receipt(path: Path | None, receipt: JsonObject) -> JsonObject:
824
+ typed_receipt = _receipt_payload(receipt)
825
+ if path is not None:
826
+ atomic_write_text(path, json.dumps(typed_receipt, ensure_ascii=False, indent=2) + "\n")
827
+ return typed_receipt
828
+
829
+
830
+ def _receipt_blocked_unwritable(
831
+ *,
832
+ plan: _RecoveryPlanPayload,
833
+ db_path: Path,
834
+ receipt_path: Path,
835
+ error: BaseException,
836
+ ) -> JsonObject:
837
+ return _receipt_payload({
838
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
839
+ "status": "blocked",
840
+ "blocked_reason": "vocabulary_recovery_receipt_unwritable",
841
+ "plan_id": plan.plan_id,
842
+ "db_path": str(db_path),
843
+ "backup_path": plan.backup_path,
844
+ "applied_actions": [],
845
+ "skipped_items": [],
846
+ "error_items": [
847
+ {
848
+ "code": "receipt_unwritable",
849
+ "receipt_path": str(receipt_path),
850
+ "message": str(error),
851
+ }
852
+ ],
853
+ "next_action": "Escolher um caminho gravável para --receipt e repetir o apply antes de mutar SQLite.",
854
+ "required_inputs": ["receipt_path"],
855
+ })
856
+
857
+
858
+ def _reserve_recovery_receipt(*, plan: _RecoveryPlanPayload, db_path: Path, receipt_path: Path) -> JsonObject | None:
859
+ pending_receipt = _receipt_payload({
860
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
861
+ "status": "pending",
862
+ "plan_id": plan.plan_id,
863
+ "db_path": str(db_path),
864
+ "backup_path": plan.backup_path,
865
+ "applied_actions": [],
866
+ "skipped_items": [],
867
+ "error_items": [],
868
+ "next_action": "Recovery em andamento; este recibo será finalizado após a aplicação do plano.",
869
+ "created_at": _now_iso(),
870
+ })
871
+ try:
872
+ _write_recovery_receipt(receipt_path, pending_receipt)
873
+ except (FileWriteError, OSError) as exc:
874
+ return _receipt_blocked_unwritable(plan=plan, db_path=db_path, receipt_path=receipt_path, error=exc)
875
+ return None
876
+
877
+
878
+ def _plan_stale_receipt(
879
+ *,
880
+ plan: _RecoveryPlanPayload,
881
+ db_path: Path,
882
+ backup_path: str,
883
+ skipped: Sequence[JsonObject],
884
+ ) -> JsonObject:
885
+ return _receipt_payload({
886
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
887
+ "status": "blocked",
888
+ "blocked_reason": "vocabulary_recovery_plan_stale",
889
+ "plan_id": plan.plan_id,
890
+ "db_path": str(db_path),
891
+ "backup_path": backup_path,
892
+ "applied_actions": [],
893
+ "skipped_items": skipped,
894
+ "error_items": skipped,
895
+ "next_action": "Gerar novo vocabulary-recover --dry-run e revisar skipped_items antes de repetir apply.",
896
+ })
897
+
898
+
899
+ def _receipt_finalization_failed_receipt(
900
+ *,
901
+ plan: _RecoveryPlanPayload,
902
+ db_path: Path,
903
+ backup_path: str,
904
+ error: BaseException,
905
+ ) -> JsonObject:
906
+ return _receipt_payload({
907
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
908
+ "status": "blocked",
909
+ "blocked_reason": "vocabulary_recovery_receipt_finalization_failed",
910
+ "plan_id": plan.plan_id,
911
+ "db_path": str(db_path),
912
+ "backup_path": backup_path,
913
+ "applied_actions": [],
914
+ "skipped_items": [],
915
+ "error_items": [{"code": "receipt_finalization_failed", "message": str(error)}],
916
+ "next_action": "Rollback do SQLite executado; corrigir o caminho do receipt e repetir o apply.",
917
+ })
918
+
919
+
920
+ def _restore_db_backup(*, db_path: Path, backup_path: str, db_existed: bool) -> None:
921
+ if db_existed and backup_path and Path(backup_path).is_file():
922
+ shutil.copy2(Path(backup_path), db_path)
923
+ elif db_path.exists():
924
+ db_path.unlink()
925
+
926
+
927
+ def _queue_row_exists(conn: sqlite3.Connection, *, queue_id: int, content_hash: str) -> bool:
928
+ return bool(
929
+ conn.execute(
930
+ """
931
+ SELECT 1
932
+ FROM note_semantic_ingestion_queue
933
+ WHERE id=? AND content_hash=? AND status IN ('pending', 'claimed')
934
+ """,
935
+ (queue_id, content_hash),
936
+ ).fetchone()
937
+ )
938
+
939
+
940
+ def _queue_row_exists_with_status(
941
+ conn: sqlite3.Connection,
942
+ *,
943
+ queue_id: int,
944
+ content_hash: str,
945
+ statuses: tuple[str, ...],
946
+ ) -> bool:
947
+ placeholders = ",".join("?" for _ in statuses)
948
+ return bool(
949
+ conn.execute(
950
+ f"""
951
+ SELECT 1
952
+ FROM note_semantic_ingestion_queue
953
+ WHERE id=? AND content_hash=? AND status IN ({placeholders})
954
+ """,
955
+ (queue_id, content_hash, *statuses),
956
+ ).fetchone()
957
+ )
958
+
959
+
960
+ def _validate_recovery_actions(conn: sqlite3.Connection | None, actions: Sequence[JsonObject]) -> list[JsonObject]:
961
+ skipped: list[JsonObject] = []
962
+ for raw in actions:
963
+ fields = _action_fields(raw)
964
+ if fields is None:
965
+ skipped.append(_json_object({"action": "", "skipped_reason": "invalid_action"}))
966
+ continue
967
+ action = fields.action
968
+ if action in {"reset_db", "create_missing_schema"}:
969
+ continue
970
+ if action in {"reset_claimed_to_pending", "mark_orphan_queue_blocked"}:
971
+ if conn is not None:
972
+ from_status = fields.from_status
973
+ statuses = ("pending", "claimed", "stale") if from_status == "stale" else ("pending", "claimed")
974
+ if not _queue_row_exists_with_status(
975
+ conn,
976
+ queue_id=fields.queue_id,
977
+ content_hash=fields.content_hash,
978
+ statuses=statuses,
979
+ ):
980
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
981
+ continue
982
+ if action == "fix_case_path":
983
+ actual_path = Path(fields.actual_path)
984
+ if not actual_path.is_file():
985
+ skipped.append(_action_with(raw, "skipped_reason", "actual_path_missing"))
986
+ continue
987
+ content_hash = fields.content_hash
988
+ if note_content_hash(actual_path) != content_hash:
989
+ skipped.append(_action_with(raw, "skipped_reason", "content_hash_mismatch"))
990
+ continue
991
+ if conn is not None:
992
+ from_status = fields.from_status
993
+ statuses = ("pending", "claimed", "stale") if from_status == "stale" else ("pending", "claimed")
994
+ if not _queue_row_exists_with_status(
995
+ conn,
996
+ queue_id=fields.queue_id,
997
+ content_hash=content_hash,
998
+ statuses=statuses,
999
+ ):
1000
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1001
+ continue
1002
+ if action == "enqueue_semantic_work":
1003
+ note_path = Path(fields.note_path)
1004
+ if not note_path.is_file():
1005
+ skipped.append(_action_with(raw, "skipped_reason", "note_path_missing"))
1006
+ continue
1007
+ content_hash = fields.content_hash
1008
+ if note_content_hash(note_path) != content_hash:
1009
+ skipped.append(_action_with(raw, "skipped_reason", "content_hash_mismatch"))
1010
+ continue
1011
+ if action == "refresh_stale_to_pending":
1012
+ note_path = Path(fields.note_path)
1013
+ if not note_path.is_file():
1014
+ skipped.append(_action_with(raw, "skipped_reason", "note_path_missing"))
1015
+ continue
1016
+ actual_hash = fields.actual_hash
1017
+ if note_content_hash(note_path) != actual_hash:
1018
+ skipped.append(_action_with(raw, "skipped_reason", "content_hash_mismatch"))
1019
+ continue
1020
+ queue_id = fields.queue_id
1021
+ if conn is not None:
1022
+ if not _queue_row_exists_with_status(
1023
+ conn,
1024
+ queue_id=queue_id,
1025
+ content_hash=fields.content_hash,
1026
+ statuses=("stale",),
1027
+ ):
1028
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1029
+ continue
1030
+ duplicate = conn.execute(
1031
+ """
1032
+ SELECT 1
1033
+ FROM note_semantic_ingestion_queue
1034
+ WHERE note_path=? AND content_hash=? AND id<>?
1035
+ """,
1036
+ (str(note_path), actual_hash, queue_id),
1037
+ ).fetchone()
1038
+ if duplicate:
1039
+ skipped.append(_action_with(raw, "skipped_reason", "target_queue_row_exists"))
1040
+ continue
1041
+ if action not in {"mark_stale", "mark_blocked"}:
1042
+ skipped.append(_action_with(raw, "skipped_reason", "not_db_state_transition"))
1043
+ continue
1044
+ if conn is not None:
1045
+ from_status = fields.from_status
1046
+ statuses = ("pending", "claimed", "stale") if from_status == "stale" else ("pending", "claimed")
1047
+ if not _queue_row_exists_with_status(
1048
+ conn,
1049
+ queue_id=fields.queue_id,
1050
+ content_hash=fields.content_hash,
1051
+ statuses=statuses,
1052
+ ):
1053
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1054
+ return skipped
1055
+
1056
+
1057
+ def _apply_recovery_actions(conn: sqlite3.Connection, actions: Sequence[JsonObject]) -> tuple[list[JsonObject], list[JsonObject]]:
1058
+ applied: list[JsonObject] = []
1059
+ skipped: list[JsonObject] = []
1060
+ for raw in actions:
1061
+ fields = _action_fields(raw)
1062
+ if fields is None:
1063
+ skipped.append(_json_object({"action": "", "skipped_reason": "invalid_action"}))
1064
+ continue
1065
+ action = fields.action
1066
+ if action in {"reset_db", "create_missing_schema"}:
1067
+ applied.append(raw)
1068
+ continue
1069
+ if action == "reset_claimed_to_pending":
1070
+ cursor = conn.execute(
1071
+ """
1072
+ UPDATE note_semantic_ingestion_queue
1073
+ SET status='pending', updated_at=CURRENT_TIMESTAMP
1074
+ WHERE id=? AND content_hash=? AND status='claimed'
1075
+ """,
1076
+ (fields.queue_id, fields.content_hash),
1077
+ )
1078
+ if cursor.rowcount:
1079
+ applied.append(raw)
1080
+ else:
1081
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1082
+ continue
1083
+ if action == "mark_orphan_queue_blocked":
1084
+ from_status = fields.from_status
1085
+ allowed_statuses = "'pending', 'claimed', 'stale'" if from_status == "stale" else "'pending', 'claimed'"
1086
+ cursor = conn.execute(
1087
+ f"""
1088
+ UPDATE note_semantic_ingestion_queue
1089
+ SET status='blocked', updated_at=CURRENT_TIMESTAMP
1090
+ WHERE id=? AND content_hash=? AND status IN ({allowed_statuses})
1091
+ """,
1092
+ (fields.queue_id, fields.content_hash),
1093
+ )
1094
+ if cursor.rowcount:
1095
+ applied.append(raw)
1096
+ else:
1097
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1098
+ continue
1099
+ if action == "fix_case_path":
1100
+ content_hash = fields.content_hash
1101
+ actual_path = Path(fields.actual_path)
1102
+ from_status = fields.from_status
1103
+ allowed_statuses = "'pending', 'claimed', 'stale'" if from_status == "stale" else "'pending', 'claimed'"
1104
+ cursor = conn.execute(
1105
+ f"""
1106
+ UPDATE note_semantic_ingestion_queue
1107
+ SET note_path=?, updated_at=CURRENT_TIMESTAMP
1108
+ WHERE id=? AND content_hash=? AND status IN ({allowed_statuses})
1109
+ """,
1110
+ (str(actual_path), fields.queue_id, content_hash),
1111
+ )
1112
+ if cursor.rowcount:
1113
+ applied.append(raw)
1114
+ else:
1115
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1116
+ continue
1117
+ if action == "enqueue_semantic_work":
1118
+ note_path = Path(fields.note_path)
1119
+ content_hash = fields.content_hash
1120
+ note_id = upsert_note(
1121
+ conn,
1122
+ path=note_path,
1123
+ title=fields.title or note_path.stem,
1124
+ content_hash=content_hash,
1125
+ )
1126
+ conn.execute(
1127
+ """
1128
+ INSERT OR IGNORE INTO note_semantic_ingestion_queue(
1129
+ note_id, note_path, content_hash, queue_flags_json, assigned_agent, status
1130
+ )
1131
+ VALUES (?, ?, ?, ?, ?, 'pending')
1132
+ """,
1133
+ (
1134
+ note_id,
1135
+ str(note_path),
1136
+ content_hash,
1137
+ json.dumps(fields.queue_flags or ["needs_semantic_ingestion"], ensure_ascii=False),
1138
+ fields.assigned_agent or "med-link-graph-curator",
1139
+ ),
1140
+ )
1141
+ applied.append(raw)
1142
+ continue
1143
+ if action == "refresh_stale_to_pending":
1144
+ queue_id = fields.queue_id
1145
+ actual_hash = fields.actual_hash
1146
+ note_path = Path(fields.note_path)
1147
+ conn.execute(
1148
+ """
1149
+ UPDATE notes
1150
+ SET content_hash=?, updated_at=CURRENT_TIMESTAMP
1151
+ WHERE id = (
1152
+ SELECT note_id
1153
+ FROM note_semantic_ingestion_queue
1154
+ WHERE id=?
1155
+ )
1156
+ """,
1157
+ (actual_hash, queue_id),
1158
+ )
1159
+ cursor = conn.execute(
1160
+ """
1161
+ UPDATE note_semantic_ingestion_queue
1162
+ SET note_path=?, content_hash=?, status='pending', updated_at=CURRENT_TIMESTAMP
1163
+ WHERE id=? AND content_hash=? AND status='stale'
1164
+ """,
1165
+ (str(note_path), actual_hash, queue_id, fields.content_hash),
1166
+ )
1167
+ if cursor.rowcount:
1168
+ applied.append(raw)
1169
+ else:
1170
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1171
+ continue
1172
+ if action not in {"mark_stale", "mark_blocked"}:
1173
+ skipped.append(_action_with(raw, "skipped_reason", "not_db_state_transition"))
1174
+ continue
1175
+ status = "stale" if action == "mark_stale" else "blocked"
1176
+ from_status = fields.from_status
1177
+ allowed_statuses = "'pending', 'claimed', 'stale'" if from_status == "stale" else "'pending', 'claimed'"
1178
+ cursor = conn.execute(
1179
+ f"""
1180
+ UPDATE note_semantic_ingestion_queue
1181
+ SET status=?, updated_at=CURRENT_TIMESTAMP
1182
+ WHERE id=? AND content_hash=? AND status IN ({allowed_statuses})
1183
+ """,
1184
+ (status, fields.queue_id, fields.content_hash),
1185
+ )
1186
+ if cursor.rowcount:
1187
+ applied.append(raw)
1188
+ else:
1189
+ skipped.append(_action_with(raw, "skipped_reason", "queue_row_changed"))
1190
+ return applied, skipped
1191
+
1192
+
1193
+ def _legacy_direct_apply_allowed(actions: Sequence[JsonObject]) -> bool:
1194
+ allowed = {"create_missing_schema", "reset_claimed_to_pending", "mark_orphan_queue_blocked"}
1195
+ return all(_action_name(raw) in allowed for raw in actions)
1196
+
1197
+
1198
+ def _completed_receipt_status(actions: Sequence[JsonObject]) -> Literal["completed", "applied"]:
1199
+ completed = {"create_missing_schema", "reset_claimed_to_pending", "mark_orphan_queue_blocked"}
1200
+ return "completed" if any(_action_name(raw) in completed for raw in actions) else "applied"
1201
+
1202
+
1203
+ def _action_summaries(actions: Sequence[JsonObject]) -> list[JsonObject]:
1204
+ summaries: list[JsonObject] = []
1205
+ for action in actions:
1206
+ fields = _action_fields(action)
1207
+ code = fields.code or fields.action if fields is not None else ""
1208
+ summaries.append(_json_object({**action, "code": code, "applied_count": 1}))
1209
+ return summaries
1210
+
1211
+
1212
+ def apply_vocabulary_recovery_plan(
1213
+ *args: object,
1214
+ db_path: Path | None = None,
1215
+ plan: JsonObject | None = None,
1216
+ receipt_path: Path | None = None,
1217
+ ) -> JsonObject:
1218
+ legacy_direct_apply = False
1219
+ if args:
1220
+ if len(args) != 1 or not isinstance(args[0], dict) or plan is not None:
1221
+ raise TypeError("apply_vocabulary_recovery_plan() accepts either plan or keyword arguments")
1222
+ plan = _json_object(args[0])
1223
+ db_path = Path(_plan_payload(plan).db_path)
1224
+ legacy_direct_apply = True
1225
+ if plan is None or db_path is None:
1226
+ raise TypeError("apply_vocabulary_recovery_plan() missing required plan/db_path")
1227
+ plan_data = _json_object(plan)
1228
+ plan_model = _plan_payload(plan_data)
1229
+ expected = _with_plan_id(_json_object({key: value for key, value in plan_data.items() if key != "plan_id"}))
1230
+ if expected["plan_id"] != plan_model.plan_id:
1231
+ raise ValidationError("vocabulary recovery plan_id mismatch")
1232
+ if plan_model.db_path != str(db_path):
1233
+ raise ValidationError("vocabulary recovery db_path mismatch")
1234
+ actions = plan_model.actions
1235
+ blocked_items = plan_model.blocked_items
1236
+ if plan_model.status == "blocked" or blocked_items:
1237
+ return _write_recovery_receipt(
1238
+ receipt_path,
1239
+ _receipt_payload({
1240
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
1241
+ "status": "blocked",
1242
+ "blocked_reason": "vocabulary_recovery_plan_blocked",
1243
+ "plan_id": plan_model.plan_id,
1244
+ "db_path": str(db_path),
1245
+ "backup_path": plan_model.backup_path,
1246
+ "applied_actions": [],
1247
+ "skipped_items": [],
1248
+ "error_items": blocked_items,
1249
+ "next_action": "Corrigir blockers do plano e gerar novo vocabulary-recover --dry-run antes do apply.",
1250
+ }),
1251
+ )
1252
+ if actions and receipt_path is None and not (
1253
+ legacy_direct_apply and _legacy_direct_apply_allowed(actions)
1254
+ ):
1255
+ return _receipt_payload({
1256
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
1257
+ "status": "blocked",
1258
+ "blocked_reason": "vocabulary_recovery_receipt_required",
1259
+ "plan_id": plan_model.plan_id,
1260
+ "db_path": str(db_path),
1261
+ "backup_path": plan_model.backup_path,
1262
+ "applied_actions": [],
1263
+ "skipped_items": [],
1264
+ "error_items": [],
1265
+ "next_action": "Repetir com receipt_path/--receipt para preservar rollback/auditoria antes de mutar SQLite.",
1266
+ "required_inputs": ["receipt_path"],
1267
+ })
1268
+
1269
+ db_path = Path(db_path)
1270
+ if actions and receipt_path is not None:
1271
+ blocked_receipt = _reserve_recovery_receipt(plan=plan_model, db_path=db_path, receipt_path=receipt_path)
1272
+ if blocked_receipt is not None:
1273
+ return blocked_receipt
1274
+ backup_path = plan_model.backup_path
1275
+ needs_reset = any(_action_name(raw) == "reset_db" for raw in actions)
1276
+ needs_schema_create = any(_action_name(raw) == "create_missing_schema" for raw in actions)
1277
+ if not needs_reset:
1278
+ if needs_schema_create:
1279
+ initialize_vocabulary_db(db_path)
1280
+ with sqlite3.connect(db_path) as conn:
1281
+ conn.execute("BEGIN IMMEDIATE")
1282
+ skipped = _validate_recovery_actions(conn, actions)
1283
+ if skipped:
1284
+ conn.rollback()
1285
+ return _write_recovery_receipt(
1286
+ receipt_path,
1287
+ _plan_stale_receipt(plan=plan_model, db_path=db_path, backup_path=backup_path, skipped=skipped),
1288
+ )
1289
+ applied, skipped = _apply_recovery_actions(conn, actions)
1290
+ if skipped:
1291
+ conn.rollback()
1292
+ return _write_recovery_receipt(
1293
+ receipt_path,
1294
+ _plan_stale_receipt(plan=plan_model, db_path=db_path, backup_path=backup_path, skipped=skipped),
1295
+ )
1296
+ final_receipt = _receipt_payload({
1297
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
1298
+ "status": _completed_receipt_status(actions),
1299
+ "mode": plan_model.mode,
1300
+ "plan_id": plan_model.plan_id,
1301
+ "db_path": str(db_path),
1302
+ "backup_path": backup_path,
1303
+ "applied_actions": applied,
1304
+ "actions": _action_summaries(applied),
1305
+ "skipped_items": [],
1306
+ "error_items": [],
1307
+ "diagnosis_after": {},
1308
+ "next_action": "",
1309
+ })
1310
+ try:
1311
+ _write_recovery_receipt(receipt_path, final_receipt)
1312
+ except (FileWriteError, OSError) as exc:
1313
+ conn.rollback()
1314
+ return _receipt_finalization_failed_receipt(
1315
+ plan=plan_model,
1316
+ db_path=db_path,
1317
+ backup_path=backup_path,
1318
+ error=exc,
1319
+ )
1320
+ conn.commit()
1321
+ if receipt_path is None:
1322
+ final_receipt["diagnosis_after"] = diagnose_vocabulary_status(db_path)
1323
+ return _receipt_payload(final_receipt)
1324
+
1325
+ if backup_path and db_path.exists():
1326
+ backup = Path(backup_path)
1327
+ if backup.exists():
1328
+ return _write_recovery_receipt(
1329
+ receipt_path,
1330
+ _receipt_payload({
1331
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
1332
+ "status": "blocked",
1333
+ "blocked_reason": "vocabulary_recovery_backup_exists",
1334
+ "plan_id": plan_model.plan_id,
1335
+ "db_path": str(db_path),
1336
+ "backup_path": str(backup),
1337
+ "applied_actions": [],
1338
+ "skipped_items": [],
1339
+ "error_items": [{"code": "backup_exists", "backup_path": str(backup)}],
1340
+ "next_action": "Gerar novo vocabulary-recover --dry-run para obter um novo backup/plan antes de aplicar novamente.",
1341
+ }),
1342
+ )
1343
+ backup.parent.mkdir(parents=True, exist_ok=True)
1344
+ shutil.copy2(db_path, backup)
1345
+ db_existed_before_reset = bool(backup_path)
1346
+ skipped = _validate_recovery_actions(None, actions)
1347
+ if skipped:
1348
+ return _write_recovery_receipt(
1349
+ receipt_path,
1350
+ _plan_stale_receipt(plan=plan_model, db_path=db_path, backup_path=backup_path, skipped=skipped),
1351
+ )
1352
+ if needs_reset and db_path.exists():
1353
+ db_path.unlink()
1354
+ if needs_reset:
1355
+ initialize_vocabulary_db(db_path)
1356
+
1357
+ with sqlite3.connect(db_path) as conn:
1358
+ applied, skipped = _apply_recovery_actions(conn, actions)
1359
+ if skipped:
1360
+ _restore_db_backup(db_path=db_path, backup_path=backup_path, db_existed=db_existed_before_reset)
1361
+ return _write_recovery_receipt(
1362
+ receipt_path,
1363
+ _plan_stale_receipt(plan=plan_model, db_path=db_path, backup_path=backup_path, skipped=skipped),
1364
+ )
1365
+ final_receipt = _receipt_payload({
1366
+ "schema": VOCABULARY_RECOVERY_RECEIPT_SCHEMA,
1367
+ "status": _completed_receipt_status(actions),
1368
+ "mode": plan_model.mode,
1369
+ "plan_id": plan_model.plan_id,
1370
+ "db_path": str(db_path),
1371
+ "backup_path": backup_path,
1372
+ "applied_actions": applied,
1373
+ "actions": _action_summaries(applied),
1374
+ "skipped_items": [],
1375
+ "error_items": [],
1376
+ "diagnosis_after": diagnose_vocabulary_status(db_path),
1377
+ "next_action": "",
1378
+ })
1379
+ try:
1380
+ return _write_recovery_receipt(receipt_path, final_receipt)
1381
+ except (FileWriteError, OSError) as exc:
1382
+ _restore_db_backup(db_path=db_path, backup_path=backup_path, db_existed=db_existed_before_reset)
1383
+ return _receipt_finalization_failed_receipt(
1384
+ plan=plan_model,
1385
+ db_path=db_path,
1386
+ backup_path=backup_path,
1387
+ error=exc,
1388
+ )