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,1560 @@
1
+ """Shared path resolution for MedNotes.
2
+
3
+ User-specific paths must live in persistent MedNotes state, not in generated
4
+ runtime bundles or workflow code. This module centralizes that rule for Wiki,
5
+ flashcard, Obsidian and enrichment workflows.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import platform
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ try:
22
+ import tomllib
23
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
24
+ tomllib = None
25
+
26
+ from mednotes.kernel.workflow import DecisionEvidence, HumanDecisionPacket, RejectedAutomation, WorkflowDecision
27
+
28
+
29
+ def extension_root() -> Path:
30
+ """Raiz do bundle distribuído — a pasta que contém ``scripts/`` e ``src/``.
31
+
32
+ Fonte ÚNICA da resolução (ADR-0001 regra 10): em vez de cada módulo contar
33
+ níveis com ``parents[N]`` (que quebram quando o módulo muda de profundidade),
34
+ todos chamam isto. Acha o ancestral certo, logo funciona no repo (``bundle/``)
35
+ e no artefato (``dist/``).
36
+ """
37
+ here = Path(__file__).resolve()
38
+ for parent in here.parents:
39
+ if (parent / "scripts").is_dir() and (parent / "src").is_dir():
40
+ return parent
41
+ return here.parents[4]
42
+
43
+
44
+ APP_DIR_NAME = ".mednotes"
45
+ APP_HOME_ENV_VARS = ("MEDNOTES_HOME",)
46
+ CONFIG_ENV_VARS = ("MEDNOTES_CONFIG",)
47
+ GEMINI_MEMORY_ENV_VARS = ("MEDNOTES_GEMINI_MEMORY", "MEDICAL_NOTES_GEMINI_MEMORY")
48
+ PATHS_SCHEMA = "medical-notes-workbench.paths.v1"
49
+ ENVIRONMENT_PREFLIGHT_SCHEMA = "medical-notes-workbench.environment-preflight.v1"
50
+ CONFIG_REPAIR_SCHEMA = "medical-notes-workbench.config-template-repair.v1"
51
+ ENVIRONMENT_BLOCKER_CODE = "environment_blocker.windows_path_or_venv"
52
+ GEMINI_PATH_PROBE_SCHEMA = "medical-notes-workbench.gemini-path-probe.v1"
53
+ DEFAULT_CATALOG_PATH = "~/.mednotes/CATALOGO_WIKI.json"
54
+ DEFAULT_RAW_DIR = "~/.mednotes/Chats_Raw"
55
+ _WINDOWS_PROMPT_FILE_THRESHOLD = 6000
56
+
57
+ _PATHS_BLOCK_RE = re.compile(
58
+ rf"(?ms)^```[ \t]*toml[^\n]*\b{re.escape(PATHS_SCHEMA)}\b[^\n]*\n(?P<body>.*?)^```[ \t]*$"
59
+ )
60
+ _WINDOWS_PATH_STRING_RE = re.compile(
61
+ r'(?m)^(\s*(?:wiki_dir|raw_dir|path|catalog_path|vocabulary_db_path)\s*=\s*)"([^"\n]*\\[^"\n]*)"'
62
+ )
63
+ _MOJIBAKE_MARKERS = (
64
+ "á",
65
+ "â",
66
+ "ã",
67
+ "ç",
68
+ "é",
69
+ "ê",
70
+ "í",
71
+ "ó",
72
+ "ô",
73
+ "ú",
74
+ "Ç",
75
+ "É",
76
+ "—",
77
+ "–",
78
+ "“",
79
+ "â€",
80
+ "´",
81
+ "�",
82
+ )
83
+ _MARKDOWN_AT_REF_RE = re.compile(r"@([^\s)]+\.md)\b", re.IGNORECASE)
84
+ _MARKDOWN_LINK_REF_RE = re.compile(r"\]\(([^)#?]+\.md)(?:[#?][^)]*)?\)", re.IGNORECASE)
85
+ _BARE_MARKDOWN_CONTEXT_REF_RE = re.compile(r"\b[\w.-]+\.md\b", re.IGNORECASE)
86
+ # Probe hints for identifying a Wiki root; taxonomy policy lives in
87
+ # bundle/scripts/mednotes/wiki/taxonomy/policy.py.
88
+ _WIKI_DIR_PROBE_TOP_LEVEL_HINTS = {
89
+ "1. Clínica Médica",
90
+ "1. Clinica Medica",
91
+ "2. Cirurgia",
92
+ "3. Ginecologia e Obstetrícia",
93
+ "3. Ginecologia e Obstetricia",
94
+ "4. Pediatria",
95
+ "5. Preventiva e Saúde Coletiva",
96
+ "5. Preventiva e Saude Coletiva",
97
+ }
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class PathCandidate:
102
+ path: Path
103
+ source: str
104
+ exists: bool
105
+ is_dir: bool
106
+ reason: str = ""
107
+ compat_warning: str = ""
108
+ raw_dir: Path | None = None
109
+ confidence: str = ""
110
+
111
+ def as_dict(self) -> dict[str, object]:
112
+ data: dict[str, object] = {
113
+ "path": str(self.path),
114
+ "source": self.source,
115
+ "exists": self.exists,
116
+ "is_dir": self.is_dir,
117
+ }
118
+ if self.reason:
119
+ data["reason"] = self.reason
120
+ if self.compat_warning:
121
+ data["compat_warning"] = self.compat_warning
122
+ if self.raw_dir is not None:
123
+ data["raw_dir"] = str(self.raw_dir)
124
+ if self.confidence:
125
+ data["confidence"] = self.confidence
126
+ return data
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class WikiPathResolution:
131
+ path: Path | None
132
+ source: str
133
+ memory_path: Path
134
+ config_path: Path | None
135
+ candidates: tuple[PathCandidate, ...] = ()
136
+ compat_warnings: tuple[str, ...] = ()
137
+ blocked_reason: str = ""
138
+ next_action: str = ""
139
+ required_inputs: tuple[str, ...] = ("wiki_dir",)
140
+ human_decision_packet: HumanDecisionPacket | None = None
141
+
142
+ @property
143
+ def ok(self) -> bool:
144
+ return self.path is not None and not self.blocked_reason
145
+
146
+ def as_payload(self, *, phase: str = "resolve_wiki_dir") -> dict[str, object]:
147
+ payload: dict[str, object] = {
148
+ "schema": "medical-notes-workbench.path-resolution.v1",
149
+ "phase": phase,
150
+ "status": "completed" if self.ok else "blocked",
151
+ "blocked_reason": self.blocked_reason,
152
+ "next_action": self.next_action,
153
+ "required_inputs": list(self.required_inputs),
154
+ "wiki_dir": str(self.path) if self.path else "",
155
+ "wiki_source": self.source,
156
+ "wiki_dir_source": self.source,
157
+ "memory_path": str(self.memory_path),
158
+ "config_path": str(self.config_path) if self.config_path else "",
159
+ "candidates": [candidate.as_dict() for candidate in self.candidates],
160
+ "compat_warnings": list(self.compat_warnings),
161
+ "human_decision_required": self.human_decision_packet is not None,
162
+ }
163
+ if self.human_decision_packet is not None:
164
+ packet = _human_decision_packet_payload(self.human_decision_packet)
165
+ payload["human_decision_packet"] = packet
166
+ payload["human_decision_packets"] = [packet]
167
+ return payload
168
+
169
+
170
+ def expand_path(value: str | os.PathLike[str]) -> Path:
171
+ return Path(os.path.expandvars(str(value))).expanduser()
172
+
173
+
174
+ def _human_decision_packet_payload(packet: HumanDecisionPacket) -> dict[str, object]:
175
+ """Serialize typed human-decision packets at the public JSON edge."""
176
+
177
+ return packet.model_dump(mode="json", by_alias=True)
178
+
179
+
180
+ def user_state_dir() -> Path:
181
+ for env_name in APP_HOME_ENV_VARS:
182
+ value = os.environ.get(env_name)
183
+ if value:
184
+ return expand_path(value)
185
+ return Path.home() / APP_DIR_NAME
186
+
187
+
188
+ def default_config_path() -> Path:
189
+ return user_state_dir() / "config.toml"
190
+
191
+
192
+ def persistent_gemini_path() -> Path:
193
+ for env_name in GEMINI_MEMORY_ENV_VARS:
194
+ value = os.environ.get(env_name)
195
+ if value:
196
+ return expand_path(value)
197
+ return Path.home() / ".gemini" / "GEMINI.md"
198
+
199
+
200
+ def find_config(explicit: str | os.PathLike[str] | None = None, *, start: Path | None = None) -> Path | None:
201
+ if explicit:
202
+ return expand_path(explicit)
203
+
204
+ for env_name in CONFIG_ENV_VARS:
205
+ value = os.environ.get(env_name)
206
+ if value:
207
+ return expand_path(value)
208
+
209
+ if any(os.environ.get(env_name) for env_name in APP_HOME_ENV_VARS):
210
+ return default_config_path()
211
+
212
+ cur = (start or Path.cwd()).resolve()
213
+ for directory in (cur, *cur.parents):
214
+ candidate = directory / "config.toml"
215
+ if candidate.is_file():
216
+ return candidate
217
+
218
+ user_config = default_config_path()
219
+ if user_config.is_file():
220
+ return user_config
221
+ return user_config
222
+
223
+
224
+ def read_toml(path: Path | None) -> dict[str, Any]:
225
+ if not path or not path.exists():
226
+ return {}
227
+ if tomllib is None:
228
+ raise RuntimeError("tomllib unavailable; use Python 3.11+ for TOML support")
229
+ return _loads_toml_with_windows_path_fallback(path.read_text(encoding="utf-8"))
230
+
231
+
232
+ def _loads_toml_with_windows_path_fallback(text: str) -> dict[str, Any]:
233
+ if tomllib is None:
234
+ raise RuntimeError("tomllib unavailable; use Python 3.11+ for TOML support")
235
+ toml = tomllib
236
+ try:
237
+ return toml.loads(text)
238
+ except toml.TOMLDecodeError:
239
+ repaired = _WINDOWS_PATH_STRING_RE.sub(
240
+ lambda match: match.group(1) + json.dumps(match.group(2).replace("\\", "/"), ensure_ascii=False),
241
+ text,
242
+ )
243
+ if repaired == text:
244
+ raise
245
+ return toml.loads(repaired)
246
+
247
+
248
+ def config_encoding_warnings(path: Path | None) -> list[dict[str, Any]]:
249
+ if not path or not path.exists():
250
+ return []
251
+ raw = path.read_bytes()
252
+ try:
253
+ text = raw.decode("utf-8")
254
+ except UnicodeDecodeError as exc:
255
+ return [
256
+ {
257
+ "code": "config_encoding.not_utf8",
258
+ "path": str(path),
259
+ "detail": str(exc),
260
+ "next_action": "Rodar repair-config-template --json para recriar config.toml como UTF-8; nao editar manualmente durante o workflow.",
261
+ }
262
+ ]
263
+ markers = sorted({marker for marker in _MOJIBAKE_MARKERS if marker in text})
264
+ if not markers:
265
+ return []
266
+ return [
267
+ {
268
+ "code": "config_encoding.possible_mojibake",
269
+ "path": str(path),
270
+ "markers": markers[:8],
271
+ "next_action": "Rodar repair-config-template --json para recriar config.toml a partir do template UTF-8; set-paths deve alterar apenas [paths].",
272
+ }
273
+ ]
274
+
275
+
276
+ def read_persistent_paths(memory_path: Path | None = None) -> tuple[dict[str, str], str]:
277
+ path = memory_path or persistent_gemini_path()
278
+ if not path.exists():
279
+ return {}, ""
280
+ text = path.read_text(encoding="utf-8")
281
+ match = _PATHS_BLOCK_RE.search(text)
282
+ if not match:
283
+ return {}, ""
284
+ if tomllib is None:
285
+ return {}, "tomllib unavailable; use Python 3.11+ for legacy GEMINI.md paths"
286
+ try:
287
+ data = _loads_toml_with_windows_path_fallback(match.group("body"))
288
+ except tomllib.TOMLDecodeError as exc:
289
+ return {}, str(exc)
290
+ paths = data.get("paths", {}) if isinstance(data.get("paths"), dict) else {}
291
+ return {
292
+ key: str(value).strip()
293
+ for key, value in paths.items()
294
+ if key in {"wiki_dir", "raw_dir"} and isinstance(value, str) and value.strip()
295
+ }, ""
296
+
297
+
298
+ def resolve_wiki_dir(
299
+ *,
300
+ explicit: str | os.PathLike[str] | None = None,
301
+ config: str | os.PathLike[str] | None = None,
302
+ start: Path | None = None,
303
+ context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None = None,
304
+ enable_gemini_probe: bool = False,
305
+ ) -> WikiPathResolution:
306
+ memory_path = persistent_gemini_path()
307
+ config_path = find_config(config, start=start)
308
+
309
+ def maybe_probe(resolution: WikiPathResolution) -> WikiPathResolution:
310
+ return _maybe_gemini_path_probe(
311
+ resolution,
312
+ enabled=enable_gemini_probe,
313
+ start=start,
314
+ context_paths=context_paths,
315
+ )
316
+
317
+ if explicit:
318
+ path = expand_path(explicit).resolve()
319
+ return WikiPathResolution(
320
+ path=path,
321
+ source="cli",
322
+ memory_path=memory_path,
323
+ config_path=config_path,
324
+ candidates=(_candidate(path, "cli", "explicit --wiki-dir"),),
325
+ )
326
+
327
+ env_candidate = _env_wiki_candidate()
328
+ if env_candidate is not None:
329
+ if env_candidate.exists and env_candidate.is_dir:
330
+ return WikiPathResolution(
331
+ path=env_candidate.path,
332
+ source=env_candidate.source,
333
+ memory_path=memory_path,
334
+ config_path=config_path,
335
+ candidates=(env_candidate,),
336
+ compat_warnings=(env_candidate.compat_warning,) if env_candidate.compat_warning else (),
337
+ )
338
+ return maybe_probe(
339
+ _blocked(
340
+ "env_wiki_dir_invalid",
341
+ "Corrigir MED_WIKI_DIR para uma pasta existente ou remover a variavel e configurar o TOML do app.",
342
+ memory_path,
343
+ config_path,
344
+ candidates=(env_candidate,),
345
+ )
346
+ )
347
+
348
+ config_candidate = _config_wiki_candidate(config_path)
349
+ if config_candidate is not None:
350
+ candidate = config_candidate
351
+ if candidate.exists and candidate.is_dir:
352
+ return WikiPathResolution(
353
+ path=candidate.path,
354
+ source=candidate.source,
355
+ memory_path=memory_path,
356
+ config_path=config_path,
357
+ candidates=(candidate,),
358
+ )
359
+ return maybe_probe(
360
+ _blocked(
361
+ "config_wiki_dir_invalid",
362
+ f"Atualizar {config_path or default_config_path()} [paths].wiki_dir com uma pasta existente.",
363
+ memory_path,
364
+ config_path,
365
+ candidates=(candidate,),
366
+ )
367
+ )
368
+
369
+ contextual = _contextual_candidates(start=start, context_paths=context_paths)
370
+ distinct_contextual = _distinct_candidates(contextual)
371
+ if len(distinct_contextual) == 1:
372
+ candidate = distinct_contextual[0]
373
+ return WikiPathResolution(
374
+ path=candidate.path,
375
+ source=candidate.source,
376
+ memory_path=memory_path,
377
+ config_path=config_path,
378
+ candidates=tuple(contextual),
379
+ )
380
+ if len(distinct_contextual) > 1:
381
+ return _blocked(
382
+ "ambiguous_wiki_dir",
383
+ f"Registrar o wiki_dir correto em {config_path or default_config_path()} [paths].wiki_dir.",
384
+ memory_path,
385
+ config_path,
386
+ candidates=tuple(contextual),
387
+ human_decision_packet=_wiki_path_choice_packet(tuple(contextual), config_path or default_config_path()),
388
+ )
389
+ return maybe_probe(
390
+ _blocked(
391
+ "missing_wiki_dir",
392
+ f"Rodar set-paths ou adicionar [paths].wiki_dir em {config_path or default_config_path()}.",
393
+ memory_path,
394
+ config_path,
395
+ candidates=(),
396
+ )
397
+ )
398
+
399
+
400
+ def resolve_raw_dir(
401
+ *,
402
+ explicit: str | os.PathLike[str] | None = None,
403
+ config: str | os.PathLike[str] | None = None,
404
+ start: Path | None = None,
405
+ ) -> Path:
406
+ if explicit:
407
+ return expand_path(explicit)
408
+ env_value = os.getenv("MED_RAW_DIR")
409
+ if env_value:
410
+ return expand_path(env_value)
411
+ config_path = find_config(config, start=start)
412
+ cfg = read_toml(config_path)
413
+ paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
414
+ if paths.get("raw_dir"):
415
+ return expand_path(str(paths["raw_dir"]))
416
+ return expand_path(DEFAULT_RAW_DIR)
417
+
418
+
419
+ def plan_set_paths(
420
+ *,
421
+ config: str | os.PathLike[str] | Path | None = None,
422
+ wiki_dir: str | os.PathLike[str] | Path | None,
423
+ raw_dir: str | os.PathLike[str] | Path | None,
424
+ agent_repair: bool = False,
425
+ ) -> dict[str, Any]:
426
+ config_path = find_config(config) or default_config_path()
427
+ wiki_path = expand_path(wiki_dir).resolve(strict=False) if wiki_dir else None
428
+ raw_path = expand_path(raw_dir).resolve(strict=False) if raw_dir else None
429
+ errors: list[dict[str, str]] = []
430
+
431
+ if wiki_path is None:
432
+ errors.append({"field": "wiki_dir", "reason": "missing"})
433
+ elif not wiki_path.exists() or not wiki_path.is_dir():
434
+ errors.append({"field": "wiki_dir", "path": str(wiki_path), "reason": "not_existing_directory"})
435
+
436
+ if raw_path is None:
437
+ errors.append({"field": "raw_dir", "reason": "missing"})
438
+ elif not raw_path.exists() or not raw_path.is_dir():
439
+ errors.append({"field": "raw_dir", "path": str(raw_path), "reason": "not_existing_directory"})
440
+
441
+ if errors:
442
+ return {
443
+ "schema": "medical-notes-workbench.set-paths.v1",
444
+ "phase": "set-paths",
445
+ "status": "blocked",
446
+ "blocked_reason": "path_validation_failed",
447
+ "next_action": "Escolher pastas existentes para Wiki_Medicina e Chats_Raw antes de persistir os caminhos.",
448
+ "required_inputs": ["wiki_dir", "raw_dir"],
449
+ "human_decision_required": False,
450
+ "config_path": str(config_path),
451
+ "wiki_dir": str(wiki_path) if wiki_path else "",
452
+ "raw_dir": str(raw_path) if raw_path else "",
453
+ "errors": errors,
454
+ }
455
+
456
+ assert wiki_path is not None
457
+ assert raw_path is not None
458
+ encoding_warnings = config_encoding_warnings(config_path)
459
+ if any(warning.get("code") == "config_encoding.not_utf8" for warning in encoding_warnings):
460
+ return {
461
+ "schema": "medical-notes-workbench.set-paths.v1",
462
+ "phase": "set-paths",
463
+ "status": "blocked",
464
+ "blocked_reason": "config_encoding.not_utf8",
465
+ "next_action": "Rodar repair-config-template --json para recriar config.toml em UTF-8 antes de alterar [paths].",
466
+ "required_inputs": ["utf8_config"],
467
+ "human_decision_required": False,
468
+ "config_path": str(config_path),
469
+ "wiki_dir": str(wiki_path),
470
+ "raw_dir": str(raw_path),
471
+ "config_encoding_warnings": encoding_warnings,
472
+ }
473
+ existing = _read_paths_section(config_path)
474
+ conflicts = _valid_existing_path_conflicts(existing, wiki_dir=wiki_path, raw_dir=raw_path)
475
+ if agent_repair and conflicts:
476
+ packet = _path_conflict_packet(conflicts, wiki_dir=wiki_path, raw_dir=raw_path, config_path=config_path)
477
+ packet_payload = _human_decision_packet_payload(packet)
478
+ return {
479
+ "schema": "medical-notes-workbench.set-paths.v1",
480
+ "phase": "set-paths",
481
+ "status": "blocked",
482
+ "blocked_reason": "path_conflict.requires_decision",
483
+ "next_action": "O TOML do app ja aponta para caminhos validos diferentes; confirmar qual par deve permanecer antes de sobrescrever.",
484
+ "required_inputs": ["human_decision"],
485
+ "human_decision_required": True,
486
+ "human_decision_packet": packet_payload,
487
+ "human_decision_packets": [packet_payload],
488
+ "config_path": str(config_path),
489
+ "wiki_dir": str(wiki_path),
490
+ "raw_dir": str(raw_path),
491
+ "conflicts": conflicts,
492
+ "config_encoding_warnings": encoding_warnings,
493
+ }
494
+
495
+ _write_paths_config(config_path, wiki_dir=wiki_path, raw_dir=raw_path)
496
+ return {
497
+ "schema": "medical-notes-workbench.set-paths.v1",
498
+ "phase": "set-paths",
499
+ "status": "updated",
500
+ "blocked_reason": "",
501
+ "next_action": "",
502
+ "required_inputs": [],
503
+ "human_decision_required": False,
504
+ "config_path": str(config_path),
505
+ "wiki_dir": str(wiki_path),
506
+ "raw_dir": str(raw_path),
507
+ "config_encoding_warnings": encoding_warnings,
508
+ }
509
+
510
+
511
+ def repair_config_template(
512
+ *,
513
+ config: str | os.PathLike[str] | Path | None = None,
514
+ template: str | os.PathLike[str] | Path | None = None,
515
+ dry_run: bool = False,
516
+ ) -> dict[str, Any]:
517
+ config_path = find_config(config) or default_config_path()
518
+ template_path = expand_path(template) if template else _infer_extension_root() / "config.example.toml"
519
+ warnings_before = config_encoding_warnings(config_path)
520
+ if not template_path.is_file():
521
+ return {
522
+ "schema": CONFIG_REPAIR_SCHEMA,
523
+ "phase": "repair-config-template",
524
+ "status": "blocked",
525
+ "blocked_reason": "config_template_missing",
526
+ "next_action": "Fornecer --template apontando para config.example.toml UTF-8.",
527
+ "required_inputs": ["template"],
528
+ "human_decision_required": False,
529
+ "config_path": str(config_path),
530
+ "template_path": str(template_path),
531
+ "warnings_before": warnings_before,
532
+ }
533
+ try:
534
+ template_text = template_path.read_text(encoding="utf-8")
535
+ except UnicodeDecodeError as exc:
536
+ return {
537
+ "schema": CONFIG_REPAIR_SCHEMA,
538
+ "phase": "repair-config-template",
539
+ "status": "blocked",
540
+ "blocked_reason": "config_template_not_utf8",
541
+ "next_action": "Substituir o template por uma copia UTF-8 sem BOM antes de reparar config.toml.",
542
+ "required_inputs": ["template"],
543
+ "human_decision_required": False,
544
+ "config_path": str(config_path),
545
+ "template_path": str(template_path),
546
+ "warnings_before": warnings_before,
547
+ "error": str(exc),
548
+ }
549
+
550
+ existing_paths = _read_paths_section_relaxed(config_path)
551
+ repaired_text = _replace_paths_section_values(template_text, existing_paths)
552
+ current_text = _read_text_relaxed(config_path)[0] if config_path.exists() else ""
553
+ changed = current_text != repaired_text
554
+ status = "planned" if dry_run else "updated" if changed else "unchanged"
555
+ if not dry_run and changed:
556
+ config_path.parent.mkdir(parents=True, exist_ok=True)
557
+ tmp_path = config_path.with_name(config_path.name + ".tmp")
558
+ tmp_path.write_text(repaired_text, encoding="utf-8")
559
+ os.replace(tmp_path, config_path)
560
+
561
+ return {
562
+ "schema": CONFIG_REPAIR_SCHEMA,
563
+ "phase": "repair-config-template",
564
+ "status": status,
565
+ "blocked_reason": "",
566
+ "next_action": "",
567
+ "required_inputs": [],
568
+ "human_decision_required": False,
569
+ "config_path": str(config_path),
570
+ "template_path": str(template_path),
571
+ "changed": changed,
572
+ "dry_run": bool(dry_run),
573
+ "preserved_paths": existing_paths,
574
+ "warnings_before": warnings_before,
575
+ "warnings_after": [] if dry_run else config_encoding_warnings(config_path),
576
+ }
577
+
578
+
579
+ def environment_preflight(
580
+ *,
581
+ extension_root: str | os.PathLike[str] | None = None,
582
+ state_dir: str | os.PathLike[str] | None = None,
583
+ sample_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None = None,
584
+ platform_name: str | None = None,
585
+ python_version: tuple[int, int, int] | None = None,
586
+ uv_path: str | None = None,
587
+ powershell_command: str | None = None,
588
+ require_uv: bool = True,
589
+ ) -> dict[str, Any]:
590
+ """Return a compact preflight for Python/uv/venv/path issues.
591
+
592
+ Parameters make Windows cases testable on non-Windows CI without shelling
593
+ out or touching the user's real PATH.
594
+ """
595
+ state = expand_path(state_dir) if state_dir else user_state_dir()
596
+ extension = expand_path(extension_root) if extension_root else _infer_extension_root()
597
+ persistent_venv = state / ".venv"
598
+ bundle_venv = extension / ".venv" if extension else None
599
+ detected_platform = platform_name or platform.system()
600
+ is_windows = detected_platform.lower().startswith("win")
601
+ version = python_version or sys.version_info[:3]
602
+ uv = uv_path if uv_path is not None else shutil.which("uv")
603
+ configured_venv = os.environ.get("UV_PROJECT_ENVIRONMENT", "")
604
+ paths = [str(item) for item in (sample_paths or []) if str(item).strip()]
605
+ if configured_venv:
606
+ paths.append(configured_venv)
607
+ if extension:
608
+ paths.append(str(extension))
609
+ paths.append(str(state))
610
+
611
+ checks: list[dict[str, Any]] = []
612
+ warnings: list[str] = []
613
+ blockers: list[str] = []
614
+
615
+ def add_check(name: str, ok: bool, detail: str = "", *, warning: bool = False) -> None:
616
+ checks.append({"name": name, "ok": bool(ok), "detail": detail, "warning": bool(warning and ok is False)})
617
+ if ok:
618
+ return
619
+ (warnings if warning else blockers).append(name)
620
+
621
+ add_check(
622
+ "python_version",
623
+ tuple(version) >= (3, 11, 0),
624
+ ".".join(str(part) for part in version),
625
+ )
626
+ add_check("uv_available", bool(uv), uv or "uv not found on PATH", warning=not require_uv)
627
+
628
+ if configured_venv:
629
+ normalized_configured = _normcase_path(expand_path(configured_venv))
630
+ normalized_expected = _normcase_path(persistent_venv)
631
+ add_check(
632
+ "uv_project_environment_persistent",
633
+ normalized_configured == normalized_expected,
634
+ f"UV_PROJECT_ENVIRONMENT={configured_venv}; expected={persistent_venv}",
635
+ warning=True,
636
+ )
637
+ else:
638
+ add_check(
639
+ "uv_project_environment_set",
640
+ False,
641
+ f"set UV_PROJECT_ENVIRONMENT to {persistent_venv}",
642
+ warning=True,
643
+ )
644
+
645
+ add_check(
646
+ "persistent_venv_exists",
647
+ persistent_venv.exists(),
648
+ str(persistent_venv),
649
+ warning=True,
650
+ )
651
+ if bundle_venv is not None:
652
+ add_check(
653
+ "bundle_venv_absent",
654
+ not bundle_venv.exists(),
655
+ str(bundle_venv),
656
+ warning=True,
657
+ )
658
+
659
+ if is_windows:
660
+ add_check(
661
+ "powershell_execution_policy_hint",
662
+ True,
663
+ "use -ExecutionPolicy Bypass with bundled setup/reset scripts",
664
+ )
665
+ if powershell_command:
666
+ add_check(
667
+ "powershell_command_quoted",
668
+ _powershell_command_looks_quoted(powershell_command),
669
+ powershell_command,
670
+ warning=True,
671
+ )
672
+ for item in paths:
673
+ if _path_needs_windows_quoting(item):
674
+ add_check("windows_path_with_spaces", False, item, warning=True)
675
+ break
676
+ for item in paths:
677
+ if "\r\n" in item:
678
+ add_check("crlf_in_path_or_command", False, "CRLF detected in path/command text", warning=True)
679
+ break
680
+ for item in paths:
681
+ if len(item) >= 240:
682
+ add_check("windows_long_path_risk", False, f"{len(item)} chars", warning=True)
683
+ break
684
+
685
+ status = "blocked" if blockers else "completed_with_warnings" if warnings else "completed"
686
+ next_action = ""
687
+ if status != "completed":
688
+ next_action = _environment_next_action(is_windows=is_windows)
689
+ return {
690
+ "schema": ENVIRONMENT_PREFLIGHT_SCHEMA,
691
+ "status": status,
692
+ "blocked_reason": ENVIRONMENT_BLOCKER_CODE if blockers else "",
693
+ "next_action": next_action,
694
+ "required_inputs": ["python", "uv", "persistent_venv", "wiki_dir"],
695
+ "platform": detected_platform,
696
+ "python": ".".join(str(part) for part in version),
697
+ "uv_path": uv or "",
698
+ "state_dir": str(state),
699
+ "persistent_venv": str(persistent_venv),
700
+ "extension_root": str(extension) if extension else "",
701
+ "checks": checks,
702
+ "warnings": warnings,
703
+ "blockers": blockers,
704
+ "setup_command": "/mednotes:setup",
705
+ "reset_command": "scripts\\bootstrap_windows_python_uv.ps1" if is_windows else "uv sync",
706
+ }
707
+
708
+
709
+ def _infer_extension_root() -> Path:
710
+ return Path(__file__).resolve().parents[2]
711
+
712
+
713
+ def _normcase_path(path_value: Path) -> str:
714
+ return os.path.normcase(str(path_value.expanduser().resolve(strict=False)))
715
+
716
+
717
+ def _path_needs_windows_quoting(value: str) -> bool:
718
+ text = str(value or "")
719
+ return bool(re.search(r"\s", text) and re.match(r"^[A-Za-z]:[\\/]", text))
720
+
721
+
722
+ def _powershell_command_looks_quoted(command: str) -> bool:
723
+ text = str(command or "")
724
+ if not text.strip():
725
+ return True
726
+ windows_paths = re.findall(r"[A-Za-z]:[\\/][^;&|]+", text)
727
+ for value in windows_paths:
728
+ if " " in value and f'"{value}"' not in text and f"'{value}'" not in text:
729
+ return False
730
+ return True
731
+
732
+
733
+ def _environment_next_action(*, is_windows: bool) -> str:
734
+ if is_windows:
735
+ return (
736
+ "Rodar /mednotes:setup. Se persistir no Windows, executar "
737
+ "scripts\\bootstrap_windows_python_uv.ps1; como fallback, "
738
+ "scripts\\reset_windows_python_uv.ps1 -FullReset."
739
+ )
740
+ return (
741
+ "Rodar /mednotes:setup, configurar UV_PROJECT_ENVIRONMENT para "
742
+ "~/.mednotes/.venv e repetir uv sync antes do workflow."
743
+ )
744
+
745
+
746
+ def _candidate(
747
+ path: Path,
748
+ source: str,
749
+ reason: str,
750
+ compat_warning: str = "",
751
+ *,
752
+ raw_dir: Path | None = None,
753
+ confidence: str = "",
754
+ ) -> PathCandidate:
755
+ return PathCandidate(
756
+ path=path,
757
+ source=source,
758
+ exists=path.exists(),
759
+ is_dir=path.is_dir(),
760
+ reason=reason,
761
+ compat_warning=compat_warning,
762
+ raw_dir=raw_dir,
763
+ confidence=confidence,
764
+ )
765
+
766
+
767
+ def _env_wiki_candidate() -> PathCandidate | None:
768
+ env_value = os.getenv("MED_WIKI_DIR")
769
+ if not env_value:
770
+ return None
771
+ return _candidate(
772
+ expand_path(env_value).resolve(strict=False),
773
+ "env:MED_WIKI_DIR",
774
+ "temporary environment override",
775
+ "MED_WIKI_DIR e override temporario; persista o caminho em config.toml [paths].wiki_dir.",
776
+ )
777
+
778
+
779
+ def _config_wiki_candidate(config_path: Path | None) -> PathCandidate | None:
780
+ cfg = read_toml(config_path)
781
+ paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
782
+ value = paths.get("wiki_dir")
783
+ if not value:
784
+ return None
785
+ return _candidate(
786
+ expand_path(str(value)).resolve(strict=False),
787
+ "config:[paths].wiki_dir",
788
+ f"{config_path or default_config_path()} [paths].wiki_dir",
789
+ )
790
+
791
+
792
+ def _read_paths_section(config_path: Path | None) -> dict[str, str]:
793
+ cfg = read_toml(config_path)
794
+ paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
795
+ return {
796
+ key: str(value).strip()
797
+ for key, value in paths.items()
798
+ if key in {"wiki_dir", "raw_dir"} and isinstance(value, str) and value.strip()
799
+ }
800
+
801
+
802
+ def _read_paths_section_relaxed(config_path: Path | None) -> dict[str, str]:
803
+ if not config_path or not config_path.exists() or tomllib is None:
804
+ return {}
805
+ text, _encoding = _read_text_relaxed(config_path)
806
+ try:
807
+ data = _loads_toml_with_windows_path_fallback(text)
808
+ except tomllib.TOMLDecodeError:
809
+ return {}
810
+ paths = data.get("paths", {}) if isinstance(data.get("paths"), dict) else {}
811
+ return {
812
+ key: str(value).strip()
813
+ for key, value in paths.items()
814
+ if key in {"wiki_dir", "raw_dir", "catalog_path", "vocabulary_db_path"}
815
+ and isinstance(value, str)
816
+ and value.strip()
817
+ }
818
+
819
+
820
+ def _read_text_relaxed(path: Path) -> tuple[str, str]:
821
+ raw = path.read_bytes()
822
+ for encoding in ("utf-8", "utf-8-sig", "utf-16"):
823
+ try:
824
+ return raw.decode(encoding), encoding
825
+ except UnicodeDecodeError:
826
+ continue
827
+ return raw.decode("utf-8", errors="replace"), "utf-8-replace"
828
+
829
+
830
+ def _valid_existing_path_conflicts(
831
+ existing: dict[str, str],
832
+ *,
833
+ wiki_dir: Path,
834
+ raw_dir: Path,
835
+ ) -> list[dict[str, str]]:
836
+ conflicts: list[dict[str, str]] = []
837
+ desired = {"wiki_dir": wiki_dir, "raw_dir": raw_dir}
838
+ for field, desired_path in desired.items():
839
+ value = existing.get(field)
840
+ if not value:
841
+ continue
842
+ current = expand_path(value).resolve(strict=False)
843
+ if current == desired_path or not current.exists() or not current.is_dir():
844
+ continue
845
+ conflicts.append(
846
+ {
847
+ "field": field,
848
+ "current": str(current),
849
+ "proposed": str(desired_path),
850
+ }
851
+ )
852
+ return conflicts
853
+
854
+
855
+ def _path_conflict_packet(
856
+ conflicts: list[dict[str, str]],
857
+ *,
858
+ wiki_dir: Path,
859
+ raw_dir: Path,
860
+ config_path: Path,
861
+ ) -> HumanDecisionPacket:
862
+ resume_action = "Repetir set-paths sem --agent-repair ou informar explicitamente a escolha humana."
863
+ packet = _path_human_decision_packet(
864
+ kind="path_conflict_choice",
865
+ phase="set-paths",
866
+ reason_code="path_conflict.requires_decision",
867
+ question="O TOML do app ja tem caminhos validos. Quais caminhos devem ser mantidos?",
868
+ developer_summary="Dois pares de paths locais validos competem; sobrescrever sem escolha pode apontar a Wiki errada.",
869
+ resume_action=resume_action,
870
+ options=[
871
+ {
872
+ "id": "keep_existing",
873
+ "label": "Manter TOML atual",
874
+ "value": str(config_path),
875
+ "description": "Caminhos existentes no TOML tambem sao diretorios validos.",
876
+ },
877
+ {
878
+ "id": "use_proposed",
879
+ "label": "Usar caminhos propostos",
880
+ "value": f"wiki_dir={wiki_dir}; raw_dir={raw_dir}",
881
+ "description": "Caminhos propostos foram validados localmente.",
882
+ },
883
+ ],
884
+ evidence=[
885
+ DecisionEvidence(
886
+ summary="Config atual e proposta de agente apontam para diretorios validos diferentes.",
887
+ technical_code="path_conflict.requires_decision",
888
+ source="mednotes.platform.paths",
889
+ candidates=[{"conflicts": conflicts}],
890
+ risk="wrong_vault_mutation",
891
+ )
892
+ ],
893
+ )
894
+ return packet.model_copy(update={"context": {"conflicts": conflicts, "config_path": str(config_path)}})
895
+
896
+
897
+ def _write_paths_config(config_path: Path, *, wiki_dir: Path, raw_dir: Path | None) -> None:
898
+ config_path.parent.mkdir(parents=True, exist_ok=True)
899
+ old_text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
900
+ new_text = _replace_paths_section(old_text, wiki_dir=wiki_dir, raw_dir=raw_dir)
901
+ tmp_path = config_path.with_name(config_path.name + ".tmp")
902
+ tmp_path.write_text(new_text, encoding="utf-8")
903
+ os.replace(tmp_path, config_path)
904
+
905
+
906
+ def _replace_paths_section(text: str, *, wiki_dir: Path, raw_dir: Path | None) -> str:
907
+ values = {"wiki_dir": wiki_dir.as_posix()}
908
+ if raw_dir is not None:
909
+ values["raw_dir"] = raw_dir.as_posix()
910
+ return _replace_paths_section_values(text, values)
911
+
912
+
913
+ def _replace_paths_section_values(text: str, values: dict[str, str]) -> str:
914
+ lines = text.splitlines()
915
+ section_start: int | None = None
916
+ section_end = len(lines)
917
+ for index, line in enumerate(lines):
918
+ if line.strip() == "[paths]":
919
+ section_start = index
920
+ for next_index in range(index + 1, len(lines)):
921
+ stripped = lines[next_index].strip()
922
+ if stripped.startswith("[") and stripped.endswith("]"):
923
+ section_end = next_index
924
+ break
925
+ break
926
+
927
+ field_order = ("wiki_dir", "raw_dir", "catalog_path", "vocabulary_db_path")
928
+ path_lines = [
929
+ f"{field} = {json.dumps(str(values[field]), ensure_ascii=False)}"
930
+ for field in field_order
931
+ if str(values.get(field) or "").strip()
932
+ ]
933
+ if not path_lines:
934
+ path_lines = ['wiki_dir = ""', 'raw_dir = ""']
935
+
936
+ if section_start is None:
937
+ prefix = lines + ([""] if lines else [])
938
+ new_lines = [*prefix, "[paths]", *path_lines]
939
+ else:
940
+ remaining = [
941
+ line
942
+ for line in lines[section_start + 1 : section_end]
943
+ if not re.match(r"^\s*(wiki_dir|raw_dir|catalog_path|vocabulary_db_path)\s*=", line)
944
+ ]
945
+ new_lines = [
946
+ *lines[: section_start + 1],
947
+ *path_lines,
948
+ *remaining,
949
+ *lines[section_end:],
950
+ ]
951
+ return "\n".join(new_lines).rstrip() + "\n"
952
+
953
+
954
+ def _contextual_candidates(
955
+ *,
956
+ start: Path | None,
957
+ context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
958
+ ) -> list[PathCandidate]:
959
+ raw_paths: list[Path] = []
960
+ if context_paths:
961
+ raw_paths.extend(expand_path(item) for item in context_paths)
962
+ raw_paths.append(start or Path.cwd())
963
+
964
+ candidates: list[PathCandidate] = []
965
+ for raw in raw_paths:
966
+ path = raw.resolve() if raw.exists() else raw.expanduser().resolve(strict=False)
967
+ scan_start = path if path.is_dir() else path.parent
968
+ for current in (scan_start, *scan_start.parents):
969
+ if _looks_like_wiki_root(current):
970
+ candidates.append(_candidate(current, "context", "nearest plausible Wiki root"))
971
+ break
972
+ return _distinct_candidates(candidates)
973
+
974
+
975
+ def _looks_like_wiki_root(path: Path) -> bool:
976
+ if not path.exists() or not path.is_dir():
977
+ return False
978
+ if any((path / dirname).is_dir() for dirname in _WIKI_DIR_PROBE_TOP_LEVEL_HINTS):
979
+ return True
980
+ if (path / ".obsidian").is_dir() and any(item.suffix.lower() == ".md" for item in path.glob("*.md")):
981
+ return True
982
+ if (path / ".obsidian").is_dir() and any((path / dirname).is_dir() for dirname in _WIKI_DIR_PROBE_TOP_LEVEL_HINTS):
983
+ return True
984
+ return False
985
+
986
+
987
+ def _distinct_candidates(candidates: list[PathCandidate]) -> list[PathCandidate]:
988
+ by_path: dict[str, PathCandidate] = {}
989
+ for candidate in candidates:
990
+ key = os.path.normcase(str(candidate.path.resolve() if candidate.path.exists() else candidate.path))
991
+ by_path.setdefault(key, candidate)
992
+ return list(by_path.values())
993
+
994
+
995
+ def _blocked(
996
+ reason: str,
997
+ action: str,
998
+ memory_path: Path,
999
+ config_path: Path | None,
1000
+ *,
1001
+ candidates: tuple[PathCandidate, ...],
1002
+ extra_next_action: str = "",
1003
+ compat_warnings: tuple[str, ...] = (),
1004
+ human_decision_packet: HumanDecisionPacket | None = None,
1005
+ ) -> WikiPathResolution:
1006
+ next_action = action if not extra_next_action else f"{action} Detalhe: {extra_next_action}"
1007
+ return WikiPathResolution(
1008
+ path=None,
1009
+ source="",
1010
+ memory_path=memory_path,
1011
+ config_path=config_path,
1012
+ candidates=candidates,
1013
+ compat_warnings=compat_warnings,
1014
+ blocked_reason=reason,
1015
+ next_action=next_action,
1016
+ human_decision_packet=human_decision_packet,
1017
+ )
1018
+
1019
+
1020
+ def _maybe_gemini_path_probe(
1021
+ blocked: WikiPathResolution,
1022
+ *,
1023
+ enabled: bool,
1024
+ start: Path | None,
1025
+ context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
1026
+ ) -> WikiPathResolution:
1027
+ if not enabled or not _gemini_path_probe_enabled_by_env():
1028
+ return blocked
1029
+
1030
+ binary = _gemini_binary()
1031
+ if binary is None:
1032
+ return _blocked_with_probe_warning(blocked, "Gemini CLI nao encontrado para sondagem de caminhos.")
1033
+
1034
+ context = _gemini_probe_context(
1035
+ start=start,
1036
+ context_paths=context_paths,
1037
+ memory_path=blocked.memory_path,
1038
+ )
1039
+ if not context.strip():
1040
+ context = "Nenhum arquivo de contexto local foi encontrado."
1041
+
1042
+ retry_detail = ""
1043
+ probe_warnings: list[str] = []
1044
+ for attempt in range(2):
1045
+ prompt = _gemini_probe_prompt(blocked, retry_detail=retry_detail, attempt=attempt)
1046
+ result = _run_gemini_probe(binary, prompt, context)
1047
+ if result.get("error"):
1048
+ retry_detail = str(result["error"])
1049
+ probe_warnings.append(retry_detail)
1050
+ continue
1051
+
1052
+ payload = _extract_json_object(str(result.get("stdout", "")))
1053
+ if payload is None:
1054
+ retry_detail = "A resposta do Gemini nao continha um objeto JSON parseavel."
1055
+ probe_warnings.append(retry_detail)
1056
+ continue
1057
+
1058
+ candidates, invalid_reasons = _validated_probe_candidates(payload)
1059
+ if len(candidates) == 1:
1060
+ candidate = candidates[0]
1061
+ target_config = blocked.config_path or default_config_path()
1062
+ _write_paths_config(
1063
+ target_config,
1064
+ wiki_dir=candidate.path,
1065
+ raw_dir=candidate.raw_dir,
1066
+ )
1067
+ return WikiPathResolution(
1068
+ path=candidate.path,
1069
+ source="gemini_probe",
1070
+ memory_path=blocked.memory_path,
1071
+ config_path=target_config,
1072
+ candidates=(candidate,),
1073
+ compat_warnings=(
1074
+ f"Caminhos validados via Gemini CLI e persistidos em {target_config}.",
1075
+ ),
1076
+ )
1077
+ if len(candidates) > 1:
1078
+ target_config = blocked.config_path or default_config_path()
1079
+ return _blocked(
1080
+ "ambiguous_wiki_dir",
1081
+ f"Escolher um unico wiki_dir e registrar em {target_config} [paths].wiki_dir.",
1082
+ blocked.memory_path,
1083
+ target_config,
1084
+ candidates=tuple(candidates),
1085
+ compat_warnings=tuple(probe_warnings),
1086
+ human_decision_packet=_wiki_path_choice_packet(tuple(candidates), target_config),
1087
+ )
1088
+
1089
+ retry_detail = "; ".join(invalid_reasons) or "Gemini nao retornou candidatos validos."
1090
+ probe_warnings.append(retry_detail)
1091
+
1092
+ return _blocked_with_probe_warning(blocked, "Sondagem Gemini sem caminho valido: " + "; ".join(probe_warnings))
1093
+
1094
+
1095
+ def _gemini_path_probe_enabled_by_env() -> bool:
1096
+ if os.getenv("PYTEST_CURRENT_TEST") and not os.getenv("MEDNOTES_GEMINI_BINARY"):
1097
+ return False
1098
+ value = os.getenv("MEDNOTES_GEMINI_PATH_PROBE", "").strip().lower()
1099
+ return value not in {"0", "false", "no", "off"}
1100
+
1101
+
1102
+ def _gemini_binary() -> Path | None:
1103
+ configured = os.getenv("MEDNOTES_GEMINI_BINARY")
1104
+ if configured:
1105
+ configured_path = expand_path(configured)
1106
+ if configured_path.exists():
1107
+ return configured_path
1108
+ found = shutil.which(configured)
1109
+ return Path(found) if found else None
1110
+ found = shutil.which("gemini")
1111
+ return Path(found) if found else None
1112
+
1113
+
1114
+ def _gemini_probe_prompt(blocked: WikiPathResolution, *, retry_detail: str, attempt: int) -> str:
1115
+ retry = ""
1116
+ if retry_detail:
1117
+ retry = (
1118
+ "\n\nTentativa anterior falhou na validacao local. "
1119
+ f"Problema: {retry_detail}. Retorne outro candidato se houver."
1120
+ )
1121
+ return (
1122
+ "Voce esta ajudando o Medical Notes Workbench a descobrir caminhos locais em tempo de execucao.\n"
1123
+ "Use apenas os arquivos de contexto fornecidos abaixo. Eles podem incluir GEMINI.md e arquivos "
1124
+ "Markdown referenciados por ele.\n"
1125
+ "Responda somente com JSON valido, sem markdown, no schema "
1126
+ f"{GEMINI_PATH_PROBE_SCHEMA}.\n"
1127
+ "Formato aceito: {\"wiki_dir\":\"/abs/Wiki_Medicina\",\"raw_dir\":\"/abs/Chats_Raw\","
1128
+ "\"confidence\":\"high|medium|low\",\"evidence\":\"arquivo/trecho curto\",\"source\":\"arquivo-de-contexto.md\"}.\n"
1129
+ "Se houver mais de uma possibilidade, responda {\"candidates\":[...]} com objetos no mesmo formato.\n"
1130
+ "Caminhos devem ser absolutos. Se nao houver evidencia suficiente, use candidates=[].\n"
1131
+ f"Bloqueio atual: {blocked.blocked_reason}. Acao esperada: {blocked.next_action}.\n"
1132
+ f"Tentativa: {attempt + 1}."
1133
+ f"{retry}\n\n"
1134
+ "CONTEXTO LOCAL:\n"
1135
+ )
1136
+
1137
+
1138
+ def _run_gemini_probe(binary: Path, prompt: str, context: str) -> dict[str, object]:
1139
+ timeout = _probe_timeout_seconds()
1140
+ try:
1141
+ asyncio.get_running_loop()
1142
+ except RuntimeError:
1143
+ pass
1144
+ else:
1145
+ return {"error": "Sondagem Gemini requer chamada síncrona fora de um event loop ativo."}
1146
+ try:
1147
+ return asyncio.run(_run_gemini_probe_async(binary, prompt + context, context, timeout))
1148
+ except RuntimeError as exc:
1149
+ return {"error": f"Falha ao iniciar sondagem async do Gemini: {exc}"}
1150
+
1151
+
1152
+ async def _run_gemini_probe_async(binary: Path, prompt: str, context: str, timeout: float) -> dict[str, object]:
1153
+ if _gemini_probe_needs_prompt_file(binary, prompt):
1154
+ with tempfile.TemporaryDirectory(prefix="mednotes-path-probe-") as tmp:
1155
+ prompt_path = Path(tmp) / "prompt.md"
1156
+ prompt_path.write_text(prompt, encoding="utf-8")
1157
+ cmd = _gemini_probe_subprocess_command(
1158
+ [
1159
+ str(binary),
1160
+ "--include-directories",
1161
+ str(prompt_path.parent),
1162
+ "-p",
1163
+ f"@{prompt_path}",
1164
+ "--approval-mode",
1165
+ "plan",
1166
+ ]
1167
+ )
1168
+ return await _communicate_gemini_probe(cmd, context, timeout)
1169
+
1170
+ cmd = _gemini_probe_subprocess_command(
1171
+ [
1172
+ str(binary),
1173
+ "-p",
1174
+ prompt,
1175
+ "--approval-mode",
1176
+ "plan",
1177
+ ]
1178
+ )
1179
+ return await _communicate_gemini_probe(cmd, context, timeout)
1180
+
1181
+
1182
+ async def _communicate_gemini_probe(cmd: list[str], context: str, timeout: float) -> dict[str, object]:
1183
+ try:
1184
+ process = await asyncio.create_subprocess_exec(
1185
+ *cmd,
1186
+ stdin=asyncio.subprocess.PIPE,
1187
+ stdout=asyncio.subprocess.PIPE,
1188
+ stderr=asyncio.subprocess.PIPE,
1189
+ )
1190
+ try:
1191
+ stdout, stderr = await asyncio.wait_for(process.communicate(context.encode("utf-8")), timeout=timeout)
1192
+ except TimeoutError:
1193
+ process.kill()
1194
+ await process.communicate()
1195
+ return {"error": f"Gemini CLI excedeu {timeout:g}s na sondagem de caminhos."}
1196
+ except TimeoutError:
1197
+ return {"error": f"Gemini CLI excedeu {timeout:g}s na sondagem de caminhos."}
1198
+ except OSError as exc:
1199
+ return {"error": f"Gemini CLI nao pode ser executado: {exc}"}
1200
+ if process.returncode != 0:
1201
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
1202
+ return {"error": f"Gemini CLI retornou codigo {process.returncode}: {stderr_text[:500]}"}
1203
+ return {
1204
+ "stdout": stdout.decode("utf-8", errors="replace"),
1205
+ "stderr": stderr.decode("utf-8", errors="replace"),
1206
+ }
1207
+
1208
+
1209
+ def _gemini_probe_needs_prompt_file(binary: Path, prompt: str) -> bool:
1210
+ if os.name != "nt":
1211
+ return False
1212
+ suffix = Path(str(binary)).suffix.lower()
1213
+ return suffix in {".cmd", ".bat"} or len(prompt) >= _WINDOWS_PROMPT_FILE_THRESHOLD
1214
+
1215
+
1216
+ def _gemini_probe_subprocess_command(cmd: list[str]) -> list[str]:
1217
+ if not cmd:
1218
+ return cmd
1219
+ suffix = Path(cmd[0]).suffix.lower()
1220
+ if os.name == "nt" and suffix in {".cmd", ".bat"}:
1221
+ return [os.environ.get("COMSPEC") or "cmd.exe", "/d", "/s", "/c", *cmd]
1222
+ return cmd
1223
+
1224
+
1225
+ def _probe_timeout_seconds() -> float:
1226
+ raw = os.getenv("MEDNOTES_GEMINI_PATH_PROBE_TIMEOUT", "20").strip()
1227
+ try:
1228
+ value = float(raw)
1229
+ except ValueError:
1230
+ return 20.0
1231
+ return max(1.0, min(value, 120.0))
1232
+
1233
+
1234
+ def _extract_json_object(text: str) -> dict[str, Any] | None:
1235
+ stripped = text.strip()
1236
+ if not stripped:
1237
+ return None
1238
+ try:
1239
+ parsed = json.loads(stripped)
1240
+ except json.JSONDecodeError:
1241
+ start = stripped.find("{")
1242
+ end = stripped.rfind("}")
1243
+ if start < 0 or end <= start:
1244
+ return None
1245
+ try:
1246
+ parsed = json.loads(stripped[start : end + 1])
1247
+ except json.JSONDecodeError:
1248
+ return None
1249
+ return parsed if isinstance(parsed, dict) else None
1250
+
1251
+
1252
+ def _validated_probe_candidates(payload: dict[str, Any]) -> tuple[list[PathCandidate], list[str]]:
1253
+ raw_candidates: list[Any]
1254
+ if isinstance(payload.get("candidates"), list):
1255
+ raw_candidates = payload["candidates"]
1256
+ elif payload.get("wiki_dir") or payload.get("path"):
1257
+ raw_candidates = [payload]
1258
+ else:
1259
+ raw_candidates = []
1260
+
1261
+ candidates: list[PathCandidate] = []
1262
+ invalid_reasons: list[str] = []
1263
+ for index, raw in enumerate(raw_candidates, start=1):
1264
+ if isinstance(raw, str):
1265
+ raw = {"wiki_dir": raw}
1266
+ if not isinstance(raw, dict):
1267
+ invalid_reasons.append(f"candidato {index} nao e objeto JSON")
1268
+ continue
1269
+
1270
+ wiki_value = str(raw.get("wiki_dir") or raw.get("path") or "").strip()
1271
+ if not wiki_value:
1272
+ invalid_reasons.append(f"candidato {index} sem wiki_dir")
1273
+ continue
1274
+
1275
+ confidence = str(raw.get("confidence") or "medium").strip().lower()
1276
+ if confidence == "low":
1277
+ invalid_reasons.append(f"{wiki_value}: confidence low")
1278
+ continue
1279
+
1280
+ wiki_path = expand_path(wiki_value).resolve(strict=False)
1281
+ if not wiki_path.exists() or not wiki_path.is_dir():
1282
+ invalid_reasons.append(f"{wiki_path}: nao existe ou nao e diretorio")
1283
+ continue
1284
+ if not _looks_like_wiki_root(wiki_path):
1285
+ invalid_reasons.append(f"{wiki_path}: nao parece raiz da Wiki_Medicina")
1286
+ continue
1287
+
1288
+ raw_path = _validated_optional_raw_dir(raw.get("raw_dir"))
1289
+ if raw.get("raw_dir") and raw_path is None:
1290
+ invalid_reasons.append(f"{raw.get('raw_dir')}: raw_dir nao existe ou nao e diretorio")
1291
+ continue
1292
+
1293
+ evidence = str(raw.get("evidence") or raw.get("source") or "Gemini CLI path probe").strip()
1294
+ candidates.append(
1295
+ _candidate(
1296
+ wiki_path,
1297
+ "gemini_probe",
1298
+ evidence,
1299
+ raw_dir=raw_path,
1300
+ confidence=confidence,
1301
+ )
1302
+ )
1303
+ return _distinct_candidates(candidates), invalid_reasons
1304
+
1305
+
1306
+ def _validated_optional_raw_dir(value: object) -> Path | None:
1307
+ if not isinstance(value, str) or not value.strip():
1308
+ return None
1309
+ path = expand_path(value.strip()).resolve(strict=False)
1310
+ return path if path.exists() and path.is_dir() else None
1311
+
1312
+
1313
+ def _write_persistent_paths(
1314
+ memory_path: Path,
1315
+ *,
1316
+ wiki_dir: Path,
1317
+ raw_dir: Path | None,
1318
+ evidence: str,
1319
+ ) -> None:
1320
+ memory_path.parent.mkdir(parents=True, exist_ok=True)
1321
+ lines = [
1322
+ f"```toml {PATHS_SCHEMA}",
1323
+ "# Atualizado automaticamente depois de validar a sondagem Gemini CLI.",
1324
+ f"# Evidencia: {_toml_comment(evidence)}",
1325
+ "[paths]",
1326
+ f"wiki_dir = {json.dumps(wiki_dir.as_posix(), ensure_ascii=False)}",
1327
+ ]
1328
+ if raw_dir is not None:
1329
+ lines.append(f"raw_dir = {json.dumps(raw_dir.as_posix(), ensure_ascii=False)}")
1330
+ lines.append("```")
1331
+ block = "\n".join(lines) + "\n"
1332
+
1333
+ old_text = memory_path.read_text(encoding="utf-8") if memory_path.exists() else ""
1334
+ if _PATHS_BLOCK_RE.search(old_text):
1335
+ new_text = _PATHS_BLOCK_RE.sub(block.rstrip("\n"), old_text, count=1)
1336
+ if not new_text.endswith("\n"):
1337
+ new_text += "\n"
1338
+ else:
1339
+ prefix = old_text.rstrip()
1340
+ new_text = (prefix + "\n\n" if prefix else "# Medical Notes Workbench local memory\n\n") + block
1341
+ memory_path.write_text(new_text, encoding="utf-8")
1342
+
1343
+
1344
+ def _toml_comment(value: str) -> str:
1345
+ return str(value).replace("\r", " ").replace("\n", " ")[:240]
1346
+
1347
+
1348
+ def _blocked_with_probe_warning(blocked: WikiPathResolution, warning: str) -> WikiPathResolution:
1349
+ return WikiPathResolution(
1350
+ path=None,
1351
+ source=blocked.source,
1352
+ memory_path=blocked.memory_path,
1353
+ config_path=blocked.config_path,
1354
+ candidates=blocked.candidates,
1355
+ compat_warnings=(*blocked.compat_warnings, warning),
1356
+ blocked_reason=blocked.blocked_reason,
1357
+ next_action=blocked.next_action,
1358
+ required_inputs=blocked.required_inputs,
1359
+ human_decision_packet=blocked.human_decision_packet,
1360
+ )
1361
+
1362
+
1363
+ def _wiki_path_choice_packet(candidates: tuple[PathCandidate, ...], config_path: Path) -> HumanDecisionPacket | None:
1364
+ distinct = _distinct_candidates(list(candidates))
1365
+ if not distinct:
1366
+ return None
1367
+ options: list[dict[str, object]] = []
1368
+ for index, candidate in enumerate(distinct, start=1):
1369
+ label = candidate.path.name or str(candidate.path)
1370
+ options.append(
1371
+ {
1372
+ "id": f"wiki_path_{index}",
1373
+ "label": label,
1374
+ "value": str(candidate.path),
1375
+ "description": f"{candidate.source}: {candidate.reason}",
1376
+ }
1377
+ )
1378
+ packet = _path_human_decision_packet(
1379
+ kind="wiki_path_choice",
1380
+ phase="resolve_wiki_dir",
1381
+ reason_code="ambiguous_wiki_dir",
1382
+ question="Qual pasta e a Wiki_Medicina correta para este usuario?",
1383
+ developer_summary="A resolucao encontrou mais de uma candidata plausivel; escolher automaticamente pode mutar a Wiki errada.",
1384
+ resume_action=(
1385
+ f"Registrar a opcao escolhida em {config_path} [paths].wiki_dir "
1386
+ "e repetir o workflow."
1387
+ ),
1388
+ options=options,
1389
+ evidence=[
1390
+ DecisionEvidence(
1391
+ summary="Mais de uma candidata de Wiki foi encontrada.",
1392
+ technical_code="ambiguous_wiki_dir",
1393
+ source="mednotes.platform.paths",
1394
+ candidates=[
1395
+ {
1396
+ "path": str(candidate.path),
1397
+ "raw_dir": str(candidate.raw_dir) if candidate.raw_dir is not None else "",
1398
+ "source": candidate.source,
1399
+ "reason": candidate.reason,
1400
+ }
1401
+ for candidate in distinct
1402
+ ],
1403
+ risk="wrong_vault_mutation",
1404
+ )
1405
+ ],
1406
+ )
1407
+ return packet.model_copy(update={"context": {"config_path": str(config_path)}})
1408
+
1409
+
1410
+ def _path_human_decision_packet(
1411
+ *,
1412
+ kind: str,
1413
+ phase: str,
1414
+ reason_code: str,
1415
+ question: str,
1416
+ developer_summary: str,
1417
+ resume_action: str,
1418
+ options: list[dict[str, object]],
1419
+ evidence: list[DecisionEvidence],
1420
+ ) -> HumanDecisionPacket:
1421
+ """Build path-choice packets through the canonical workflow decision model."""
1422
+
1423
+ decision = WorkflowDecision(
1424
+ kind="ask_human",
1425
+ phase=phase,
1426
+ reason_code=reason_code,
1427
+ public_summary=question,
1428
+ developer_summary=developer_summary,
1429
+ evidence=evidence,
1430
+ next_action=resume_action,
1431
+ resume_action=resume_action,
1432
+ rejected_automations=[
1433
+ RejectedAutomation(
1434
+ kind="auto_fix",
1435
+ reason_code=reason_code,
1436
+ reason="Path mutation without explicit choice can target the wrong vault.",
1437
+ safe=False,
1438
+ ),
1439
+ RejectedAutomation(
1440
+ kind="auto_defer",
1441
+ reason_code=reason_code,
1442
+ reason="Deferring without a closed choice leaves setup blocked.",
1443
+ safe=False,
1444
+ ),
1445
+ RejectedAutomation(
1446
+ kind="auto_plan",
1447
+ reason_code=reason_code,
1448
+ reason="Planning cannot disambiguate user-owned local paths.",
1449
+ safe=False,
1450
+ ),
1451
+ ],
1452
+ recommended_option_id=str(options[0]["id"]),
1453
+ options=options,
1454
+ human_decision_kind=kind,
1455
+ )
1456
+ return HumanDecisionPacket.model_validate(decision.to_human_decision_packet())
1457
+
1458
+
1459
+ def _gemini_probe_context(
1460
+ *,
1461
+ start: Path | None,
1462
+ context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
1463
+ memory_path: Path,
1464
+ ) -> str:
1465
+ budget = _probe_context_budget()
1466
+ snippets: list[str] = []
1467
+ queue = _gemini_context_seed_files(start=start, context_paths=context_paths, memory_path=memory_path)
1468
+ seen: set[str] = set()
1469
+ used = 0
1470
+
1471
+ while queue and used < budget:
1472
+ path = queue.pop(0).expanduser().resolve(strict=False)
1473
+ key = os.path.normcase(str(path))
1474
+ if key in seen or not path.is_file():
1475
+ continue
1476
+ seen.add(key)
1477
+ try:
1478
+ text = path.read_text(encoding="utf-8", errors="replace")
1479
+ except OSError:
1480
+ continue
1481
+ remaining = max(0, budget - used)
1482
+ if remaining <= 0:
1483
+ break
1484
+ header = f"--- FILE: {path} ---\n"
1485
+ body = text[: max(0, remaining - len(header) - 2)]
1486
+ snippets.append(header + body + "\n")
1487
+ used += len(header) + len(body) + 1
1488
+ for ref in _markdown_context_references(text):
1489
+ resolved = _resolve_context_reference(path, ref)
1490
+ if resolved is not None:
1491
+ queue.append(resolved)
1492
+
1493
+ return "\n".join(snippets)
1494
+
1495
+
1496
+ def _probe_context_budget() -> int:
1497
+ raw = os.getenv("MEDNOTES_GEMINI_PATH_PROBE_CONTEXT_BYTES", "60000").strip()
1498
+ try:
1499
+ value = int(raw)
1500
+ except ValueError:
1501
+ return 60000
1502
+ return max(4096, min(value, 200000))
1503
+
1504
+
1505
+ def _gemini_context_seed_files(
1506
+ *,
1507
+ start: Path | None,
1508
+ context_paths: list[str | os.PathLike[str]] | tuple[str | os.PathLike[str], ...] | None,
1509
+ memory_path: Path,
1510
+ ) -> list[Path]:
1511
+ seeds: list[Path] = []
1512
+
1513
+ def add(path: Path) -> None:
1514
+ key = os.path.normcase(str(path.expanduser().resolve(strict=False)))
1515
+ if key not in {os.path.normcase(str(item.expanduser().resolve(strict=False))) for item in seeds}:
1516
+ seeds.append(path)
1517
+
1518
+ add(memory_path)
1519
+ add(Path.home() / ".gemini" / "GEMINI.md")
1520
+
1521
+ raw_roots: list[Path] = []
1522
+ if start is not None:
1523
+ raw_roots.append(start)
1524
+ if context_paths:
1525
+ raw_roots.extend(expand_path(item) for item in context_paths)
1526
+ raw_roots.append(Path.cwd())
1527
+
1528
+ for raw in raw_roots:
1529
+ path = raw.expanduser().resolve(strict=False)
1530
+ scan_start = path if path.suffix == "" else path.parent
1531
+ if path.exists() and path.is_file():
1532
+ add(path)
1533
+ scan_start = path.parent
1534
+ for directory in (scan_start, *scan_start.parents):
1535
+ add(directory / "GEMINI.md")
1536
+
1537
+ extension_root = _infer_extension_root()
1538
+ add(extension_root / "GEMINI.md")
1539
+ add(extension_root / "extension" / "GEMINI.md")
1540
+ return seeds
1541
+
1542
+
1543
+ def _markdown_context_references(text: str) -> list[str]:
1544
+ refs: list[str] = []
1545
+ refs.extend(match.group(1) for match in _MARKDOWN_AT_REF_RE.finditer(text))
1546
+ refs.extend(match.group(1) for match in _MARKDOWN_LINK_REF_RE.finditer(text))
1547
+ refs.extend(match.group(0) for match in _BARE_MARKDOWN_CONTEXT_REF_RE.finditer(text))
1548
+ return refs
1549
+
1550
+
1551
+ def _resolve_context_reference(base_file: Path, ref: str) -> Path | None:
1552
+ if "://" in ref:
1553
+ return None
1554
+ clean = ref.strip().strip("<>").split("#", 1)[0].split("?", 1)[0]
1555
+ if not clean:
1556
+ return None
1557
+ path = expand_path(clean)
1558
+ if not path.is_absolute():
1559
+ path = base_file.parent / path
1560
+ return path