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,1562 @@
1
+ #!/usr/bin/env python3
2
+ """Capture a recoverable diff from an installed Medical Notes Workbench extension.
3
+
4
+ This script is intentionally self-contained. It is meant for the uncomfortable
5
+ case where a user already updated the Gemini CLI extension and we still need a
6
+ best-effort diff of local drift against the extension integrity manifest.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import difflib
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import platform
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import uuid
20
+ import zipfile
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib import error as urllib_error
26
+ from urllib import request as urllib_request
27
+ from urllib.parse import urlsplit, urlunsplit
28
+
29
+ MANIFEST_FILENAME = "extension-integrity-manifest.json"
30
+ INTEGRITY_MANIFEST_SCHEMA = "medical-notes-workbench.extension-integrity-manifest.v1"
31
+ PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA = "medical-notes-workbench.pre-update-extension-snapshot.v1"
32
+ TELEMETRY_ENVELOPE_SCHEMA = "medical-notes-workbench.workflow-telemetry-envelope.v1"
33
+ RUN_RECORD_SCHEMA = "medical-notes-workbench.workflow-run-record.v1"
34
+ MANUAL_REPORT_RECEIPT_SCHEMA = "medical-notes-workbench.manual-report-receipt.v1"
35
+ PAYLOAD_LEVEL = "trusted_extension_debug"
36
+
37
+ MAX_PATCH_CHARS = 160 * 1024
38
+ MAX_ENVELOPE_BYTES = 1024 * 1024
39
+ MAX_GIT_HISTORY_COMMITS = 600
40
+ MAX_TEXT_BYTES = 768 * 1024
41
+ MAX_ZIP_FILE_BYTES = 6 * 1024 * 1024
42
+ DEFAULT_GITHUB_BASELINE_URL = "https://codeload.github.com/augustocaruso/medical-notes-workbench/zip/refs/heads/gemini-cli-extension"
43
+ DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS = 5
44
+ DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS = 7
45
+
46
+ MONITORED_EXACT_FILES = {
47
+ "GEMINI.md",
48
+ "gemini-extension.json",
49
+ "package.json",
50
+ "pyproject.toml",
51
+ }
52
+ MONITORED_DIRS = {
53
+ "agents",
54
+ "commands",
55
+ "docs",
56
+ "hooks",
57
+ "mcp",
58
+ "policies",
59
+ "scripts",
60
+ "skills",
61
+ "src",
62
+ }
63
+ MONITORED_SUFFIXES = {
64
+ ".cjs",
65
+ ".cmd",
66
+ ".js",
67
+ ".json",
68
+ ".md",
69
+ ".mjs",
70
+ ".ps1",
71
+ ".py",
72
+ ".sh",
73
+ ".toml",
74
+ ".txt",
75
+ ".yaml",
76
+ ".yml",
77
+ }
78
+ SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".sh", ".ps1", ".cmd"}
79
+ EXCLUDED_PARTS = {
80
+ ".git",
81
+ ".mypy_cache",
82
+ ".pytest_cache",
83
+ ".venv",
84
+ "__pycache__",
85
+ "cache",
86
+ "dist",
87
+ "feedback",
88
+ "node_modules",
89
+ "outbox",
90
+ }
91
+ EXCLUDED_NAMES = {
92
+ ".DS_Store",
93
+ ".env",
94
+ ".telemetry-defaults.json",
95
+ MANIFEST_FILENAME,
96
+ "extension-integrity-cache.json",
97
+ "telemetry.defaults.json",
98
+ "uv.lock",
99
+ }
100
+ PRE_UPDATE_PATCH_NOISE_PARTS = tuple(f"{part}/" for part in EXCLUDED_PARTS) + (".pyc", ".pyo", ".egg-info/")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class TelemetrySettings:
105
+ endpoint_url: str = ""
106
+ auth_token: str = ""
107
+ install_id: str = ""
108
+
109
+ @property
110
+ def ready(self) -> bool:
111
+ return bool(self.endpoint_url and self.auth_token and self.install_id)
112
+
113
+
114
+ def now_iso() -> str:
115
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
116
+
117
+
118
+ def capture_extension_diff(
119
+ extension_path: str | Path,
120
+ *,
121
+ output_dir: str | Path | None = None,
122
+ send: bool = False,
123
+ endpoint_url: str = "",
124
+ auth_token: str = "",
125
+ config_path: str | Path | None = None,
126
+ flush_digest: bool = False,
127
+ include_existing_snapshots: bool = False,
128
+ github_baseline_url: str = "",
129
+ ) -> dict[str, Any]:
130
+ """Capture extension drift and optionally send a trusted debug envelope."""
131
+ root = Path(os.path.expandvars(str(extension_path))).expanduser().resolve()
132
+ snapshot_id = _snapshot_id()
133
+ snapshot_path = _default_snapshot_dir(snapshot_id) if output_dir is None else Path(output_dir).expanduser().resolve()
134
+ snapshot_path.mkdir(parents=True, exist_ok=True)
135
+
136
+ manifest_path = root / MANIFEST_FILENAME
137
+ manifest = _read_manifest(manifest_path)
138
+ expected = _manifest_files(manifest)
139
+ current = _current_file_states(root)
140
+ existing_snapshot_diffs, existing_snapshot_summaries = (
141
+ _load_existing_pre_update_snapshot_diffs(exclude_dir=snapshot_path) if include_existing_snapshots else ([], [])
142
+ )
143
+ extension_diffs: list[dict[str, Any]] = []
144
+ combined_patches: list[str] = []
145
+ changed_paths: list[str] = []
146
+ generated_scripts: list[dict[str, Any]] = []
147
+ modified_files: list[dict[str, Any]] = []
148
+ missing_files: list[dict[str, Any]] = []
149
+ unexpected_files: list[dict[str, Any]] = []
150
+ line_ending_only_files: list[dict[str, Any]] = []
151
+ unavailable: list[dict[str, str]] = []
152
+ git_diff_empty_count = 0
153
+ baseline_recovered_count = 0
154
+ github_baseline_diffs: list[dict[str, Any]] = []
155
+
156
+ has_git = _has_git_worktree(root)
157
+ for rel, item in sorted(expected.items()):
158
+ if not _allowed_full_diff_path(rel):
159
+ continue
160
+ path = root / rel
161
+ current_state = current.get(rel)
162
+ if not path.exists() or not current_state:
163
+ entry = _changed_file_entry(rel, item, None, change="missing")
164
+ missing_files.append(entry)
165
+ changed_paths.append(rel)
166
+ patch, source, reason, used_git_empty = _patch_for_expected_file(root, rel, item, has_git=has_git, missing=True)
167
+ elif current_state["sha256"] == str(item.get("sha256") or ""):
168
+ continue
169
+ elif _same_normalized(current_state, item):
170
+ line_ending_only_files.append(_changed_file_entry(rel, item, current_state, change="line_ending_only"))
171
+ continue
172
+ else:
173
+ entry = _changed_file_entry(rel, item, current_state, change="modified")
174
+ modified_files.append(entry)
175
+ changed_paths.append(rel)
176
+ patch, source, reason, used_git_empty = _patch_for_expected_file(root, rel, item, has_git=has_git, missing=False)
177
+
178
+ if used_git_empty:
179
+ git_diff_empty_count += 1
180
+ if source.startswith("git:") and source != "git:diff":
181
+ baseline_recovered_count += 1
182
+ diff_entry: dict[str, Any] = {
183
+ "path": rel,
184
+ "kind": _file_kind(rel),
185
+ "change": "missing" if not path.exists() else "modified",
186
+ "baseline_source": source,
187
+ }
188
+ if patch:
189
+ sanitized = redact_operational_text(patch, max_chars=MAX_PATCH_CHARS)
190
+ diff_entry["patch"] = sanitized
191
+ diff_entry["truncated"] = len(sanitized) < len(patch)
192
+ combined_patches.append(sanitized)
193
+ else:
194
+ diff_entry["full_diff_unavailable_reason"] = reason or "full_diff_unavailable"
195
+ unavailable.append({"path": rel, "reason": diff_entry["full_diff_unavailable_reason"]})
196
+ extension_diffs.append(diff_entry)
197
+
198
+ for rel, state in sorted(current.items()):
199
+ if rel in expected or not _allowed_full_diff_path(rel):
200
+ continue
201
+ unexpected_files.append(_changed_file_entry(rel, None, state, change="unexpected"))
202
+ changed_paths.append(rel)
203
+ patch = _new_file_patch(root, rel)
204
+ diff_entry: dict[str, Any] = {
205
+ "path": rel,
206
+ "kind": _file_kind(rel),
207
+ "change": "unexpected",
208
+ "baseline_source": "new-file",
209
+ }
210
+ if patch:
211
+ sanitized = redact_operational_text(patch, max_chars=MAX_PATCH_CHARS)
212
+ diff_entry["patch"] = sanitized
213
+ diff_entry["truncated"] = len(sanitized) < len(patch)
214
+ combined_patches.append(sanitized)
215
+ else:
216
+ diff_entry["full_diff_unavailable_reason"] = "new_file_patch_unavailable"
217
+ unavailable.append({"path": rel, "reason": "new_file_patch_unavailable"})
218
+ extension_diffs.append(diff_entry)
219
+ script = _generated_script(root, rel, state, source="unexpected_extension_file")
220
+ if script:
221
+ generated_scripts.append(script)
222
+
223
+ if existing_snapshot_diffs:
224
+ extension_diffs = existing_snapshot_diffs + extension_diffs
225
+ for item in existing_snapshot_diffs:
226
+ patch = str(item.get("patch") or "")
227
+ if patch.strip():
228
+ combined_patches.insert(0, patch)
229
+
230
+ github_baseline: dict[str, Any] = {}
231
+ if github_baseline_url:
232
+ github_baseline, github_baseline_diffs = _capture_github_baseline_diffs(
233
+ root,
234
+ snapshot_path=snapshot_path,
235
+ source_url=github_baseline_url,
236
+ )
237
+ extension_diffs = github_baseline_diffs + extension_diffs
238
+ for item in github_baseline_diffs:
239
+ rel = str(item.get("path") or "")
240
+ if rel and rel not in changed_paths:
241
+ changed_paths.append(rel)
242
+ patch = str(item.get("patch") or "")
243
+ if patch.strip():
244
+ combined_patches.append(patch)
245
+ if item.get("change") == "unexpected":
246
+ state = current.get(rel)
247
+ script = _generated_script(root, rel, state or {}, source="github_baseline_unexpected_file")
248
+ if script:
249
+ generated_scripts.append(script)
250
+
251
+ combined_patch = "\n\n".join(patch for patch in combined_patches if patch.strip())
252
+ git_head = _git_stdout(root, "rev-parse", "HEAD") if has_git else ""
253
+ manifest_version = str(manifest.get("app_version") or "")
254
+ summary = {
255
+ "modified_count": len(modified_files),
256
+ "missing_count": len(missing_files),
257
+ "unexpected_count": len(unexpected_files),
258
+ "line_ending_only_count": len(line_ending_only_files),
259
+ "changed_count": len(modified_files) + len(missing_files) + len(unexpected_files),
260
+ "manifest_file_count": len(expected),
261
+ "extension_diff_count": len(extension_diffs),
262
+ "github_baseline_diff_count": len(github_baseline_diffs),
263
+ "rescued_pre_update_diff_count": len(existing_snapshot_diffs),
264
+ "existing_pre_update_snapshot_count": len(existing_snapshot_summaries),
265
+ "generated_script_count": len(generated_scripts),
266
+ }
267
+ snapshot = {
268
+ "schema": PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA,
269
+ "snapshot_id": snapshot_id,
270
+ "recorded_at": now_iso(),
271
+ "extension_name": "medical-notes-workbench",
272
+ "extension_path": str(root),
273
+ "snapshot_path": str(snapshot_path),
274
+ "current_version": manifest_version,
275
+ "target_version": "",
276
+ "git_head": git_head,
277
+ "git_available": has_git,
278
+ "reason": "manual-extension-diff-rescue",
279
+ "patch_id": "manual-extension-diff-rescue",
280
+ "phase": "manual-extension-diff-capture",
281
+ "changed_path_count": len(modified_files) + len(missing_files),
282
+ "untracked_path_count": len(unexpected_files),
283
+ "changed_paths": changed_paths[:200],
284
+ "generated_scripts": generated_scripts,
285
+ "summary": summary,
286
+ "github_baseline": github_baseline,
287
+ "modified_files": modified_files,
288
+ "missing_files": missing_files,
289
+ "unexpected_files": unexpected_files,
290
+ "line_ending_only_files": line_ending_only_files,
291
+ "extension_diffs": extension_diffs,
292
+ "existing_pre_update_snapshots": existing_snapshot_summaries,
293
+ "combined_patch": combined_patch,
294
+ "git_diff_empty_count": git_diff_empty_count,
295
+ "baseline_recovered_count": baseline_recovered_count,
296
+ "diff_unavailable": unavailable,
297
+ }
298
+ snapshot["telemetry_evidence"] = _telemetry_evidence_from_snapshot(snapshot, send_path="manual_extension_diff_capture")
299
+
300
+ _write_snapshot_files(snapshot_path, snapshot, combined_patch, unavailable)
301
+ _write_json(snapshot_path / "existing-pre-update-snapshots.json", existing_snapshot_summaries)
302
+ envelope = _build_envelope(snapshot, endpoint_url=endpoint_url, auth_token=auth_token, config_path=config_path)
303
+ _write_json(snapshot_path / "telemetry-envelope.json", envelope)
304
+ send_result = {"ok": False, "sent": False, "reason": "send_not_requested"}
305
+ if send:
306
+ send_result = _send_envelope(envelope, endpoint_url=endpoint_url, auth_token=auth_token, config_path=config_path)
307
+ if flush_digest and send_result.get("ok"):
308
+ send_result["digest_flush"] = _flush_digest(send_result.get("endpoint_url", ""), send_result.get("auth_token", ""))
309
+ redacted_send_result = _redacted_send_result(send_result)
310
+ _write_json(snapshot_path / "send-result.json", redacted_send_result)
311
+ snapshot["send_result"] = redacted_send_result
312
+ snapshot["manual_report_receipt"] = _manual_report_receipt(snapshot, redacted_send_result)
313
+ _write_json(snapshot_path / "manual-report-receipt.json", snapshot["manual_report_receipt"])
314
+ _write_json(snapshot_path / "capture-result.json", _public_capture_result(snapshot))
315
+ zip_path = _write_zip(snapshot_path)
316
+ snapshot["zip_path"] = str(zip_path) if zip_path else ""
317
+ retention_result = _prune_pre_update_snapshots_for_path(snapshot_path)
318
+ if retention_result:
319
+ snapshot["local_retention"] = retention_result
320
+ _write_json(snapshot_path / "capture-result.json", _public_capture_result(snapshot))
321
+ return snapshot
322
+
323
+
324
+ def _snapshot_id() -> str:
325
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
326
+ return f"{stamp}-manual-rescue-{uuid.uuid4().hex[:8]}"
327
+
328
+
329
+ def _default_snapshot_dir(snapshot_id: str) -> Path:
330
+ return _pre_update_snapshot_root() / snapshot_id
331
+
332
+
333
+ def _pre_update_snapshot_root() -> Path:
334
+ return Path.home() / ".gemini" / "medical-notes-workbench" / "feedback" / "pre-update-snapshots"
335
+
336
+
337
+ def _read_manifest(path: Path) -> dict[str, Any]:
338
+ try:
339
+ data = json.loads(path.read_text(encoding="utf-8"))
340
+ except (OSError, json.JSONDecodeError):
341
+ return {"schema": INTEGRITY_MANIFEST_SCHEMA, "files": []}
342
+ if not isinstance(data, dict):
343
+ return {"schema": INTEGRITY_MANIFEST_SCHEMA, "files": []}
344
+ return data
345
+
346
+
347
+ def _manifest_files(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
348
+ values: dict[str, dict[str, Any]] = {}
349
+ files = manifest.get("files")
350
+ if not isinstance(files, list):
351
+ return values
352
+ for item in files:
353
+ if not isinstance(item, dict):
354
+ continue
355
+ rel = _clean_rel(item.get("path"))
356
+ if rel and _allowed_full_diff_path(rel):
357
+ values[rel] = dict(item, path=rel)
358
+ return values
359
+
360
+
361
+ def _current_file_states(root: Path) -> dict[str, dict[str, Any]]:
362
+ states: dict[str, dict[str, Any]] = {}
363
+ for path in _iter_public_files(root):
364
+ rel = _relative_path(path, root)
365
+ states[rel] = _file_state(path, root)
366
+ return states
367
+
368
+
369
+ def _capture_github_baseline_diffs(root: Path, *, snapshot_path: Path, source_url: str) -> tuple[dict[str, Any], list[dict[str, Any]]]:
370
+ baseline_dir = snapshot_path / "github-baseline"
371
+ archive_path = snapshot_path / "github-baseline.zip"
372
+ metadata: dict[str, Any] = {
373
+ "source_url": source_url,
374
+ "archive_path": str(archive_path),
375
+ "baseline_root": "",
376
+ "ok": False,
377
+ "diff_count": 0,
378
+ }
379
+ try:
380
+ _download_baseline_archive(source_url, archive_path)
381
+ if baseline_dir.exists():
382
+ shutil.rmtree(baseline_dir)
383
+ baseline_dir.mkdir(parents=True, exist_ok=True)
384
+ with zipfile.ZipFile(archive_path) as archive:
385
+ archive.extractall(baseline_dir)
386
+ baseline_root = _find_baseline_root(baseline_dir)
387
+ metadata["baseline_root"] = str(baseline_root)
388
+ current = _current_file_states(root)
389
+ baseline = _current_file_states(baseline_root)
390
+ diffs: list[dict[str, Any]] = []
391
+ for rel in sorted(set(current) | set(baseline)):
392
+ if not _allowed_full_diff_path(rel):
393
+ continue
394
+ current_path = root / rel
395
+ baseline_path = baseline_root / rel
396
+ current_exists = current_path.exists()
397
+ baseline_exists = baseline_path.exists()
398
+ if current_exists and baseline_exists:
399
+ current_data = _read_bytes(current_path)
400
+ baseline_data = _read_bytes(baseline_path)
401
+ if _hash_bytes(_normalize_line_endings(current_data)) == _hash_bytes(_normalize_line_endings(baseline_data)):
402
+ continue
403
+ change = "modified"
404
+ elif current_exists:
405
+ current_data = _read_bytes(current_path)
406
+ baseline_data = b""
407
+ change = "unexpected"
408
+ else:
409
+ current_data = b""
410
+ baseline_data = _read_bytes(baseline_path)
411
+ change = "missing"
412
+ patch = _unified_diff_bytes(baseline_data, current_data, fromfile=f"github/{rel}", tofile=f"current/{rel}")
413
+ entry: dict[str, Any] = {
414
+ "path": rel,
415
+ "kind": _file_kind(rel),
416
+ "change": change,
417
+ "baseline_source": "github-baseline",
418
+ }
419
+ if patch:
420
+ sanitized = redact_operational_text(patch, max_chars=MAX_PATCH_CHARS)
421
+ entry["patch"] = sanitized
422
+ entry["truncated"] = len(sanitized) < len(patch)
423
+ else:
424
+ entry["full_diff_unavailable_reason"] = "github_baseline_diff_unavailable"
425
+ diffs.append(entry)
426
+ metadata["ok"] = True
427
+ metadata["diff_count"] = len(diffs)
428
+ return metadata, diffs
429
+ except Exception as exc:
430
+ metadata["error"] = redact_operational_text(str(exc), max_chars=2000)
431
+ return metadata, []
432
+
433
+
434
+ def _download_baseline_archive(source_url: str, output: Path) -> None:
435
+ output.parent.mkdir(parents=True, exist_ok=True)
436
+ if source_url.startswith("file://"):
437
+ with urllib_request.urlopen(source_url, timeout=8) as response:
438
+ output.write_bytes(response.read(MAX_ZIP_FILE_BYTES * 2))
439
+ return
440
+ source_path = Path(os.path.expandvars(source_url)).expanduser()
441
+ if source_path.exists():
442
+ shutil.copy2(source_path, output)
443
+ return
444
+ request = urllib_request.Request(source_url, headers={"User-Agent": "medical-notes-workbench"})
445
+ with urllib_request.urlopen(request, timeout=15) as response:
446
+ output.write_bytes(response.read(MAX_ZIP_FILE_BYTES * 2))
447
+
448
+
449
+ def _find_baseline_root(extracted_dir: Path) -> Path:
450
+ candidates = [path for path in extracted_dir.iterdir() if path.is_dir()]
451
+ for candidate in candidates:
452
+ if (candidate / "gemini-extension.json").exists() or (candidate / "GEMINI.md").exists():
453
+ return candidate
454
+ if len(candidates) == 1:
455
+ return candidates[0]
456
+ return extracted_dir
457
+
458
+
459
+ def _iter_public_files(root: Path) -> list[Path]:
460
+ if not root.exists():
461
+ return []
462
+ paths: list[Path] = []
463
+ for path in root.rglob("*"):
464
+ if not path.is_file():
465
+ continue
466
+ rel = _relative_path(path, root)
467
+ if _is_public_file(rel):
468
+ paths.append(path)
469
+ return paths
470
+
471
+
472
+ def _is_public_file(rel: str) -> bool:
473
+ rel = _clean_rel(rel)
474
+ if not rel:
475
+ return False
476
+ parts = rel.split("/")
477
+ if any(part in EXCLUDED_PARTS or part.endswith(".egg-info") for part in parts):
478
+ return False
479
+ name = parts[-1]
480
+ if name in EXCLUDED_NAMES:
481
+ return False
482
+ if rel in MONITORED_EXACT_FILES:
483
+ return True
484
+ if parts[0] not in MONITORED_DIRS:
485
+ return False
486
+ return Path(rel).suffix.lower() in MONITORED_SUFFIXES
487
+
488
+
489
+ def _allowed_full_diff_path(rel: str) -> bool:
490
+ rel = _clean_rel(rel)
491
+ if not _is_public_file(rel):
492
+ return False
493
+ if rel in MONITORED_EXACT_FILES:
494
+ return True
495
+ first = rel.split("/", 1)[0]
496
+ return first in MONITORED_DIRS
497
+
498
+
499
+ def _clean_rel(value: Any) -> str:
500
+ text = str(value or "").replace("\\", "/").strip("/")
501
+ if not text or text.startswith("../") or "/../" in f"/{text}/":
502
+ return ""
503
+ return text
504
+
505
+
506
+ def _relative_path(path: Path, root: Path) -> str:
507
+ return path.resolve().relative_to(root.resolve()).as_posix()
508
+
509
+
510
+ def _file_state(path: Path, root: Path) -> dict[str, Any]:
511
+ data = path.read_bytes()
512
+ text = _decode_text(data)
513
+ return {
514
+ "path": _relative_path(path, root),
515
+ "kind": _file_kind(_relative_path(path, root)),
516
+ "sha256": _hash_bytes(data),
517
+ "normalized_sha256": _hash_bytes(_normalize_line_endings(data)),
518
+ "size_bytes": len(data),
519
+ "line_count": len(text.splitlines()) if text is not None else 0,
520
+ }
521
+
522
+
523
+ def _changed_file_entry(
524
+ rel: str,
525
+ expected: dict[str, Any] | None,
526
+ current: dict[str, Any] | None,
527
+ *,
528
+ change: str,
529
+ ) -> dict[str, Any]:
530
+ return {
531
+ "path": rel,
532
+ "kind": _file_kind(rel),
533
+ "change": change,
534
+ "expected_sha256": str((expected or {}).get("sha256") or ""),
535
+ "current_sha256": str((current or {}).get("sha256") or ""),
536
+ "expected_size_bytes": _safe_int((expected or {}).get("size_bytes")),
537
+ "current_size_bytes": _safe_int((current or {}).get("size_bytes")),
538
+ }
539
+
540
+
541
+ def _same_normalized(current: dict[str, Any], expected: dict[str, Any]) -> bool:
542
+ current_hash = str(current.get("normalized_sha256") or "")
543
+ expected_hash = str(expected.get("normalized_sha256") or "")
544
+ return bool(current_hash and expected_hash and current_hash == expected_hash)
545
+
546
+
547
+ def _patch_for_expected_file(
548
+ root: Path,
549
+ rel: str,
550
+ expected: dict[str, Any],
551
+ *,
552
+ has_git: bool,
553
+ missing: bool,
554
+ ) -> tuple[str, str, str, bool]:
555
+ used_git_empty = False
556
+ if has_git:
557
+ patch = _git_diff(root, rel)
558
+ if patch:
559
+ return patch, "git:diff", "", False
560
+ used_git_empty = True
561
+ baseline, baseline_source = _recover_baseline_from_git(root, rel, expected) if has_git else (None, "")
562
+ if baseline is not None:
563
+ current = b"" if missing else _read_bytes(root / rel)
564
+ patch = _unified_diff_bytes(baseline, current, fromfile=f"manifest/{rel}", tofile=f"current/{rel}")
565
+ return patch, baseline_source, "", used_git_empty
566
+ if not has_git:
567
+ return "", "", "git_repository_not_available", used_git_empty
568
+ return "", "", "git_diff_empty_and_manifest_baseline_not_found", used_git_empty
569
+
570
+
571
+ def _git_diff(root: Path, rel: str) -> str:
572
+ result = _run_git(root, "diff", "--no-ext-diff", "--no-color", "--binary", "--", rel, timeout=8)
573
+ if result.returncode not in {0, 1}:
574
+ return ""
575
+ return result.stdout
576
+
577
+
578
+ def _recover_baseline_from_git(root: Path, rel: str, expected: dict[str, Any]) -> tuple[bytes | None, str]:
579
+ expected_sha = str(expected.get("sha256") or "")
580
+ expected_normalized_sha = str(expected.get("normalized_sha256") or "")
581
+ result = _run_git(root, "rev-list", "--all", "--", rel, timeout=10)
582
+ if result.returncode != 0:
583
+ return None, ""
584
+ commits = [line.strip() for line in result.stdout.splitlines() if line.strip()]
585
+ for commit in commits[:MAX_GIT_HISTORY_COMMITS]:
586
+ show = _run_git(root, "show", f"{commit}:{rel}", timeout=10, text=False)
587
+ if show.returncode != 0:
588
+ continue
589
+ data = show.stdout if isinstance(show.stdout, bytes) else str(show.stdout).encode("utf-8", errors="replace")
590
+ if expected_sha and _hash_bytes(data) == expected_sha:
591
+ return data, f"git:{commit[:12]}"
592
+ if expected_normalized_sha and _hash_bytes(_normalize_line_endings(data)) == expected_normalized_sha:
593
+ return data, f"git:{commit[:12]}:normalized"
594
+ return None, ""
595
+
596
+
597
+ def _new_file_patch(root: Path, rel: str) -> str:
598
+ path = root / rel
599
+ data = _read_bytes(path)
600
+ if not data:
601
+ return ""
602
+ return _unified_diff_bytes(b"", data, fromfile="/dev/null", tofile=f"current/{rel}")
603
+
604
+
605
+ def _unified_diff_bytes(old: bytes, new: bytes, *, fromfile: str, tofile: str) -> str:
606
+ old_text = _decode_text(old)
607
+ new_text = _decode_text(new)
608
+ if old_text is None or new_text is None:
609
+ return ""
610
+ if len(old) > MAX_TEXT_BYTES or len(new) > MAX_TEXT_BYTES:
611
+ return ""
612
+ lines = difflib.unified_diff(
613
+ old_text.splitlines(keepends=True),
614
+ new_text.splitlines(keepends=True),
615
+ fromfile=fromfile,
616
+ tofile=tofile,
617
+ )
618
+ return "".join(lines)
619
+
620
+
621
+ def _generated_script(root: Path, rel: str, state: dict[str, Any], *, source: str) -> dict[str, Any] | None:
622
+ suffix = Path(rel).suffix.lower()
623
+ if suffix not in SCRIPT_SUFFIXES:
624
+ return None
625
+ path = root / rel
626
+ data = _read_bytes(path)
627
+ content = _decode_text(data)
628
+ script: dict[str, Any] = {
629
+ "path": rel,
630
+ "language": _language_for_suffix(suffix),
631
+ "sha256": str(state.get("sha256") or _hash_bytes(data)),
632
+ "size_bytes": len(data),
633
+ "source": source,
634
+ "capture_method": "manual_extension_diff_rescue",
635
+ }
636
+ if content is not None and len(data) <= MAX_TEXT_BYTES:
637
+ script["content"] = redact_operational_text(content, max_chars=MAX_PATCH_CHARS)
638
+ script["truncated"] = len(script["content"]) < len(content)
639
+ risk_codes = _script_risk_codes(path=rel, content=content)
640
+ if risk_codes:
641
+ script["risk_codes"] = risk_codes
642
+ else:
643
+ script["content_omitted_reason"] = "script_not_text_or_too_large"
644
+ return script
645
+
646
+
647
+ def _script_risk_codes(*, path: str, content: str) -> list[str]:
648
+ text = str(content or "")
649
+ lowered = text.lower()
650
+ path_lower = str(path or "").replace("\\", "/").lower()
651
+ codes: list[str] = []
652
+
653
+ def add(code: str, condition: bool) -> None:
654
+ if condition and code not in codes:
655
+ codes.append(code)
656
+
657
+ markdown_scan = (
658
+ "rglob(\"*.md\")" in lowered
659
+ or "rglob('*.md')" in lowered
660
+ or "glob(\"**/*.md\")" in lowered
661
+ or "glob('**/*.md')" in lowered
662
+ or ("os.walk" in lowered and ".md" in lowered)
663
+ )
664
+ writes_files = bool(
665
+ re.search(r"\bwrite_text\s*\(", lowered)
666
+ or re.search(r"\bopen\s*\([^)]*['\"]w", lowered)
667
+ or "fs.writefile" in lowered
668
+ or "set-content" in lowered
669
+ or "out-file" in lowered
670
+ or ".unlink(" in lowered
671
+ or "shutil.move" in lowered
672
+ )
673
+ add("mass_markdown_mutation", markdown_scan and writes_files)
674
+ add("hardcoded_user_path", bool(re.search(r"(?i)([a-z]:\\\\|/users/|/home/|~[/\\])", text)))
675
+ add("reads_obsidian_plugin_data", ".obsidian/plugins" in lowered or "related-notes" in lowered or "related notes" in lowered)
676
+ add("writes_related_notes_section", "notas relacionadas" in lowered or "related notes" in lowered)
677
+ add(
678
+ "external_api_or_embedding_call",
679
+ bool(
680
+ re.search(r"\b(openai|anthropic|gemini|embedding|embeddings)\b", lowered)
681
+ or re.search(r"\b(requests|httpx)\.(post|get|request)\s*\(", lowered)
682
+ or re.search(r"\bfetch\s*\(", lowered)
683
+ ),
684
+ )
685
+ add("no_dry_run", writes_files and "dry_run" not in lowered and "--dry-run" not in lowered and "dry-run" not in lowered)
686
+ add("encoding_corruption", "\ufffd" in text or bool(re.search(r"(?m)^##\s+\?+\s+(notas relacionadas|fontes consolidadas|fechamento)\b", lowered)))
687
+ add(
688
+ "extension_prompt_or_script_drift",
689
+ path_lower == "gemini.md"
690
+ or path_lower.startswith(("commands/", "skills/", "docs/", "hooks/", "scripts/", "src/"))
691
+ or "/extensions/medical-notes-workbench/" in path_lower,
692
+ )
693
+ return codes
694
+
695
+
696
+ def _telemetry_evidence_from_snapshot(snapshot: dict[str, Any], *, send_path: str) -> dict[str, Any]:
697
+ summary = snapshot.get("summary") if isinstance(snapshot.get("summary"), dict) else {}
698
+ extension_diffs = snapshot.get("extension_diffs") if isinstance(snapshot.get("extension_diffs"), list) else []
699
+ generated_scripts = snapshot.get("generated_scripts") if isinstance(snapshot.get("generated_scripts"), list) else []
700
+ counts = {
701
+ "extension_diff_count": len(extension_diffs),
702
+ "generated_script_count": len(generated_scripts),
703
+ "command_event_count": 0,
704
+ "hook_error_count": 0,
705
+ }
706
+ sources = []
707
+ if extension_diffs:
708
+ sources.append("manual_capture:extension_diffs")
709
+ if _safe_int(summary.get("github_baseline_diff_count")):
710
+ sources.append("github_baseline:extension_diffs")
711
+ if _safe_int(summary.get("rescued_pre_update_diff_count")):
712
+ sources.append("pre_update_snapshot:extension_diffs")
713
+ if generated_scripts:
714
+ sources.append("manual_capture:generated_scripts")
715
+ quality_flags = []
716
+ if generated_scripts:
717
+ quality_flags.append("telemetry.command_events_missing")
718
+ if _safe_int(summary.get("rescued_pre_update_diff_count")):
719
+ quality_flags.append("telemetry.rescued_pre_update_snapshot")
720
+ seed = {
721
+ "snapshot_id": snapshot.get("snapshot_id"),
722
+ "recorded_at": snapshot.get("recorded_at"),
723
+ "counts": counts,
724
+ "sources": sources,
725
+ }
726
+ return {
727
+ "schema": "medical-notes-workbench.telemetry-evidence.v1",
728
+ "bundle_id": f"telem-{hashlib.sha256(json.dumps(seed, sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()[:16]}",
729
+ "sources": sources,
730
+ "artifact_counts": counts,
731
+ "timeline": _evidence_timeline_from_snapshot(snapshot, extension_diffs, generated_scripts),
732
+ "quality_flags": quality_flags,
733
+ "redaction_summary": {
734
+ "applied": True,
735
+ "blocked_fields": ["content", "markdown", "html", "raw_chat", "note_text", ".env", "tokens", "keys"],
736
+ "operational_debug_fields": ["extension_diffs", "generated_scripts"],
737
+ },
738
+ "truncation_summary": {
739
+ "truncated_artifacts": sum(1 for item in extension_diffs + generated_scripts if isinstance(item, dict) and item.get("truncated")),
740
+ "omitted_artifacts": sum(
741
+ 1
742
+ for item in extension_diffs + generated_scripts
743
+ if isinstance(item, dict) and (item.get("full_diff_unavailable_reason") or item.get("content_omitted_reason"))
744
+ ),
745
+ },
746
+ "send_path": send_path,
747
+ }
748
+
749
+
750
+ def _evidence_timeline_from_snapshot(
751
+ snapshot: dict[str, Any],
752
+ extension_diffs: list[Any],
753
+ generated_scripts: list[Any],
754
+ ) -> list[dict[str, Any]]:
755
+ at = str(snapshot.get("recorded_at") or now_iso())
756
+ timeline: list[dict[str, Any]] = [
757
+ {"at": at, "kind": "pre_update_snapshot", "label": str(snapshot.get("snapshot_id") or ""), "phase": str(snapshot.get("phase") or "")}
758
+ ]
759
+ for diff in extension_diffs[:4]:
760
+ if isinstance(diff, dict):
761
+ timeline.append({"at": at, "kind": "extension_diff", "label": str(diff.get("path") or ""), "change": str(diff.get("change") or "")})
762
+ for script in generated_scripts[:4]:
763
+ if isinstance(script, dict):
764
+ timeline.append({"at": at, "kind": "generated_script", "label": str(script.get("path") or ""), "source": str(script.get("source") or "")})
765
+ return timeline[:12]
766
+
767
+
768
+ def _write_snapshot_files(snapshot_path: Path, snapshot: dict[str, Any], combined_patch: str, unavailable: list[dict[str, str]]) -> None:
769
+ _write_json(snapshot_path / "snapshot.json", _snapshot_metadata(snapshot))
770
+ _write_json(snapshot_path / "status.json", _status_payload(snapshot))
771
+ _write_json(snapshot_path / "diff-unavailable.json", unavailable)
772
+ _write_text(snapshot_path / "tracked.diff", combined_patch)
773
+ _write_text(snapshot_path / "extension-full.diff", combined_patch)
774
+ _write_text(snapshot_path / "staged.diff", "")
775
+ _write_text(snapshot_path / "untracked.diff", "")
776
+ for idx, item in enumerate(snapshot.get("extension_diffs") or [], start=1):
777
+ if not isinstance(item, dict) or not item.get("patch"):
778
+ continue
779
+ name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(item.get("path") or f"diff-{idx}"))
780
+ _write_text(snapshot_path / "diffs" / f"{idx:03d}-{name}.diff", str(item.get("patch") or ""))
781
+ for idx, script in enumerate(snapshot.get("generated_scripts") or [], start=1):
782
+ if not isinstance(script, dict) or not script.get("content"):
783
+ continue
784
+ name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(script.get("path") or f"script-{idx}"))
785
+ _write_text(snapshot_path / "generated-scripts" / name, str(script.get("content") or ""))
786
+
787
+
788
+ def _load_existing_pre_update_snapshot_diffs(*, exclude_dir: Path, limit: int = 8) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
789
+ root = Path.home() / ".gemini" / "medical-notes-workbench" / "feedback" / "pre-update-snapshots"
790
+ diffs: list[dict[str, Any]] = []
791
+ summaries: list[dict[str, Any]] = []
792
+ if not root.exists():
793
+ return diffs, summaries
794
+ for metadata_path in sorted(root.glob("*/snapshot.json"), reverse=True):
795
+ snapshot_dir = metadata_path.parent.resolve()
796
+ if snapshot_dir == exclude_dir.resolve():
797
+ continue
798
+ try:
799
+ snapshot = json.loads(metadata_path.read_text(encoding="utf-8"))
800
+ except (OSError, json.JSONDecodeError):
801
+ continue
802
+ if not isinstance(snapshot, dict):
803
+ continue
804
+ snapshot_id = str(snapshot.get("snapshot_id") or snapshot_dir.name)
805
+ snapshot_diffs = _read_existing_snapshot_diff_files(snapshot_dir, snapshot_id=snapshot_id)
806
+ changed_count = _safe_int(snapshot.get("changed_path_count")) + _safe_int(snapshot.get("untracked_path_count"))
807
+ summaries.append(
808
+ {
809
+ "snapshot_id": snapshot_id,
810
+ "recorded_at": str(snapshot.get("recorded_at") or ""),
811
+ "snapshot_path": str(snapshot_dir),
812
+ "current_version": str(snapshot.get("current_version") or ""),
813
+ "target_version": str(snapshot.get("target_version") or ""),
814
+ "git_head": str(snapshot.get("git_head") or ""),
815
+ "changed_count": changed_count,
816
+ "diff_count": len(snapshot_diffs),
817
+ "has_diff": bool(snapshot_diffs),
818
+ }
819
+ )
820
+ diffs.extend(snapshot_diffs)
821
+ if len(summaries) >= limit:
822
+ break
823
+ return diffs, summaries
824
+
825
+
826
+ def _read_existing_snapshot_diff_files(snapshot_dir: Path, *, snapshot_id: str) -> list[dict[str, Any]]:
827
+ out: list[dict[str, Any]] = []
828
+ for filename, change in (
829
+ ("tracked.diff", "rescued_pre_update_tracked"),
830
+ ("staged.diff", "rescued_pre_update_staged"),
831
+ ("untracked.diff", "rescued_pre_update_untracked"),
832
+ ("extension-full.diff", "rescued_manual_extension_full"),
833
+ ):
834
+ path = snapshot_dir / filename
835
+ try:
836
+ patch = path.read_text(encoding="utf-8")
837
+ except OSError:
838
+ continue
839
+ patch = _filter_pre_update_patch_noise(patch)
840
+ if not patch.strip():
841
+ continue
842
+ sanitized = redact_operational_text(patch, max_chars=MAX_PATCH_CHARS)
843
+ out.append(
844
+ {
845
+ "path": f"existing-pre-update/{snapshot_id}/{filename}",
846
+ "kind": "pre_update_snapshot",
847
+ "change": change,
848
+ "baseline_source": "existing-pre-update-snapshot",
849
+ "patch": sanitized,
850
+ "truncated": len(sanitized) < len(patch),
851
+ }
852
+ )
853
+ return out
854
+
855
+
856
+ def _filter_pre_update_patch_noise(patch: str) -> str:
857
+ blocks = re.split(r"(?m)(?=^diff --git )", patch)
858
+ kept: list[str] = []
859
+ for block in blocks:
860
+ if not block.strip():
861
+ continue
862
+ normalized = block.replace("\\", "/").lower()
863
+ if "git binary patch" in normalized:
864
+ continue
865
+ if any(part.lower() in normalized for part in PRE_UPDATE_PATCH_NOISE_PARTS):
866
+ continue
867
+ kept.append(block)
868
+ return "\n".join(item.rstrip("\n") for item in kept if item.strip()) + ("\n" if kept else "")
869
+
870
+
871
+ def _snapshot_metadata(snapshot: dict[str, Any]) -> dict[str, Any]:
872
+ keys = {
873
+ "schema",
874
+ "snapshot_id",
875
+ "recorded_at",
876
+ "extension_name",
877
+ "extension_path",
878
+ "snapshot_path",
879
+ "current_version",
880
+ "target_version",
881
+ "git_head",
882
+ "git_available",
883
+ "reason",
884
+ "patch_id",
885
+ "phase",
886
+ "changed_path_count",
887
+ "untracked_path_count",
888
+ "changed_paths",
889
+ "generated_scripts",
890
+ "summary",
891
+ "baseline_recovered_count",
892
+ "git_diff_empty_count",
893
+ "github_baseline",
894
+ "existing_pre_update_snapshots",
895
+ "telemetry_evidence",
896
+ }
897
+ return {key: snapshot.get(key) for key in keys if key in snapshot}
898
+
899
+
900
+ def _status_payload(snapshot: dict[str, object]) -> dict[str, object]:
901
+ summary = _object_dict(snapshot.get("summary"))
902
+ return {
903
+ "schema": "medical-notes-workbench.manual-extension-diff-capture.v1",
904
+ "checked": True,
905
+ "checked_at": snapshot.get("recorded_at"),
906
+ "drift_detected": bool(summary.get("changed_count")),
907
+ "root_label": _compact_path(str(snapshot.get("extension_path") or "")),
908
+ "summary": summary,
909
+ "modified_files": snapshot.get("modified_files") or [],
910
+ "missing_files": snapshot.get("missing_files") or [],
911
+ "unexpected_files": snapshot.get("unexpected_files") or [],
912
+ "line_ending_only_files": snapshot.get("line_ending_only_files") or [],
913
+ "extension_diffs": snapshot.get("extension_diffs") or [],
914
+ "diff_unavailable": snapshot.get("diff_unavailable") or [],
915
+ "existing_pre_update_snapshots": snapshot.get("existing_pre_update_snapshots") or [],
916
+ "github_baseline": snapshot.get("github_baseline") or {},
917
+ "telemetry_evidence": snapshot.get("telemetry_evidence") or {},
918
+ }
919
+
920
+
921
+ def _manual_report_receipt(snapshot: dict[str, Any], send_result: dict[str, Any]) -> dict[str, object]:
922
+ """Build the explicit /report receipt without depending on installed deps."""
923
+ sent = bool(send_result.get("sent") or send_result.get("ok"))
924
+ reason = str(send_result.get("reason") or "")
925
+ status = "sent" if sent else "not_sent"
926
+ return {
927
+ "schema": MANUAL_REPORT_RECEIPT_SCHEMA,
928
+ "status": status,
929
+ "requested_by_user": True,
930
+ "capture_schema": "medical-notes-workbench.manual-extension-diff-capture.v1",
931
+ "envelope_schema": TELEMETRY_ENVELOPE_SCHEMA,
932
+ "snapshot_path": str(snapshot.get("snapshot_path") or ""),
933
+ "envelope_path": str(Path(str(snapshot.get("snapshot_path") or "")) / "telemetry-envelope.json"),
934
+ "send_result_path": str(Path(str(snapshot.get("snapshot_path") or "")) / "send-result.json"),
935
+ "sent": sent,
936
+ "reason": reason or ("sent" if sent else "send_not_requested"),
937
+ "next_action": ""
938
+ if sent
939
+ else "O relatório manual ficou salvo localmente; envie somente quando o usuário pedir /report com envio explícito.",
940
+ "redaction_status": "redacted",
941
+ }
942
+
943
+
944
+ def _object_dict(value: object) -> dict[str, object]:
945
+ if not isinstance(value, dict):
946
+ return {}
947
+ return {str(key): nested for key, nested in value.items()}
948
+
949
+
950
+ def _build_envelope(
951
+ snapshot: dict[str, Any],
952
+ *,
953
+ endpoint_url: str = "",
954
+ auth_token: str = "",
955
+ config_path: str | Path | None = None,
956
+ ) -> dict[str, Any]:
957
+ settings = _read_telemetry_settings(endpoint_url=endpoint_url, auth_token=auth_token, config_path=config_path, extension_path=snapshot.get("extension_path"))
958
+ record = _run_record(snapshot)
959
+ return _fit_envelope(
960
+ {
961
+ "schema": TELEMETRY_ENVELOPE_SCHEMA,
962
+ "envelope_id": str(uuid.uuid4()),
963
+ "generated_at": now_iso(),
964
+ "install_id": settings.install_id or f"manual-rescue-{platform.node() or 'unknown'}",
965
+ "payload_level": PAYLOAD_LEVEL,
966
+ "client": {
967
+ "app": "medical-notes-workbench",
968
+ "app_version": str(snapshot.get("current_version") or "unknown"),
969
+ "python": platform.python_version(),
970
+ "platform": platform.platform(),
971
+ "capture_script": "scripts/mednotes/capture_extension_diff.py",
972
+ },
973
+ "records": [record],
974
+ "limits": {"max_envelope_bytes": MAX_ENVELOPE_BYTES},
975
+ },
976
+ max_bytes=MAX_ENVELOPE_BYTES,
977
+ )
978
+
979
+
980
+ def _run_record(snapshot: dict[str, Any]) -> dict[str, Any]:
981
+ summary = snapshot.get("summary") or {}
982
+ changed = _safe_int(summary.get("changed_count"))
983
+ return {
984
+ "schema": RUN_RECORD_SCHEMA,
985
+ "run_id": f"manual-extension-diff-capture-{snapshot.get('snapshot_id')}",
986
+ "recorded_at": snapshot.get("recorded_at"),
987
+ "workflow": "/mednotes:telemetry",
988
+ "source": "manual_rescue",
989
+ "command": "capture_extension_diff.py",
990
+ "exit_code": 0,
991
+ "duration_ms": 0,
992
+ "status": "completed_with_warnings" if changed else "completed",
993
+ "phase": "manual-extension-diff-capture",
994
+ "blocked_reason": "",
995
+ "next_action": "Analisar extension_diffs e preservar o snapshot antes de atualizar novamente a extensao.",
996
+ "required_inputs": [],
997
+ "human_decision_required": False,
998
+ "dry_run": False,
999
+ "apply": False,
1000
+ "payload_summary": {
1001
+ "counts": {
1002
+ "changed_path_count": _safe_int(snapshot.get("changed_path_count")),
1003
+ "untracked_path_count": _safe_int(snapshot.get("untracked_path_count")),
1004
+ "extension_diff_count": _safe_int(summary.get("extension_diff_count")),
1005
+ "github_baseline_diff_count": _safe_int(summary.get("github_baseline_diff_count")),
1006
+ "rescued_pre_update_diff_count": _safe_int(summary.get("rescued_pre_update_diff_count")),
1007
+ "existing_pre_update_snapshot_count": _safe_int(summary.get("existing_pre_update_snapshot_count")),
1008
+ "baseline_recovered_count": _safe_int(snapshot.get("baseline_recovered_count")),
1009
+ "git_diff_empty_count": _safe_int(snapshot.get("git_diff_empty_count")),
1010
+ },
1011
+ "warnings": ["manual_extension_diff_capture"],
1012
+ "errors": [],
1013
+ "required_inputs": [],
1014
+ "relevant_paths": (snapshot.get("changed_paths") or [])[:40],
1015
+ "path_hashes": {},
1016
+ "signals": ["extension.manual_diff_capture"],
1017
+ "status": "completed_with_warnings" if changed else "completed",
1018
+ "phase": "manual-extension-diff-capture",
1019
+ },
1020
+ "diagnostic_context": {
1021
+ "root_cause_code": "extension.manual_diff_capture",
1022
+ "root_cause_label": "Captura manual de drift da extensao",
1023
+ "recovery_command": "Abrir capture.zip ou extension-full.diff; se --send foi usado, verificar o email de telemetria.",
1024
+ "missing_inputs": [],
1025
+ "decision_context": {"types": [], "decisions": []},
1026
+ "blocker_context": {"codes": [], "counts": {}, "summaries": [], "samples": [], "routes": []},
1027
+ "contract_gaps": [],
1028
+ },
1029
+ "environment_context": {
1030
+ "extension_integrity": {
1031
+ "schema": PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA,
1032
+ "drift_detected": bool(changed),
1033
+ "snapshot_id": snapshot.get("snapshot_id"),
1034
+ "snapshot_path": snapshot.get("snapshot_path"),
1035
+ "patch_id": snapshot.get("patch_id"),
1036
+ "phase": snapshot.get("phase"),
1037
+ "reason": snapshot.get("reason"),
1038
+ "extension_name": snapshot.get("extension_name"),
1039
+ "current_version": snapshot.get("current_version"),
1040
+ "target_version": snapshot.get("target_version"),
1041
+ "git_head": snapshot.get("git_head"),
1042
+ "git_available": snapshot.get("git_available"),
1043
+ "extension_path": snapshot.get("extension_path"),
1044
+ "summary": summary,
1045
+ "extension_diffs": snapshot.get("extension_diffs") or [],
1046
+ "github_baseline": snapshot.get("github_baseline") or {},
1047
+ "baseline_recovered_count": snapshot.get("baseline_recovered_count"),
1048
+ "git_diff_empty_count": snapshot.get("git_diff_empty_count"),
1049
+ }
1050
+ },
1051
+ "diagnostic_snippets": [],
1052
+ "telemetry_evidence": snapshot.get("telemetry_evidence") or _telemetry_evidence_from_snapshot(snapshot, send_path="manual_extension_diff_capture"),
1053
+ "extension_diffs": snapshot.get("extension_diffs") or [],
1054
+ "generated_scripts": snapshot.get("generated_scripts") or [],
1055
+ "command_events": [],
1056
+ }
1057
+
1058
+
1059
+ def _fit_envelope(envelope: dict[str, Any], *, max_bytes: int) -> dict[str, Any]:
1060
+ body = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
1061
+ if len(body) <= max_bytes:
1062
+ return envelope
1063
+ records = envelope.get("records")
1064
+ if not isinstance(records, list) or not records:
1065
+ return envelope
1066
+ record = records[0]
1067
+ if not isinstance(record, dict):
1068
+ return envelope
1069
+ diffs = record.get("extension_diffs")
1070
+ if isinstance(diffs, list):
1071
+ for item in diffs:
1072
+ if isinstance(item, dict) and isinstance(item.get("patch"), str) and len(item["patch"]) > 24 * 1024:
1073
+ item["patch"] = item["patch"][: 24 * 1024 - 3].rstrip() + "..."
1074
+ item["truncated"] = True
1075
+ if len(diffs) > 8:
1076
+ record["extension_diffs"] = diffs[:8] + [{"path": "", "kind": "summary", "change": "truncated", "omitted_count": len(diffs) - 8}]
1077
+ integrity = record.get("environment_context", {}).get("extension_integrity") if isinstance(record.get("environment_context"), dict) else None
1078
+ if isinstance(integrity, dict):
1079
+ integrity["extension_diffs"] = record.get("extension_diffs", [])
1080
+ return envelope
1081
+
1082
+
1083
+ def _read_telemetry_settings(
1084
+ *,
1085
+ endpoint_url: str = "",
1086
+ auth_token: str = "",
1087
+ config_path: str | Path | None = None,
1088
+ extension_path: Any = None,
1089
+ ) -> TelemetrySettings:
1090
+ config = _read_toml_telemetry_section(Path(config_path).expanduser() if config_path else _default_config_path())
1091
+ defaults = _read_distribution_defaults(extension_path)
1092
+ endpoint = endpoint_url or str(config.get("endpoint_url") or defaults.get("endpoint_url") or "")
1093
+ token = auth_token or str(config.get("auth_token") or defaults.get("auth_token") or "")
1094
+ install_id = str(config.get("install_id") or defaults.get("install_id") or f"manual-rescue-{uuid.uuid4()}")
1095
+ return TelemetrySettings(endpoint_url=endpoint, auth_token=token, install_id=install_id)
1096
+
1097
+
1098
+ def _default_config_path() -> Path:
1099
+ override = os.getenv("MEDNOTES_TELEMETRY_CONFIG")
1100
+ if override:
1101
+ return Path(os.path.expandvars(override)).expanduser()
1102
+ home = os.getenv("MEDNOTES_HOME")
1103
+ base = Path(os.path.expandvars(home)).expanduser() if home else Path.home() / ".mednotes"
1104
+ return base / "config.toml"
1105
+
1106
+
1107
+ def _read_toml_telemetry_section(path: Path) -> dict[str, Any]:
1108
+ if not path.exists():
1109
+ return {}
1110
+ try:
1111
+ import tomllib
1112
+
1113
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
1114
+ section = data.get("telemetry") if isinstance(data, dict) else {}
1115
+ return section if isinstance(section, dict) else {}
1116
+ except Exception:
1117
+ return _read_toml_section_fallback(path, "telemetry")
1118
+
1119
+
1120
+ def _read_toml_section_fallback(path: Path, section_name: str) -> dict[str, Any]:
1121
+ try:
1122
+ text = path.read_text(encoding="utf-8")
1123
+ except OSError:
1124
+ return {}
1125
+ match = re.search(rf"(?ms)^\[{re.escape(section_name)}\]\s*(.*?)(?=^\[[^\n]+\]\s*|\Z)", text)
1126
+ if not match:
1127
+ return {}
1128
+ out: dict[str, Any] = {}
1129
+ for line in match.group(1).splitlines():
1130
+ line = line.strip()
1131
+ if not line or line.startswith("#") or "=" not in line:
1132
+ continue
1133
+ key, value = line.split("=", 1)
1134
+ key = key.strip()
1135
+ value = value.strip().strip('"').strip("'")
1136
+ if value.lower() in {"true", "false"}:
1137
+ out[key] = value.lower() == "true"
1138
+ else:
1139
+ out[key] = value
1140
+ return out
1141
+
1142
+
1143
+ def _read_distribution_defaults(extension_path: Any) -> dict[str, Any]:
1144
+ candidates: list[Path] = []
1145
+ env_path = os.getenv("MEDNOTES_TELEMETRY_DEFAULTS")
1146
+ if env_path:
1147
+ candidates.append(Path(os.path.expandvars(env_path)).expanduser())
1148
+ if extension_path:
1149
+ root = Path(str(extension_path)).expanduser()
1150
+ candidates.extend([root / "telemetry.defaults.json", root / ".telemetry-defaults.json"])
1151
+ for candidate in candidates:
1152
+ try:
1153
+ data = json.loads(candidate.read_text(encoding="utf-8"))
1154
+ except (OSError, json.JSONDecodeError):
1155
+ continue
1156
+ if isinstance(data, dict):
1157
+ return data
1158
+ return {}
1159
+
1160
+
1161
+ def _send_envelope(
1162
+ envelope: dict[str, Any],
1163
+ *,
1164
+ endpoint_url: str = "",
1165
+ auth_token: str = "",
1166
+ config_path: str | Path | None = None,
1167
+ ) -> dict[str, Any]:
1168
+ settings = _read_telemetry_settings(
1169
+ endpoint_url=endpoint_url,
1170
+ auth_token=auth_token,
1171
+ config_path=config_path,
1172
+ extension_path=(envelope.get("records") or [{}])[0].get("environment_context", {}).get("extension_integrity", {}).get("extension_path")
1173
+ if isinstance((envelope.get("records") or [{}])[0], dict)
1174
+ else "",
1175
+ )
1176
+ if not settings.endpoint_url or not settings.auth_token:
1177
+ return {"ok": False, "sent": False, "reason": "telemetry_endpoint_or_token_missing"}
1178
+ body = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
1179
+ request = urllib_request.Request(
1180
+ settings.endpoint_url,
1181
+ data=body,
1182
+ method="POST",
1183
+ headers={
1184
+ "Authorization": f"Bearer {settings.auth_token}",
1185
+ "Content-Type": "application/json",
1186
+ "X-MedNotes-Telemetry-Schema": TELEMETRY_ENVELOPE_SCHEMA,
1187
+ },
1188
+ )
1189
+ try:
1190
+ with urllib_request.urlopen(request, timeout=8) as response:
1191
+ response_body = response.read(64 * 1024).decode("utf-8", errors="replace")
1192
+ return {
1193
+ "ok": 200 <= response.status < 300,
1194
+ "sent": 200 <= response.status < 300,
1195
+ "status": response.status,
1196
+ "endpoint_url": settings.endpoint_url,
1197
+ "auth_token": settings.auth_token,
1198
+ "response": redact_operational_text(response_body, max_chars=2000),
1199
+ }
1200
+ except urllib_error.HTTPError as exc:
1201
+ response_body = exc.read(64 * 1024).decode("utf-8", errors="replace")
1202
+ return {
1203
+ "ok": False,
1204
+ "sent": False,
1205
+ "status": exc.code,
1206
+ "endpoint_url": settings.endpoint_url,
1207
+ "auth_token": settings.auth_token,
1208
+ "reason": redact_operational_text(response_body or str(exc), max_chars=2000),
1209
+ }
1210
+ except Exception as exc:
1211
+ return {
1212
+ "ok": False,
1213
+ "sent": False,
1214
+ "endpoint_url": settings.endpoint_url,
1215
+ "auth_token": settings.auth_token,
1216
+ "reason": redact_operational_text(str(exc), max_chars=2000),
1217
+ }
1218
+
1219
+
1220
+ def _flush_digest(endpoint_url: str, auth_token: str) -> dict[str, Any]:
1221
+ if not endpoint_url or not auth_token:
1222
+ return {"ok": False, "reason": "endpoint_or_token_missing"}
1223
+ digest_url = _digest_url(endpoint_url)
1224
+ request = urllib_request.Request(
1225
+ digest_url,
1226
+ data=b"{}",
1227
+ method="POST",
1228
+ headers={"Authorization": f"Bearer {auth_token}", "Content-Type": "application/json"},
1229
+ )
1230
+ try:
1231
+ with urllib_request.urlopen(request, timeout=8) as response:
1232
+ response_body = response.read(64 * 1024).decode("utf-8", errors="replace")
1233
+ return {"ok": 200 <= response.status < 300, "status": response.status, "response": redact_operational_text(response_body, max_chars=2000)}
1234
+ except urllib_error.HTTPError as exc:
1235
+ response_body = exc.read(64 * 1024).decode("utf-8", errors="replace")
1236
+ return {"ok": False, "status": exc.code, "reason": redact_operational_text(response_body or str(exc), max_chars=2000)}
1237
+ except Exception as exc:
1238
+ return {"ok": False, "reason": redact_operational_text(str(exc), max_chars=2000)}
1239
+
1240
+
1241
+ def _digest_url(endpoint_url: str) -> str:
1242
+ parsed = urlsplit(endpoint_url)
1243
+ path = parsed.path.rstrip("/")
1244
+ if path.endswith("/v1/telemetry/workflow-runs"):
1245
+ path = path[: -len("/v1/telemetry/workflow-runs")] + "/v1/telemetry/digest/send"
1246
+ else:
1247
+ path = path + "/digest/send"
1248
+ return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, parsed.fragment))
1249
+
1250
+
1251
+ def _redacted_send_result(result: dict[str, Any]) -> dict[str, Any]:
1252
+ out = dict(result)
1253
+ if out.get("auth_token"):
1254
+ out["auth_token"] = "[redacted]"
1255
+ if out.get("endpoint_url"):
1256
+ out["endpoint_url"] = _redact_url(str(out["endpoint_url"]))
1257
+ return out
1258
+
1259
+
1260
+ def _public_capture_result(snapshot: dict[str, Any]) -> dict[str, Any]:
1261
+ return {
1262
+ "ok": True,
1263
+ "snapshot_path": snapshot.get("snapshot_path"),
1264
+ "zip_path": snapshot.get("zip_path"),
1265
+ "summary": snapshot.get("summary"),
1266
+ "existing_pre_update_snapshots": snapshot.get("existing_pre_update_snapshots") or [],
1267
+ "baseline_recovered_count": snapshot.get("baseline_recovered_count"),
1268
+ "git_diff_empty_count": snapshot.get("git_diff_empty_count"),
1269
+ "send_result": snapshot.get("send_result"),
1270
+ "next_action": "Se --send foi usado, o envelope ficou no digest horario do Worker; use --send --flush apenas quando precisar forcar um email imediato.",
1271
+ }
1272
+
1273
+
1274
+ def _write_zip(snapshot_path: Path) -> Path | None:
1275
+ zip_path = snapshot_path / "capture.zip"
1276
+ try:
1277
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
1278
+ for path in snapshot_path.rglob("*"):
1279
+ if path == zip_path or not path.is_file():
1280
+ continue
1281
+ if path.stat().st_size > MAX_ZIP_FILE_BYTES:
1282
+ continue
1283
+ archive.write(path, path.relative_to(snapshot_path))
1284
+ except OSError:
1285
+ return None
1286
+ return zip_path
1287
+
1288
+
1289
+ def _prune_pre_update_snapshots_for_path(snapshot_path: Path) -> dict[str, object]:
1290
+ root = _pre_update_snapshot_root()
1291
+ if not _path_is_relative_to(snapshot_path, root):
1292
+ return {}
1293
+ return _prune_pre_update_snapshots(root=root, keep=snapshot_path)
1294
+
1295
+
1296
+ def _prune_pre_update_snapshots(*, root: Path, keep: Path) -> dict[str, object]:
1297
+ max_dirs = _env_int("MEDNOTES_PRE_UPDATE_SNAPSHOT_MAX_DIRS", DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS, minimum=0)
1298
+ retention_days = _env_int(
1299
+ "MEDNOTES_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS",
1300
+ DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS,
1301
+ minimum=0,
1302
+ )
1303
+ try:
1304
+ items = [item for item in root.iterdir() if item.is_dir()]
1305
+ except OSError:
1306
+ return {
1307
+ "schema": "medical-notes-workbench.local-feedback-retention.v1",
1308
+ "target": "pre-update-snapshots",
1309
+ "max_items": max_dirs,
1310
+ "retention_days": retention_days,
1311
+ "removed_count": 0,
1312
+ "remaining_count": 0,
1313
+ "error": "list_failed",
1314
+ }
1315
+
1316
+ victims = _retention_victims(items, max_items=max_dirs, retention_days=retention_days, keep=keep)
1317
+ removed = 0
1318
+ for victim in victims:
1319
+ try:
1320
+ shutil.rmtree(victim)
1321
+ removed += 1
1322
+ except OSError:
1323
+ continue
1324
+
1325
+ try:
1326
+ remaining = sum(1 for item in root.iterdir() if item.is_dir())
1327
+ except OSError:
1328
+ remaining = 0
1329
+ return {
1330
+ "schema": "medical-notes-workbench.local-feedback-retention.v1",
1331
+ "target": "pre-update-snapshots",
1332
+ "max_items": max_dirs,
1333
+ "retention_days": retention_days,
1334
+ "removed_count": removed,
1335
+ "remaining_count": remaining,
1336
+ }
1337
+
1338
+
1339
+ def _retention_victims(paths: list[Path], *, max_items: int, retention_days: int, keep: Path) -> list[Path]:
1340
+ keep_path = keep.resolve()
1341
+ ordered = sorted(paths, key=lambda item: (_mtime(item), item.name), reverse=True)
1342
+ kept: set[Path] = {keep_path}
1343
+ for item in ordered:
1344
+ resolved = item.resolve()
1345
+ if resolved in kept:
1346
+ continue
1347
+ if len(kept) < max_items:
1348
+ kept.add(resolved)
1349
+
1350
+ cutoff = datetime.now(UTC).timestamp() - (retention_days * 24 * 60 * 60)
1351
+ victims: list[Path] = []
1352
+ for item in ordered:
1353
+ resolved = item.resolve()
1354
+ if resolved == keep_path:
1355
+ continue
1356
+ if resolved not in kept or _mtime(item) < cutoff:
1357
+ victims.append(item)
1358
+ return victims
1359
+
1360
+
1361
+ def _path_is_relative_to(path: Path, root: Path) -> bool:
1362
+ try:
1363
+ path.resolve().relative_to(root.resolve())
1364
+ except ValueError:
1365
+ return False
1366
+ return True
1367
+
1368
+
1369
+ def _mtime(path: Path) -> float:
1370
+ try:
1371
+ return path.stat().st_mtime
1372
+ except OSError:
1373
+ return 0.0
1374
+
1375
+
1376
+ def _env_int(name: str, default: int, *, minimum: int) -> int:
1377
+ try:
1378
+ value = int(os.environ.get(name, ""))
1379
+ except (TypeError, ValueError):
1380
+ value = default
1381
+ return max(minimum, value)
1382
+
1383
+
1384
+ def _run_git(root: Path, *args: str, timeout: float = 5, text: bool = True) -> subprocess.CompletedProcess[Any]:
1385
+ return subprocess.run(
1386
+ ["git", "-C", str(root), *args],
1387
+ text=text,
1388
+ capture_output=True,
1389
+ check=False,
1390
+ timeout=timeout,
1391
+ )
1392
+
1393
+
1394
+ def _git_stdout(root: Path, *args: str) -> str:
1395
+ try:
1396
+ result = _run_git(root, *args, timeout=5)
1397
+ except (OSError, subprocess.SubprocessError):
1398
+ return ""
1399
+ if result.returncode != 0:
1400
+ return ""
1401
+ return str(result.stdout).strip()
1402
+
1403
+
1404
+ def _has_git_worktree(root: Path) -> bool:
1405
+ if shutil.which("git") is None:
1406
+ return False
1407
+ return _git_stdout(root, "rev-parse", "--is-inside-work-tree").lower() == "true"
1408
+
1409
+
1410
+ def _read_bytes(path: Path) -> bytes:
1411
+ try:
1412
+ return path.read_bytes()
1413
+ except OSError:
1414
+ return b""
1415
+
1416
+
1417
+ def _decode_text(data: bytes) -> str | None:
1418
+ if b"\x00" in data:
1419
+ return None
1420
+ for encoding in ("utf-8", "utf-8-sig", "cp1252"):
1421
+ try:
1422
+ return data.decode(encoding)
1423
+ except UnicodeDecodeError:
1424
+ continue
1425
+ return data.decode("utf-8", errors="replace")
1426
+
1427
+
1428
+ def _hash_bytes(data: bytes) -> str:
1429
+ return hashlib.sha256(data).hexdigest()
1430
+
1431
+
1432
+ def _normalize_line_endings(data: bytes) -> bytes:
1433
+ return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
1434
+
1435
+
1436
+ def redact_operational_text(value: Any, *, max_chars: int = MAX_PATCH_CHARS) -> str:
1437
+ text = str(value)
1438
+ text = re.sub(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[email]", text)
1439
+ text = re.sub(
1440
+ r"(?i)\b(api[_-]?key|token|secret|password|authorization|bearer)(\s*[:=]\s*)([\"']?)[^\s\"']+",
1441
+ r"\1\2[redacted]",
1442
+ text,
1443
+ )
1444
+ text = re.sub(
1445
+ r"(?i)(--(?:api-key|auth-token|token|secret|password)\s+)([^\s\"']+)",
1446
+ r"\1[redacted]",
1447
+ text,
1448
+ )
1449
+ text = re.sub(r"https?://[^\s)>\"]+", lambda match: _redact_url(match.group(0)), text)
1450
+ text = re.sub(r"\b[A-Za-z0-9_=-]{36,}\b", "[redacted-token]", text)
1451
+ if len(text) > max_chars:
1452
+ return text[: max_chars - 3].rstrip() + "..."
1453
+ return text
1454
+
1455
+
1456
+ def _redact_url(url: str) -> str:
1457
+ if "?" not in url:
1458
+ return url
1459
+ head, _query = url.split("?", 1)
1460
+ return f"{head}?[redacted]"
1461
+
1462
+
1463
+ def _compact_path(path: str) -> str:
1464
+ home = str(Path.home())
1465
+ if path.startswith(home):
1466
+ return "~" + path[len(home) :]
1467
+ return path
1468
+
1469
+
1470
+ def _write_json(path: Path, payload: Any) -> None:
1471
+ path.parent.mkdir(parents=True, exist_ok=True)
1472
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1473
+
1474
+
1475
+ def _write_text(path: Path, value: str) -> None:
1476
+ path.parent.mkdir(parents=True, exist_ok=True)
1477
+ path.write_text(value, encoding="utf-8")
1478
+
1479
+
1480
+ def _safe_int(value: Any) -> int:
1481
+ try:
1482
+ return int(value or 0)
1483
+ except (TypeError, ValueError):
1484
+ return 0
1485
+
1486
+
1487
+ def _file_kind(rel: str) -> str:
1488
+ path = Path(rel)
1489
+ parts = rel.split("/")
1490
+ suffix = path.suffix.lower()
1491
+ if rel == "GEMINI.md" or (len(parts) >= 2 and parts[0] == "skills" and path.name == "SKILL.md"):
1492
+ return "prompt"
1493
+ if parts and parts[0] == "commands":
1494
+ return "launcher"
1495
+ if parts and parts[0] == "docs":
1496
+ return "runbook" if len(parts) > 1 and parts[1] == "workflows" else "documentation"
1497
+ if suffix in SCRIPT_SUFFIXES:
1498
+ return "script"
1499
+ if parts and parts[0] in {"agents", "policies", "mcp"}:
1500
+ return parts[0][:-1] if parts[0].endswith("s") else parts[0]
1501
+ return "metadata"
1502
+
1503
+
1504
+ def _language_for_suffix(suffix: str) -> str:
1505
+ return {
1506
+ ".cjs": "javascript",
1507
+ ".cmd": "batch",
1508
+ ".js": "javascript",
1509
+ ".mjs": "javascript",
1510
+ ".ps1": "powershell",
1511
+ ".py": "python",
1512
+ ".sh": "shell",
1513
+ }.get(suffix, suffix.lstrip(".") or "text")
1514
+
1515
+
1516
+ def main(argv: list[str] | None = None) -> int:
1517
+ parser = argparse.ArgumentParser(description="Capture trusted extension debug diff for Medical Notes Workbench.")
1518
+ parser.add_argument(
1519
+ "--extension-path",
1520
+ default=str(Path.home() / ".gemini" / "extensions" / "medical-notes-workbench"),
1521
+ help="Installed extension path. Defaults to ~/.gemini/extensions/medical-notes-workbench.",
1522
+ )
1523
+ parser.add_argument(
1524
+ "--output-dir",
1525
+ default="",
1526
+ help="Snapshot output directory. Defaults to ~/.mednotes/feedback/pre-update-snapshots/<id>.",
1527
+ )
1528
+ parser.add_argument("--send", action="store_true", help="Post the trusted debug envelope to the configured telemetry Worker.")
1529
+ parser.add_argument("--flush", action="store_true", help="Request immediate digest delivery after --send. Use sparingly; normal delivery is hourly digest.")
1530
+ parser.add_argument("--no-flush", action="store_true", help="Compatibility no-op; immediate digest delivery is already disabled unless --flush is passed.")
1531
+ parser.add_argument(
1532
+ "--no-existing-snapshots",
1533
+ action="store_true",
1534
+ help="Do not attach previously captured pre-update snapshots from the feedback directory.",
1535
+ )
1536
+ parser.add_argument("--endpoint", default="", help="Telemetry endpoint override.")
1537
+ parser.add_argument("--token", default="", help="Telemetry auth token override.")
1538
+ parser.add_argument("--config", default="", help="Telemetry config.toml override.")
1539
+ parser.add_argument(
1540
+ "--github-baseline-url",
1541
+ default="",
1542
+ help=f"Downloaded GitHub extension bundle zip to compare against, for example {DEFAULT_GITHUB_BASELINE_URL}.",
1543
+ )
1544
+ args = parser.parse_args(argv)
1545
+
1546
+ snapshot = capture_extension_diff(
1547
+ args.extension_path,
1548
+ output_dir=args.output_dir or None,
1549
+ send=args.send,
1550
+ endpoint_url=args.endpoint,
1551
+ auth_token=args.token,
1552
+ config_path=args.config or None,
1553
+ flush_digest=bool(args.flush and not args.no_flush),
1554
+ include_existing_snapshots=not args.no_existing_snapshots,
1555
+ github_baseline_url=args.github_baseline_url,
1556
+ )
1557
+ print(json.dumps(_public_capture_result(snapshot), ensure_ascii=False, indent=2, sort_keys=True))
1558
+ return 0
1559
+
1560
+
1561
+ if __name__ == "__main__":
1562
+ raise SystemExit(main())