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,3107 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform git policy helpers for the Obsidian vault."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime, timedelta
16
+ from pathlib import Path
17
+
18
+ try:
19
+ import tomllib
20
+ except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
21
+ tomllib = None
22
+
23
+
24
+ STATE_SUBDIR = Path(".mednotes")
25
+ APP_HOME_ENV_VARS = ("MEDNOTES_HOME",)
26
+ CONFIG_ENV_VARS = ("MEDNOTES_CONFIG",)
27
+ VAULT_IDENTITY_MARKER = ".medical-notes-workbench-vault"
28
+ WORKTREE_SUBDIR = "vault-worktrees"
29
+ RESTORE_PLAN_SUBDIR = "vault-restore-plans"
30
+ GIT_IDENTITIES_FILE = "vault.git-identities.json"
31
+ GUARD_LEASE_SUBDIR = Path("vault-guard") / "leases"
32
+ GUARD_LEASE_TTL_MINUTES = 12 * 60
33
+ GIT_PROBE_TIMEOUT_SECONDS = 30
34
+ GIT_NETWORK_TIMEOUT_SECONDS = 120
35
+ GITHUB_LOGIN_TIMEOUT_SECONDS = 300
36
+ SUBPROCESS_TEXT_KWARGS = {"encoding": "utf-8", "errors": "replace"}
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class VaultHumanDecisionOption:
41
+ """Closed option rendered in setup payloads that need human confirmation."""
42
+
43
+ label: str
44
+ description: str
45
+ resume_action: str
46
+
47
+ def to_payload(self) -> dict[str, object]:
48
+ return {
49
+ "label": self.label,
50
+ "description": self.description,
51
+ "resume_action": self.resume_action,
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class VaultHumanDecisionPacket:
57
+ """Typed local packet for the standalone vault setup script."""
58
+
59
+ kind: str
60
+ prompt: str
61
+ options: tuple[VaultHumanDecisionOption, ...] = ()
62
+ resume_action: str = ""
63
+ current_branch: str = ""
64
+
65
+ def to_payload(self) -> dict[str, object]:
66
+ payload: dict[str, object] = {
67
+ "kind": self.kind,
68
+ "prompt": self.prompt,
69
+ "options": [option.to_payload() for option in self.options],
70
+ }
71
+ if self.resume_action:
72
+ payload["resume_action"] = self.resume_action
73
+ if self.current_branch:
74
+ payload["current_branch"] = self.current_branch
75
+ return payload
76
+
77
+
78
+ class VaultGitError(RuntimeError):
79
+ """Operational error that should be shown directly to the caller."""
80
+
81
+ def __init__(
82
+ self,
83
+ message: str,
84
+ *,
85
+ status: str = "blocked_error",
86
+ blocked_reason: str = "error",
87
+ next_action: str | None = None,
88
+ required_inputs: list[str] | None = None,
89
+ human_decision_required: bool = False,
90
+ human_decision_packet: VaultHumanDecisionPacket | None = None,
91
+ ) -> None:
92
+ super().__init__(message)
93
+ self.status = status
94
+ self.blocked_reason = blocked_reason
95
+ self.next_action = next_action
96
+ self.required_inputs = required_inputs or []
97
+ self.human_decision_required = human_decision_required
98
+ self.human_decision_packet = human_decision_packet
99
+
100
+ def to_payload(self) -> dict[str, object]:
101
+ payload: dict[str, object] = {
102
+ "schema": "medical-notes-workbench.vault-error.v1",
103
+ "status": self.status,
104
+ "blocked_reason": self.blocked_reason,
105
+ "human_message": str(self),
106
+ "human_decision_required": self.human_decision_required,
107
+ }
108
+ if self.next_action:
109
+ payload["next_action"] = self.next_action
110
+ if self.required_inputs:
111
+ payload["required_inputs"] = self.required_inputs
112
+ if self.human_decision_packet:
113
+ payload["human_decision_packet"] = self.human_decision_packet.to_payload()
114
+ return payload
115
+
116
+
117
+ class MarkProvidedAction(argparse.Action):
118
+ """Record whether an optional argument was explicitly provided."""
119
+
120
+ def __call__(
121
+ self,
122
+ parser: argparse.ArgumentParser,
123
+ namespace: argparse.Namespace,
124
+ values: str | None,
125
+ option_string: str | None = None,
126
+ ) -> None:
127
+ setattr(namespace, self.dest, values)
128
+ setattr(namespace, f"{self.dest}_provided", True)
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class VaultContext:
133
+ vault_dir: Path
134
+ origin_url: str | None = None
135
+
136
+ @property
137
+ def backup_online(self) -> bool:
138
+ return bool(self.origin_url)
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class GitIdentity:
143
+ name: str
144
+ email: str
145
+ source: str
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class SetupRestorePoint:
150
+ restore_point_id: str
151
+ status: str
152
+ label: str
153
+ working_tree_clean: bool
154
+ local_changes_present: bool
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class RunFinishRunIdResolution:
159
+ run_id: str
160
+ requested_run_id: str = ""
161
+ auto_recovered: bool = False
162
+ recovery_reason: str = ""
163
+
164
+
165
+ def _state_dir() -> Path:
166
+ for env_name in APP_HOME_ENV_VARS:
167
+ value = os.environ.get(env_name)
168
+ if value:
169
+ return Path(os.path.expandvars(value)).expanduser()
170
+ return Path.home() / STATE_SUBDIR
171
+
172
+
173
+ def _read_first_config_line(path: Path) -> str:
174
+ try:
175
+ lines = path.read_text(encoding="utf-8").splitlines()
176
+ except OSError as exc:
177
+ raise VaultGitError(f"vault_resolve: nao consegui ler {path}: {exc}") from exc
178
+ for line in lines:
179
+ value = line.strip()
180
+ if value:
181
+ return value
182
+ raise VaultGitError(f"vault_resolve: {path} esta vazio")
183
+
184
+
185
+ def resolve_vault_dir(preferred: str | None = None) -> Path:
186
+ if preferred:
187
+ raw = preferred
188
+ elif os.environ.get("VAULT_DIR"):
189
+ raw = os.environ["VAULT_DIR"]
190
+ elif configured_wiki := _configured_wiki_dir():
191
+ raw = str(configured_wiki)
192
+ else:
193
+ path_file = _state_dir() / "vault.path"
194
+ if not path_file.is_file():
195
+ raise VaultGitError(
196
+ "vault_resolve: nao consegui resolver o caminho do vault.\n"
197
+ "Defina UMA das opcoes abaixo:\n"
198
+ " - flag --vault-dir <path>\n"
199
+ " - variavel de ambiente VAULT_DIR\n"
200
+ " - arquivo ~/.mednotes/vault.path com o caminho absoluto"
201
+ )
202
+ raw = _read_first_config_line(path_file)
203
+
204
+ vault_dir = Path(raw).expanduser()
205
+ if not vault_dir.is_dir():
206
+ raise VaultGitError(f"vault_resolve: {vault_dir} nao e diretorio")
207
+ return _coerce_to_configured_git_root(vault_dir.resolve())
208
+
209
+
210
+ def _timeout_result(args: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
211
+ return subprocess.CompletedProcess(args=args, returncode=124, stdout="", stderr=f"timeout depois de {timeout}s")
212
+
213
+
214
+ def _windows_command_fallbacks(command: str, env: dict[str, str]) -> list[str]:
215
+ name = Path(command).name.lower()
216
+ candidates: list[Path] = []
217
+ program_roots = [
218
+ env.get("ProgramFiles"),
219
+ env.get("ProgramFiles(x86)"),
220
+ ]
221
+ local_app_data = env.get("LOCALAPPDATA")
222
+ if local_app_data:
223
+ program_roots.append(str(Path(local_app_data) / "Programs"))
224
+
225
+ roots = [Path(root) for root in program_roots if root]
226
+ if name in {"git", "git.exe"}:
227
+ for root in roots:
228
+ candidates.extend(
229
+ [
230
+ root / Path("Git") / "cmd" / "git.exe",
231
+ root / Path("Git") / "bin" / "git.exe",
232
+ ]
233
+ )
234
+ elif name in {"gh", "gh.exe"}:
235
+ for root in roots:
236
+ candidates.append(root / Path("GitHub CLI") / "gh.exe")
237
+
238
+ unique: list[str] = []
239
+ seen: set[str] = set()
240
+ for candidate in candidates:
241
+ value = str(candidate)
242
+ key = os.path.normcase(value)
243
+ if key not in seen:
244
+ seen.add(key)
245
+ unique.append(value)
246
+ return unique
247
+
248
+
249
+ def _resolve_windows_command(command: str, env: dict[str, str]) -> str | None:
250
+ resolved = shutil.which(command, path=env.get("PATH"))
251
+ if resolved:
252
+ return resolved
253
+ for candidate in _windows_command_fallbacks(command, env):
254
+ if Path(candidate).is_file():
255
+ return candidate
256
+ return None
257
+
258
+
259
+ def _subprocess_command(args: list[str], env: dict[str, str]) -> list[str]:
260
+ if os.name != "nt" or not args:
261
+ return args
262
+ resolved = _resolve_windows_command(args[0], env)
263
+ if not resolved:
264
+ return args
265
+ resolved_command = str(resolved)
266
+ if Path(resolved_command).suffix.lower() in {".bat", ".cmd"}:
267
+ shell = env.get("COMSPEC") or "cmd.exe"
268
+ return [shell, "/c", resolved_command, *args[1:]]
269
+ return [resolved_command, *args[1:]]
270
+
271
+
272
+ def _git(
273
+ vault_dir: Path,
274
+ args: list[str],
275
+ *,
276
+ check: bool = True,
277
+ timeout: int = GIT_PROBE_TIMEOUT_SECONDS,
278
+ extra_env: dict[str, str] | None = None,
279
+ ) -> subprocess.CompletedProcess[str]:
280
+ env = os.environ.copy()
281
+ env.setdefault("GIT_TERMINAL_PROMPT", "0")
282
+ if extra_env:
283
+ env.update(extra_env)
284
+ command = _subprocess_command(["git", "-C", str(vault_dir), *args], env)
285
+ try:
286
+ result = subprocess.run(
287
+ command,
288
+ text=True,
289
+ **SUBPROCESS_TEXT_KWARGS,
290
+ capture_output=True,
291
+ env=env,
292
+ check=False,
293
+ timeout=timeout,
294
+ )
295
+ except FileNotFoundError as exc:
296
+ raise VaultGitError(
297
+ "Git não encontrado. Instale Git e rode /mednotes:setup novamente.",
298
+ status="blocked_missing_git",
299
+ blocked_reason="missing_git",
300
+ next_action="instalar Git e rodar /mednotes:setup novamente",
301
+ ) from exc
302
+ except subprocess.TimeoutExpired:
303
+ result = _timeout_result(command, timeout)
304
+ if check and result.returncode != 0:
305
+ detail = (result.stderr or result.stdout).strip()
306
+ raise VaultGitError(f"git {' '.join(args)} falhou: {detail}")
307
+ return result
308
+
309
+
310
+ def _git_without_repo(args: list[str], *, timeout: int = GIT_PROBE_TIMEOUT_SECONDS) -> subprocess.CompletedProcess[str]:
311
+ env = os.environ.copy()
312
+ env.setdefault("GIT_TERMINAL_PROMPT", "0")
313
+ env["GIT_DIR"] = ""
314
+ env.pop("GIT_WORK_TREE", None)
315
+ command = _subprocess_command(["git", *args], env)
316
+ try:
317
+ return subprocess.run(
318
+ command,
319
+ cwd=Path.home(),
320
+ text=True,
321
+ **SUBPROCESS_TEXT_KWARGS,
322
+ capture_output=True,
323
+ env=env,
324
+ check=False,
325
+ timeout=timeout,
326
+ )
327
+ except FileNotFoundError as exc:
328
+ raise VaultGitError(
329
+ "Git não encontrado. Instale Git e rode /mednotes:setup novamente.",
330
+ status="blocked_missing_git",
331
+ blocked_reason="missing_git",
332
+ next_action="instalar Git e rodar /mednotes:setup novamente",
333
+ ) from exc
334
+ except subprocess.TimeoutExpired:
335
+ return _timeout_result(command, timeout)
336
+
337
+
338
+ def _norm_path(path: Path) -> str:
339
+ return os.path.normcase(str(path.resolve()))
340
+
341
+
342
+ def _path_is_same_or_inside(path: Path, root: Path) -> bool:
343
+ resolved_path = path.expanduser().resolve(strict=False)
344
+ resolved_root = root.expanduser().resolve(strict=False)
345
+ if os.path.normcase(str(resolved_path)) == os.path.normcase(str(resolved_root)):
346
+ return True
347
+ try:
348
+ resolved_path.relative_to(resolved_root)
349
+ except ValueError:
350
+ return False
351
+ return True
352
+
353
+
354
+ def _read_app_config() -> dict[str, object]:
355
+ config_path = _app_config_path()
356
+ if not config_path.is_file() or tomllib is None:
357
+ return {}
358
+ try:
359
+ return tomllib.loads(config_path.read_text(encoding="utf-8"))
360
+ except (OSError, tomllib.TOMLDecodeError):
361
+ return {}
362
+
363
+
364
+ def _app_config_path() -> Path:
365
+ for env_name in CONFIG_ENV_VARS:
366
+ value = os.environ.get(env_name)
367
+ if value:
368
+ return Path(os.path.expandvars(value)).expanduser()
369
+ return _state_dir() / "config.toml"
370
+
371
+
372
+ def _configured_wiki_dir() -> Path | None:
373
+ cfg = _read_app_config()
374
+ paths = cfg.get("paths", {}) if isinstance(cfg.get("paths"), dict) else {}
375
+ value = paths.get("wiki_dir") if isinstance(paths, dict) else None
376
+ if not isinstance(value, str) or not value.strip():
377
+ return None
378
+ return Path(value).expanduser().resolve(strict=False)
379
+
380
+
381
+ def _coerce_to_configured_git_root(candidate: Path) -> Path:
382
+ root = _repo_root(candidate)
383
+ if root is None or _norm_path(root) == _norm_path(candidate):
384
+ return candidate
385
+ configured_wiki = _configured_wiki_dir()
386
+ if configured_wiki is None:
387
+ return candidate
388
+ if not _path_is_same_or_inside(configured_wiki, root):
389
+ return candidate
390
+ if _path_is_same_or_inside(candidate, configured_wiki) or _path_is_same_or_inside(configured_wiki, candidate):
391
+ return root
392
+ return candidate
393
+
394
+
395
+ def _validate_configured_wiki_inside_vault(vault_dir: Path) -> None:
396
+ configured_wiki = _configured_wiki_dir()
397
+ if configured_wiki is None:
398
+ return
399
+ if not _path_is_same_or_inside(configured_wiki, vault_dir):
400
+ raise VaultGitError(
401
+ f"vault_validate: [paths].wiki_dir aponta para {configured_wiki}, fora da raiz Git {vault_dir}.",
402
+ status="blocked_setup_required",
403
+ blocked_reason="wiki_dir_outside_vault",
404
+ next_action=(
405
+ "rodar set-paths com a Wiki correta ou rodar /mednotes:setup apontando para "
406
+ "a raiz Git que contém essa Wiki"
407
+ ),
408
+ )
409
+
410
+
411
+ def validate_vault(vault_dir: Path, *, require_remote: bool = False) -> VaultContext:
412
+ inside = _git(vault_dir, ["rev-parse", "--is-inside-work-tree"], check=False)
413
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
414
+ raise VaultGitError(
415
+ f"vault_validate: {vault_dir} ainda nao tem protecao local configurada. "
416
+ "Rode /mednotes:setup para preparar pontos de restauração.",
417
+ status="blocked_setup_required",
418
+ blocked_reason="setup_required",
419
+ next_action="rodar /mednotes:setup antes de alterar o vault",
420
+ )
421
+
422
+ root = _git(vault_dir, ["rev-parse", "--show-toplevel"]).stdout.strip()
423
+ if _norm_path(Path(root)) != _norm_path(vault_dir):
424
+ raise VaultGitError(
425
+ f"vault_validate: {vault_dir} nao e a raiz do repo git ({root})",
426
+ status="blocked_wrong_repo_root",
427
+ blocked_reason="wrong_repo_root",
428
+ next_action="rodar set-paths com a Wiki correta ou /mednotes:setup com a raiz Git do vault",
429
+ )
430
+
431
+ _validate_configured_wiki_inside_vault(vault_dir)
432
+
433
+ origin_url = _git(vault_dir, ["remote", "get-url", "origin"], check=False).stdout.strip()
434
+ if not origin_url and require_remote:
435
+ raise VaultGitError(
436
+ f"vault_validate: backup online ainda nao configurado em {vault_dir}. "
437
+ "Rode /mednotes:setup para conduzir o login GitHub ou criar repositório privado.",
438
+ status="blocked_online_backup_required",
439
+ blocked_reason="online_backup_required",
440
+ next_action="rodar /mednotes:setup para ativar backup online antes do fluxo paralelo",
441
+ )
442
+ if not origin_url:
443
+ return VaultContext(vault_dir=vault_dir)
444
+
445
+ allowlist = _state_dir() / "vault.remote-allowlist"
446
+ if allowlist.is_file():
447
+ try:
448
+ allowed = [
449
+ line.strip()
450
+ for line in allowlist.read_text(encoding="utf-8").splitlines()
451
+ if line.strip() and not line.lstrip().startswith("#")
452
+ ]
453
+ except OSError as exc:
454
+ raise VaultGitError(f"vault_validate: nao consegui ler {allowlist}: {exc}") from exc
455
+ if origin_url not in allowed:
456
+ raise VaultGitError(
457
+ f'vault_validate: origin url "{origin_url}" nao consta em {allowlist}\n'
458
+ "Isto evita push para repo errado por acidente. Se a URL e correta, adicione-a:\n"
459
+ f' echo "{origin_url}" >> "{allowlist}"',
460
+ status="blocked_remote_untrusted",
461
+ blocked_reason="remote_not_allowlisted",
462
+ next_action="rodar /mednotes:setup para validar o backup online do vault",
463
+ )
464
+ else:
465
+ print(
466
+ f"vault_validate: aviso - vault.remote-allowlist nao existe, usando {origin_url} sem allowlist",
467
+ file=sys.stderr,
468
+ )
469
+
470
+ if require_remote and not _remote_access_ok(vault_dir):
471
+ raise VaultGitError(
472
+ "Backup online configurado, mas inacessível agora. A proteção local continua válida; "
473
+ "corrija login/rede/permissão do GitHub e tente novamente.",
474
+ status="blocked_remote_unreachable",
475
+ blocked_reason="remote_unreachable",
476
+ next_action="rodar /mednotes:setup para revalidar o backup online",
477
+ )
478
+
479
+ return VaultContext(vault_dir=vault_dir, origin_url=origin_url)
480
+
481
+
482
+ def _ensure_main(vault_dir: Path, label: str) -> None:
483
+ current = _git(vault_dir, ["symbolic-ref", "--short", "HEAD"], check=False).stdout.strip()
484
+ if current != "main":
485
+ shown = current or "detached"
486
+ raise VaultGitError(f"{label}: HEAD={shown}; politica exige main direto")
487
+
488
+
489
+ def _ensure_branch(vault_dir: Path, expected_branch: str, label: str) -> None:
490
+ current = _git(vault_dir, ["symbolic-ref", "--short", "HEAD"], check=False).stdout.strip()
491
+ if current != expected_branch:
492
+ shown = current or "detached"
493
+ raise VaultGitError(f"{label}: HEAD={shown}; esperado {expected_branch}")
494
+
495
+
496
+ def _has_worktree_changes(vault_dir: Path) -> bool:
497
+ return bool(_git(vault_dir, ["status", "--porcelain=v1"]).stdout.strip())
498
+
499
+
500
+ def _has_staged_changes(vault_dir: Path) -> bool:
501
+ return _git(vault_dir, ["diff", "--cached", "--quiet"], check=False).returncode != 0
502
+
503
+
504
+ def _run_id() -> str:
505
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ")
506
+
507
+
508
+ def _now_iso() -> str:
509
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
510
+
511
+
512
+ def _slug(value: str, *, lower: bool = False) -> str:
513
+ normalized = value.strip()
514
+ if lower:
515
+ normalized = normalized.lower()
516
+ normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", normalized)
517
+ normalized = re.sub(r"-+", "-", normalized).strip("-._")
518
+ return normalized or "run"
519
+
520
+
521
+ def _parallel_run_id(raw_run_id: str | None) -> str:
522
+ return _slug(raw_run_id or _run_id())
523
+
524
+
525
+ def _agent_slug(agent: str) -> str:
526
+ return _slug(agent, lower=True)
527
+
528
+
529
+ def _parallel_branch(agent: str, run_id: str) -> str:
530
+ return f"vault/{_agent_slug(agent)}/{_parallel_run_id(run_id)}"
531
+
532
+
533
+ def _validate_branch_ref(branch: str) -> None:
534
+ if branch.strip() != branch or " " in branch:
535
+ raise VaultGitError(f"vault_integrate: branch invalida, sem espacos: {branch!r}")
536
+ if not branch.startswith("vault/"):
537
+ raise VaultGitError(f"vault_integrate: branch {branch!r} deve comecar com vault/")
538
+ checked = _git_without_repo(["check-ref-format", "--branch", branch])
539
+ if checked.returncode != 0:
540
+ detail = (checked.stderr or checked.stdout).strip()
541
+ raise VaultGitError(f"vault_integrate: branch invalida {branch!r}: {detail}")
542
+
543
+
544
+ def _run_id_from_branch(branch: str) -> str:
545
+ return branch.rsplit("/", 1)[-1]
546
+
547
+
548
+ def _worktree_dir(agent: str, run_id: str) -> Path:
549
+ return _state_dir() / WORKTREE_SUBDIR / f"{_parallel_run_id(run_id)}-{_agent_slug(agent)}"
550
+
551
+
552
+ def _restore_plan_dir() -> Path:
553
+ return _state_dir() / RESTORE_PLAN_SUBDIR
554
+
555
+
556
+ def _guard_lease_dir() -> Path:
557
+ path = _state_dir() / GUARD_LEASE_SUBDIR
558
+ path.mkdir(parents=True, exist_ok=True)
559
+ return path
560
+
561
+
562
+ def _status_hash(vault_dir: Path) -> str:
563
+ status = _git(vault_dir, ["status", "--porcelain=v1"]).stdout
564
+ return "sha256:" + hashlib.sha256(status.encode("utf-8")).hexdigest()
565
+
566
+
567
+ def _guard_lease_id(agent: str, run_id: str) -> str:
568
+ return f"{_parallel_run_id(run_id)}-{_agent_slug(agent)}"
569
+
570
+
571
+ def _dt_iso(value: datetime) -> str:
572
+ return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")
573
+
574
+
575
+ def _write_guard_lease(vault_dir: Path, *, agent: str, workflow: str, run_id: str) -> dict[str, object]:
576
+ created = datetime.now(UTC)
577
+ lease_id = _guard_lease_id(agent, run_id)
578
+ path = _guard_lease_dir() / f"{lease_id}.json"
579
+ payload: dict[str, object] = {
580
+ "schema": "medical-notes-workbench.vault-guard-lease.v1",
581
+ "lease_id": lease_id,
582
+ "vault_dir": str(vault_dir),
583
+ "agent": _agent_slug(agent),
584
+ "workflow": workflow,
585
+ "run_id": _parallel_run_id(run_id),
586
+ "status": "active",
587
+ "created_at": _dt_iso(created),
588
+ "expires_at": _dt_iso(created + timedelta(minutes=GUARD_LEASE_TTL_MINUTES)),
589
+ "initial_head": _head(vault_dir),
590
+ "initial_status_hash": _status_hash(vault_dir),
591
+ }
592
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
593
+ return {
594
+ "status": "active",
595
+ "lease_id": lease_id,
596
+ "path": str(path),
597
+ "expires_at": payload["expires_at"],
598
+ }
599
+
600
+
601
+ def _close_guard_lease(vault_dir: Path, *, agent: str, run_id: str) -> dict[str, object]:
602
+ lease_id = _guard_lease_id(agent, run_id)
603
+ path = _guard_lease_dir() / f"{lease_id}.json"
604
+ if not path.is_file():
605
+ return {"status": "missing", "lease_id": lease_id, "path": str(path)}
606
+ try:
607
+ payload = json.loads(path.read_text(encoding="utf-8"))
608
+ except (OSError, json.JSONDecodeError):
609
+ payload = {}
610
+ if not isinstance(payload, dict):
611
+ payload = {}
612
+ payload.update(
613
+ {
614
+ "schema": "medical-notes-workbench.vault-guard-lease.v1",
615
+ "lease_id": lease_id,
616
+ "vault_dir": str(vault_dir),
617
+ "agent": _agent_slug(agent),
618
+ "run_id": _parallel_run_id(run_id),
619
+ "status": "closed",
620
+ "closed_at": _now_iso(),
621
+ "final_head": _head(vault_dir),
622
+ "final_status_hash": _status_hash(vault_dir),
623
+ }
624
+ )
625
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
626
+ return {"status": "closed", "lease_id": lease_id, "path": str(path)}
627
+
628
+
629
+ def _active_guard_leases(vault_dir: Path) -> list[dict[str, object]]:
630
+ lease_dir = _guard_lease_dir()
631
+ now = datetime.now(UTC)
632
+ leases: list[dict[str, object]] = []
633
+ for path in sorted(lease_dir.glob("*.json")):
634
+ try:
635
+ payload = json.loads(path.read_text(encoding="utf-8"))
636
+ except (OSError, json.JSONDecodeError):
637
+ continue
638
+ if not isinstance(payload, dict) or payload.get("status") != "active":
639
+ continue
640
+ if _norm_path(Path(str(payload.get("vault_dir") or ""))) != _norm_path(vault_dir):
641
+ continue
642
+ expires_raw = str(payload.get("expires_at") or "")
643
+ try:
644
+ expires_at = datetime.fromisoformat(expires_raw.replace("Z", "+00:00"))
645
+ except ValueError:
646
+ continue
647
+ if expires_at <= now:
648
+ continue
649
+ leases.append(
650
+ {
651
+ "lease_id": str(payload.get("lease_id") or path.stem),
652
+ "vault_dir": str(payload.get("vault_dir") or ""),
653
+ "agent": str(payload.get("agent") or ""),
654
+ "workflow": str(payload.get("workflow") or ""),
655
+ "run_id": str(payload.get("run_id") or ""),
656
+ "status": "active",
657
+ "created_at": str(payload.get("created_at") or ""),
658
+ "expires_at": expires_raw,
659
+ "path": str(path),
660
+ }
661
+ )
662
+ return leases
663
+
664
+
665
+ def _run_finish_run_id(vault_dir: Path, *, agent: str, workflow: str, run_id: str | None) -> RunFinishRunIdResolution:
666
+ if run_id:
667
+ requested = _parallel_run_id(run_id)
668
+ agent_slug = _agent_slug(agent)
669
+ matches = [
670
+ lease
671
+ for lease in _active_guard_leases(vault_dir)
672
+ if str(lease.get("agent") or "") == agent_slug and str(lease.get("workflow") or "") == workflow
673
+ ]
674
+ if not matches:
675
+ return RunFinishRunIdResolution(run_id=requested, requested_run_id=requested)
676
+ if any(str(lease.get("run_id") or "") == requested for lease in matches):
677
+ return RunFinishRunIdResolution(run_id=requested, requested_run_id=requested)
678
+ if len(matches) == 1:
679
+ recovered = str(matches[0].get("run_id") or matches[0].get("lease_id") or _run_id())
680
+ return RunFinishRunIdResolution(
681
+ run_id=recovered,
682
+ requested_run_id=requested,
683
+ auto_recovered=True,
684
+ recovery_reason="single_active_guard_lease",
685
+ )
686
+ run_ids = ", ".join(str(lease.get("run_id") or lease.get("lease_id") or "") for lease in matches)
687
+ raise VaultGitError(
688
+ "vault_run_finish: --run-id nao corresponde a nenhuma lease ativa deste agente/workflow.",
689
+ status="blocked_guard_lease_mismatch",
690
+ blocked_reason="guard_lease_mismatch",
691
+ next_action=(
692
+ "repetir run-finish com o --run-id literal retornado por run-start; "
693
+ f"lease ativa encontrada: {run_ids}"
694
+ ),
695
+ )
696
+ agent_slug = _agent_slug(agent)
697
+ matches = [
698
+ lease
699
+ for lease in _active_guard_leases(vault_dir)
700
+ if str(lease.get("agent") or "") == agent_slug and str(lease.get("workflow") or "") == workflow
701
+ ]
702
+ if len(matches) == 1:
703
+ return RunFinishRunIdResolution(run_id=str(matches[0].get("run_id") or matches[0].get("lease_id") or _run_id()))
704
+ if len(matches) > 1:
705
+ run_ids = ", ".join(str(lease.get("run_id") or lease.get("lease_id") or "") for lease in matches)
706
+ raise VaultGitError(
707
+ "vault_run_finish: mais de uma lease ativa corresponde a este agente/workflow.",
708
+ status="blocked_ambiguous_guard_lease",
709
+ blocked_reason="ambiguous_guard_lease",
710
+ next_action=f"repetir run-finish com --run-id de uma destas leases: {run_ids}",
711
+ )
712
+ raise VaultGitError(
713
+ "vault_run_finish: nenhuma lease ativa encontrada para este agente/workflow.",
714
+ status="blocked_guard_lease_missing",
715
+ blocked_reason="guard_lease_missing",
716
+ next_action="abrir run-start antes da mutação ou repetir run-finish com o --run-id correto",
717
+ )
718
+
719
+
720
+ def _empty_run_id_next_action(vault_dir: Path, *, agent: str, workflow: str) -> str:
721
+ agent_slug = _agent_slug(agent)
722
+ run_ids = [
723
+ str(lease.get("run_id") or lease.get("lease_id") or "")
724
+ for lease in _active_guard_leases(vault_dir)
725
+ if str(lease.get("agent") or "") == agent_slug and str(lease.get("workflow") or "") == workflow
726
+ ]
727
+ visible_ids = [run_id for run_id in run_ids if run_id]
728
+ if visible_ids:
729
+ return (
730
+ "repetir run-finish com o --run-id retornado por run-start; "
731
+ f"lease ativa encontrada: {', '.join(visible_ids)}"
732
+ )
733
+ return (
734
+ "repetir run-finish depois de ler o run_id retornado por run-start; "
735
+ "omita --run-id somente quando houver uma única lease ativa para este agente/workflow"
736
+ )
737
+
738
+
739
+ def _run_finish_next_step(*, agent: str, workflow: str, run_id: str, title: str | None = None) -> dict[str, object]:
740
+ return {
741
+ "schema": "medical-notes-workbench.vault-run-finish-next-step.v1",
742
+ "command_family": "run-finish",
743
+ "agent": _agent_slug(agent),
744
+ "workflow": workflow,
745
+ "run_id": run_id,
746
+ "title": title or _default_run_finish_title(workflow),
747
+ "arguments": [
748
+ "--agent",
749
+ _agent_slug(agent),
750
+ "--workflow",
751
+ workflow,
752
+ "--run-id",
753
+ run_id,
754
+ "--title",
755
+ title or _default_run_finish_title(workflow),
756
+ "--public-json",
757
+ "--json",
758
+ ],
759
+ "agent_instruction": (
760
+ "Use este run_id exatamente como esta; nao remova hifens, nao converta para o run_id do workflow "
761
+ "e nao derive outro identificador."
762
+ ),
763
+ }
764
+
765
+
766
+ def run_guard_status(args: argparse.Namespace) -> int:
767
+ vault_dir = resolve_vault_dir(args.vault_dir)
768
+ validate_vault(vault_dir)
769
+ leases = _active_guard_leases(vault_dir)
770
+ payload: dict[str, object] = {
771
+ "schema": "medical-notes-workbench.vault-guard-status.v1",
772
+ "status": "completed",
773
+ "vault_dir": str(vault_dir),
774
+ "active_count": len(leases),
775
+ "leases": leases,
776
+ }
777
+ _emit(args, payload, "")
778
+ return 0
779
+
780
+
781
+ def _restore_point_label(workflow: str, *, when: str) -> str:
782
+ if when == "before":
783
+ return f"Ponto de restauração antes de {workflow}"
784
+ if when == "after":
785
+ return f"Ponto de restauração depois de {workflow}"
786
+ return f"Ponto de restauração de {workflow}"
787
+
788
+
789
+ def _normalize_workflow_name(workflow: str) -> str:
790
+ normalized = workflow.strip()
791
+ if normalized.startswith("/"):
792
+ return normalized
793
+ if normalized.startswith("mednotes:"):
794
+ return f"/{normalized}"
795
+ if normalized.startswith("mednotes-"):
796
+ return f"/mednotes:{normalized.removeprefix('mednotes-')}"
797
+ if normalized in {"flashcards", "report"}:
798
+ return f"/{normalized}"
799
+ return normalized
800
+
801
+
802
+ def _default_run_finish_title(workflow: str) -> str:
803
+ return f"Resultado de {workflow}"
804
+
805
+
806
+ def _head(vault_dir: Path) -> str:
807
+ return _git(vault_dir, ["rev-parse", "HEAD"]).stdout.strip()
808
+
809
+
810
+ def _short_sha(vault_dir: Path, ref: str = "HEAD") -> str:
811
+ return _git(vault_dir, ["rev-parse", "--short", ref]).stdout.strip()
812
+
813
+
814
+ def _format_block(title: str, lines: list[str]) -> str:
815
+ if not lines:
816
+ return f"{title}\n- nenhum"
817
+ return title + "\n" + "\n".join(f"- {line}" for line in lines)
818
+
819
+
820
+ def _sentence(text: str, fallback: str) -> str:
821
+ clean = text.strip()
822
+ if not clean:
823
+ clean = fallback
824
+ return clean if clean.endswith((".", "!", "?")) else f"{clean}."
825
+
826
+
827
+ def _is_obsidian_operational_path(path: str) -> bool:
828
+ clean = path.strip().strip('"')
829
+ return clean == ".obsidian" or clean.startswith(".obsidian/")
830
+
831
+
832
+ def _status_paths(line: str) -> list[str]:
833
+ raw = line[3:].strip() if len(line) > 3 else line.strip()
834
+ if not raw:
835
+ return []
836
+ return [part.strip().strip('"') for part in raw.split(" -> ") if part.strip()]
837
+
838
+
839
+ def _split_status_for_commit_doc(lines: list[str]) -> tuple[list[str], list[str]]:
840
+ wiki: list[str] = []
841
+ obsidian: list[str] = []
842
+ for line in lines:
843
+ paths = _status_paths(line)
844
+ target = obsidian if any(_is_obsidian_operational_path(path) for path in paths) else wiki
845
+ target.append(line)
846
+ return wiki, obsidian
847
+
848
+
849
+ def _split_diffstat_for_commit_doc(lines: list[str]) -> tuple[list[str], list[str]]:
850
+ wiki: list[str] = []
851
+ obsidian: list[str] = []
852
+ for line in lines:
853
+ if "|" not in line:
854
+ continue
855
+ path = line.split("|", 1)[0].strip()
856
+ target = obsidian if _is_obsidian_operational_path(path) else wiki
857
+ target.append(line)
858
+ return wiki, obsidian
859
+
860
+
861
+ def _human_status_line(line: str) -> str:
862
+ code = line[:2]
863
+ paths = _status_paths(line)
864
+ if not paths:
865
+ return line.strip()
866
+ path_text = " -> ".join(paths)
867
+ if "R" in code:
868
+ action = "renomeada/movida"
869
+ elif "A" in code or "?" in code:
870
+ action = "criada"
871
+ elif "D" in code:
872
+ action = "removida"
873
+ elif "M" in code:
874
+ action = "alterada"
875
+ else:
876
+ action = "atualizada"
877
+ return f"{action}: {path_text}"
878
+
879
+
880
+ def _wiki_change_lines_for_delivery_record(vault_dir: Path) -> list[str]:
881
+ status = _git(vault_dir, ["status", "--short", "--untracked-files=all"]).stdout.splitlines()
882
+ staged_stat = _git(vault_dir, ["diff", "--cached", "--stat"]).stdout.splitlines()
883
+ wiki_status, _obsidian_status = _split_status_for_commit_doc(status)
884
+ wiki_staged_stat, _obsidian_staged_stat = _split_diffstat_for_commit_doc(staged_stat)
885
+
886
+ lines = [_human_status_line(line) for line in wiki_status]
887
+ if not lines:
888
+ lines = [line.strip() for line in wiki_staged_stat]
889
+ if not lines:
890
+ return ["Mudanças da Wiki salvas neste ponto de restauração."]
891
+
892
+ limit = 12
893
+ if len(lines) <= limit:
894
+ return lines
895
+ remaining = len(lines) - limit
896
+ suffix = "item da Wiki" if remaining == 1 else "itens da Wiki"
897
+ return lines[:limit] + [f"mais {remaining} {suffix} neste ponto de restauração"]
898
+
899
+
900
+ def _default_delivery_record_for_commit(
901
+ vault_dir: Path,
902
+ *,
903
+ title: str,
904
+ workflow: str,
905
+ ) -> str:
906
+ summary = _sentence(title, "Mudanças da Wiki foram salvas em um ponto de restauração")
907
+ wiki_lines = _wiki_change_lines_for_delivery_record(vault_dir)
908
+ workflow_text = workflow.strip() or "workflow atual"
909
+ sections = [
910
+ "Registro de entrega",
911
+ "",
912
+ "Em uma frase:",
913
+ f"- {summary}",
914
+ "",
915
+ "O que mudou para você:",
916
+ *[f"- {line}" for line in wiki_lines],
917
+ "",
918
+ "Como conferir:",
919
+ f"- Abra as notas listadas no Obsidian e confira o resultado de {workflow_text}.",
920
+ "- Use /mednotes:history se precisar revisar ou restaurar este ponto.",
921
+ "",
922
+ "Pontos de atenção:",
923
+ "- Este resumo cobre a Wiki; arquivos operacionais do Obsidian ficam nos detalhes abaixo quando existirem.",
924
+ "",
925
+ "Próxima ação:",
926
+ "- Continuar a partir do estado salvo; se algo estiver estranho, revise o ponto em /mednotes:history.",
927
+ ]
928
+ return "\n".join(sections)
929
+
930
+
931
+ def _precommit_observation(vault_dir: Path) -> str:
932
+ status = _git(vault_dir, ["status", "--short", "--untracked-files=all"]).stdout.splitlines()
933
+ unstaged_stat = _git(vault_dir, ["diff", "--stat"]).stdout.splitlines()
934
+ staged_stat = _git(vault_dir, ["diff", "--cached", "--stat"]).stdout.splitlines()
935
+ wiki_status, obsidian_status = _split_status_for_commit_doc(status)
936
+ wiki_unstaged_stat, obsidian_unstaged_stat = _split_diffstat_for_commit_doc(unstaged_stat)
937
+ wiki_staged_stat, obsidian_staged_stat = _split_diffstat_for_commit_doc(staged_stat)
938
+
939
+ sections = [
940
+ _format_block("Mudancas na Wiki observadas antes do snapshot:", wiki_status),
941
+ _format_block("Arquivos operacionais do Obsidian observados:", obsidian_status),
942
+ _format_block("Diffstat da Wiki rastreada:", wiki_unstaged_stat),
943
+ _format_block("Diffstat operacional do Obsidian:", obsidian_unstaged_stat),
944
+ ]
945
+ if staged_stat:
946
+ sections.append(_format_block("Diffstat da Wiki ja staged antes do snapshot:", wiki_staged_stat))
947
+ sections.append(_format_block("Diffstat operacional do Obsidian ja staged:", obsidian_staged_stat))
948
+ return "Alteracoes observadas antes do snapshot:\n\n" + "\n\n".join(sections)
949
+
950
+
951
+ def _operational_details_for_commit(vault_dir: Path) -> str:
952
+ status = _git(vault_dir, ["status", "--short", "--untracked-files=all"]).stdout.splitlines()
953
+ staged_stat = _git(vault_dir, ["diff", "--cached", "--stat"]).stdout.splitlines()
954
+ _wiki_status, obsidian_status = _split_status_for_commit_doc(status)
955
+ _wiki_staged_stat, obsidian_staged_stat = _split_diffstat_for_commit_doc(staged_stat)
956
+ if not obsidian_status and not obsidian_staged_stat:
957
+ return ""
958
+ sections = [
959
+ _format_block("Arquivos operacionais do Obsidian:", obsidian_status),
960
+ _format_block("Diffstat operacional do Obsidian:", obsidian_staged_stat),
961
+ ]
962
+ return "Detalhes operacionais fora da Wiki (gerado pelo script):\n\n" + "\n\n".join(sections)
963
+
964
+
965
+ def _sync_main(vault_dir: Path, label: str) -> str:
966
+ if not _origin_url(vault_dir):
967
+ return "skipped_no_remote"
968
+ fetch = _git(vault_dir, ["fetch", "origin", "main"], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS)
969
+ if fetch.returncode == 0:
970
+ rebase = _git(vault_dir, ["rebase", "origin/main"], check=False)
971
+ if rebase.returncode != 0:
972
+ _git(vault_dir, ["rebase", "--abort"], check=False)
973
+ detail = (rebase.stderr or rebase.stdout).strip()
974
+ raise VaultGitError(
975
+ f"{label}: rebase em origin/main falhou (conflito). "
976
+ f"Resolve manualmente e re-roda.\n{detail}"
977
+ )
978
+ return "synced"
979
+ print(
980
+ f"{label}: fetch origin/main falhou (rede/auth?); seguindo com base local",
981
+ file=sys.stderr,
982
+ )
983
+ return "pending_fetch_failed"
984
+
985
+
986
+ def _push_branch(vault_dir: Path, branch: str, label: str, *, required: bool) -> bool:
987
+ push = _git(vault_dir, ["push", "-u", "origin", branch], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS)
988
+ if push.returncode == 0:
989
+ return True
990
+ detail = (push.stderr or push.stdout).strip()
991
+ if required:
992
+ raise VaultGitError(f"{label}: push de {branch} falhou: {detail}")
993
+ print(
994
+ f"{label}: push falhou; commit local mantido, proximo run empurra o backlog",
995
+ file=sys.stderr,
996
+ )
997
+ return False
998
+
999
+
1000
+ def _sync_and_push(vault_dir: Path, label: str) -> str:
1001
+ sync_status = _sync_main(vault_dir, label)
1002
+ if sync_status == "skipped_no_remote":
1003
+ return sync_status
1004
+ push = _git(vault_dir, ["push", "origin", "main"], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS)
1005
+ if push.returncode != 0:
1006
+ print(
1007
+ f"{label}: push falhou; commit local mantido, proximo run empurra o backlog",
1008
+ file=sys.stderr,
1009
+ )
1010
+ return "pending_push_failed"
1011
+ return "synced"
1012
+
1013
+
1014
+ def _backup_status_payload(vault_dir: Path, context: VaultContext) -> dict[str, object]:
1015
+ if not context.origin_url:
1016
+ return {
1017
+ "backup_status": "skipped_no_remote",
1018
+ "sync_status": "skipped_no_remote",
1019
+ "local_checkpoints_pending_count": 0,
1020
+ "remote_changes_pending_count": 0,
1021
+ }
1022
+
1023
+ fetch = _git(vault_dir, ["fetch", "origin", "main"], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS)
1024
+ if fetch.returncode != 0:
1025
+ return {
1026
+ "backup_status": "unavailable",
1027
+ "sync_status": "pending_fetch_failed",
1028
+ "local_checkpoints_pending_count": None,
1029
+ "remote_changes_pending_count": None,
1030
+ }
1031
+
1032
+ counts = _git(
1033
+ vault_dir,
1034
+ ["rev-list", "--left-right", "--count", "origin/main...HEAD"],
1035
+ check=False,
1036
+ )
1037
+ if counts.returncode != 0:
1038
+ return {
1039
+ "backup_status": "unknown",
1040
+ "sync_status": "pending_remote_state_unknown",
1041
+ "local_checkpoints_pending_count": None,
1042
+ "remote_changes_pending_count": None,
1043
+ }
1044
+
1045
+ raw_counts = counts.stdout.strip().split()
1046
+ remote_pending = int(raw_counts[0]) if len(raw_counts) >= 1 else 0
1047
+ local_pending = int(raw_counts[1]) if len(raw_counts) >= 2 else 0
1048
+ if local_pending and remote_pending:
1049
+ backup_status = "diverged"
1050
+ elif local_pending:
1051
+ backup_status = "local_checkpoints_pending"
1052
+ elif remote_pending:
1053
+ backup_status = "remote_changes_pending"
1054
+ else:
1055
+ backup_status = "synced"
1056
+ return {
1057
+ "backup_status": backup_status,
1058
+ "sync_status": "synced" if backup_status == "synced" else "pending",
1059
+ "local_checkpoints_pending_count": local_pending,
1060
+ "remote_changes_pending_count": remote_pending,
1061
+ }
1062
+
1063
+
1064
+ def _valid_git_identity(name: str, email: str) -> bool:
1065
+ return bool(name and email and "\n" not in name and "\n" not in email and "@" in email)
1066
+
1067
+
1068
+ def _explicit_git_identity(name: str, email: str, *, source: str) -> GitIdentity:
1069
+ if not _valid_git_identity(name, email):
1070
+ raise VaultGitError(f"identidade Git invalida: {name!r} <{email!r}>")
1071
+ return GitIdentity(name=name, email=email, source=source)
1072
+
1073
+
1074
+ def _native_git_identity_from_env() -> GitIdentity | None:
1075
+ author_name = os.environ.get("GIT_AUTHOR_NAME", "").strip()
1076
+ author_email = os.environ.get("GIT_AUTHOR_EMAIL", "").strip()
1077
+ if _valid_git_identity(author_name, author_email):
1078
+ return GitIdentity(name=author_name, email=author_email, source="native")
1079
+
1080
+ committer_name = os.environ.get("GIT_COMMITTER_NAME", "").strip()
1081
+ committer_email = os.environ.get("GIT_COMMITTER_EMAIL", "").strip()
1082
+ if _valid_git_identity(committer_name, committer_email):
1083
+ return GitIdentity(name=committer_name, email=committer_email, source="native")
1084
+ return None
1085
+
1086
+
1087
+ def _git_identities_path() -> Path:
1088
+ return _state_dir() / GIT_IDENTITIES_FILE
1089
+
1090
+
1091
+ def _read_git_identities() -> dict[str, object]:
1092
+ path = _git_identities_path()
1093
+ if not path.is_file():
1094
+ return {"schema": "medical-notes-workbench.vault-git-identities.v1", "identities": {}}
1095
+ try:
1096
+ data = json.loads(path.read_text(encoding="utf-8"))
1097
+ except (OSError, json.JSONDecodeError):
1098
+ return {"schema": "medical-notes-workbench.vault-git-identities.v1", "identities": {}}
1099
+ if not isinstance(data, dict):
1100
+ return {"schema": "medical-notes-workbench.vault-git-identities.v1", "identities": {}}
1101
+ identities = data.get("identities")
1102
+ if not isinstance(identities, dict):
1103
+ data["identities"] = {}
1104
+ data["schema"] = "medical-notes-workbench.vault-git-identities.v1"
1105
+ return data
1106
+
1107
+
1108
+ def _configured_git_identity(agent: str) -> GitIdentity | None:
1109
+ data = _read_git_identities()
1110
+ identities = data.get("identities")
1111
+ if not isinstance(identities, dict):
1112
+ return None
1113
+ entry = identities.get(_agent_slug(agent))
1114
+ if not isinstance(entry, dict):
1115
+ return None
1116
+ name = str(entry.get("name") or "").strip()
1117
+ email = str(entry.get("email") or "").strip()
1118
+ if not _valid_git_identity(name, email):
1119
+ return None
1120
+ return GitIdentity(name=name, email=email, source="configured")
1121
+
1122
+
1123
+ def _persist_git_identity(agent: str, identity: GitIdentity) -> None:
1124
+ if identity.source != "native":
1125
+ return
1126
+ data = _read_git_identities()
1127
+ identities = data.setdefault("identities", {})
1128
+ if not isinstance(identities, dict):
1129
+ identities = {}
1130
+ data["identities"] = identities
1131
+ identities[_agent_slug(agent)] = {
1132
+ "name": identity.name,
1133
+ "email": identity.email,
1134
+ "captured_from": "native",
1135
+ "updated_at": _now_iso(),
1136
+ }
1137
+ path = _git_identities_path()
1138
+ path.parent.mkdir(parents=True, exist_ok=True)
1139
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1140
+
1141
+
1142
+ def _fallback_git_identity(agent: str) -> GitIdentity:
1143
+ slug = _agent_slug(agent)
1144
+ return GitIdentity(name=slug, email=f"{slug}@medical-notes", source="fallback")
1145
+
1146
+
1147
+ def _resolve_git_identity(agent: str) -> GitIdentity:
1148
+ native = _native_git_identity_from_env()
1149
+ if native:
1150
+ _persist_git_identity(agent, native)
1151
+ return native
1152
+ configured = _configured_git_identity(agent)
1153
+ if configured:
1154
+ return configured
1155
+ return _fallback_git_identity(agent)
1156
+
1157
+
1158
+ def _git_identity_env(identity: GitIdentity) -> dict[str, str]:
1159
+ return {
1160
+ "GIT_AUTHOR_NAME": identity.name,
1161
+ "GIT_AUTHOR_EMAIL": identity.email,
1162
+ "GIT_COMMITTER_NAME": identity.name,
1163
+ "GIT_COMMITTER_EMAIL": identity.email,
1164
+ }
1165
+
1166
+
1167
+ def _add_git_identity_payload(payload: dict[str, object], identity: GitIdentity) -> None:
1168
+ payload["git_identity_source"] = identity.source
1169
+ payload["git_author"] = f"{identity.name} <{identity.email}>"
1170
+ payload["git_identity_github_attribution"] = _git_identity_github_attribution(identity)
1171
+
1172
+
1173
+ def _git_identity_github_attribution(identity: GitIdentity) -> dict[str, object]:
1174
+ email = identity.email.strip()
1175
+ lower = email.lower()
1176
+ if lower.endswith("@medical-notes"):
1177
+ return {
1178
+ "status": "local_fallback_not_github",
1179
+ "github_profile_link_expected": False,
1180
+ "human_message": (
1181
+ "Autoria operacional salva. No GitHub, este fallback local nao vira "
1182
+ "autor clicavel com avatar."
1183
+ ),
1184
+ "next_action": (
1185
+ "configure a identidade Git nativa do agente/TUI com um email associado "
1186
+ "a uma conta GitHub real ou bot antes do proximo commit"
1187
+ ),
1188
+ }
1189
+
1190
+ noreply = re.fullmatch(r"(?:(\d+)\+)?([^@]+)@users\.noreply\.github\.com", lower)
1191
+ if noreply:
1192
+ numeric_id = noreply.group(1)
1193
+ if numeric_id:
1194
+ return {
1195
+ "status": "github_noreply_with_numeric_user_id",
1196
+ "github_profile_link_expected": True,
1197
+ "human_message": (
1198
+ "Autoria GitHub reconhecivel: o email no-reply tem ID numerico "
1199
+ "de usuario, entao o GitHub deve conseguir associar avatar e link."
1200
+ ),
1201
+ "next_action": "nenhuma acao necessaria para atribuir visualmente no GitHub",
1202
+ }
1203
+ return {
1204
+ "status": "github_noreply_without_numeric_user_id",
1205
+ "github_profile_link_expected": False,
1206
+ "human_message": (
1207
+ "Autoria Git salva, mas este no-reply generico pode aparecer no GitHub "
1208
+ "sem avatar e sem autor clicavel."
1209
+ ),
1210
+ "next_action": (
1211
+ "configure o setup GitHub nativo do agente/TUI com o no-reply exato "
1212
+ "da conta GitHub do agente/bot, no formato ID+login@users.noreply.github.com"
1213
+ ),
1214
+ }
1215
+
1216
+ return {
1217
+ "status": "custom_email_must_be_verified_on_github",
1218
+ "github_profile_link_expected": False,
1219
+ "human_message": (
1220
+ "Autoria Git salva. Para o GitHub mostrar avatar, link e filtro por autor, "
1221
+ "este email precisa estar verificado em uma conta GitHub."
1222
+ ),
1223
+ "next_action": (
1224
+ "confirme que o email configurado para o agente/TUI pertence a uma conta "
1225
+ "GitHub real ou bot; se quiser privacidade, use o no-reply oficial dessa conta"
1226
+ ),
1227
+ }
1228
+
1229
+
1230
+ def _commit(
1231
+ vault_dir: Path,
1232
+ *,
1233
+ title: str,
1234
+ messages: list[str],
1235
+ identity: GitIdentity,
1236
+ ) -> GitIdentity:
1237
+ message_parts = [title.rstrip()]
1238
+ message_parts.extend(message.rstrip() for message in messages if message.strip())
1239
+ commit_message = "\n\n".join(message_parts).rstrip() + "\n"
1240
+ message_file_path: str | None = None
1241
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="\n", delete=False) as message_file:
1242
+ message_file.write(commit_message)
1243
+ message_file_path = message_file.name
1244
+ args = [
1245
+ "-c",
1246
+ f"user.name={identity.name}",
1247
+ "-c",
1248
+ f"user.email={identity.email}",
1249
+ "commit",
1250
+ "--cleanup=verbatim",
1251
+ "-F",
1252
+ message_file_path,
1253
+ ]
1254
+ try:
1255
+ _git(vault_dir, args, extra_env=_git_identity_env(identity))
1256
+ finally:
1257
+ if message_file_path:
1258
+ Path(message_file_path).unlink(missing_ok=True)
1259
+ return identity
1260
+
1261
+
1262
+ def _snapshot_dirty_main(
1263
+ vault_dir: Path,
1264
+ *,
1265
+ agent: str,
1266
+ workflow: str,
1267
+ run_id: str | None = None,
1268
+ restore_point_label: str | None = None,
1269
+ ) -> str | None:
1270
+ if not _has_worktree_changes(vault_dir):
1271
+ return None
1272
+
1273
+ actual_run_id = run_id or _run_id()
1274
+ observation = _precommit_observation(vault_dir)
1275
+ _git(vault_dir, ["add", "-A"])
1276
+ restore_trailers = ""
1277
+ if restore_point_label:
1278
+ restore_trailers = (
1279
+ "\n"
1280
+ "Restore-Point: before-run\n"
1281
+ f"Restore-Point-Label: {restore_point_label}"
1282
+ )
1283
+ body = (
1284
+ "Capturado automaticamente para isolar mutacoes do humano das que o agente\n"
1285
+ "fara a seguir. Conteudo pode ser edicao manual no Obsidian, sincronizacao de\n"
1286
+ "plugin, ou trabalho em andamento.\n\n"
1287
+ f"{observation}\n\n"
1288
+ "Agent: snapshot\n"
1289
+ "Workflow: pre-agent-snapshot\n"
1290
+ f"Run-Id: {actual_run_id}\n"
1291
+ f"Triggered-By-Agent: {agent}\n"
1292
+ f"Triggered-By-Workflow: {workflow}"
1293
+ f"{restore_trailers}"
1294
+ )
1295
+ title = f"snapshot: estado antes de {agent} rodar {workflow}"
1296
+ _commit(
1297
+ vault_dir,
1298
+ title=title,
1299
+ messages=[body],
1300
+ identity=_explicit_git_identity("snapshot", "snapshot@medical-notes", source="fallback"),
1301
+ )
1302
+ return _git(vault_dir, ["rev-parse", "--short", "HEAD"]).stdout.strip()
1303
+
1304
+
1305
+ def _body_file_text(path_value: str | None, label: str) -> str:
1306
+ if not path_value:
1307
+ return ""
1308
+ body_path = Path(path_value).expanduser()
1309
+ if not body_path.is_file():
1310
+ raise VaultGitError(f"{label}: --body-file {body_path} nao existe")
1311
+ return body_path.read_text(encoding="utf-8").rstrip()
1312
+
1313
+
1314
+ def _emit(args: argparse.Namespace, payload: dict[str, object], text: str) -> None:
1315
+ if getattr(args, "json", False) or getattr(args, "public_json", False):
1316
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
1317
+ return
1318
+ print(text)
1319
+
1320
+
1321
+ def _public_run_finish_payload(payload: dict[str, object]) -> dict[str, object]:
1322
+ guard_lease = payload.get("guard_lease")
1323
+ guard_status = guard_lease.get("status") if isinstance(guard_lease, dict) else ""
1324
+ sync_status = str(payload.get("sync_status") or "")
1325
+ backup_online = bool(payload.get("backup_online"))
1326
+ message = "Proteção do vault encerrada; ponto de restauração disponível."
1327
+ if sync_status == "synced":
1328
+ message += " Backup online conferido."
1329
+ elif sync_status == "skipped_no_remote":
1330
+ message += " Backup online pendente."
1331
+ elif sync_status.startswith("pending_"):
1332
+ message += " Backup online pendente; proteção local válida."
1333
+ return {
1334
+ "schema": "medical-notes-workbench.vault-run-finish-public.v1",
1335
+ "status": payload.get("status") or "",
1336
+ "agent": payload.get("agent") or "",
1337
+ "workflow": payload.get("workflow") or "",
1338
+ "backup_online": backup_online,
1339
+ "sync_status": sync_status,
1340
+ "human_message": message,
1341
+ "version_control_safety": {
1342
+ "resource_guard_active": guard_status != "closed",
1343
+ "run_finish_seen": True,
1344
+ "restore_point_after": bool(payload.get("restore_point_id")),
1345
+ "backup_online": backup_online,
1346
+ "sync_status": sync_status,
1347
+ },
1348
+ }
1349
+
1350
+
1351
+ def _print_context(label: str, context: VaultContext) -> None:
1352
+ origin = context.origin_url or "backup-online-pendente"
1353
+ print(f"{label}: vault={context.vault_dir} origin={origin}")
1354
+
1355
+
1356
+ def _run_cmd(
1357
+ args: list[str],
1358
+ *,
1359
+ cwd: Path | None = None,
1360
+ timeout: int = GIT_PROBE_TIMEOUT_SECONDS,
1361
+ capture: bool = True,
1362
+ ) -> subprocess.CompletedProcess[str]:
1363
+ env = os.environ.copy()
1364
+ env.setdefault("GIT_TERMINAL_PROMPT", "0")
1365
+ command = _subprocess_command(args, env)
1366
+ try:
1367
+ return subprocess.run(
1368
+ command,
1369
+ cwd=cwd,
1370
+ text=True,
1371
+ **SUBPROCESS_TEXT_KWARGS,
1372
+ capture_output=capture,
1373
+ env=env,
1374
+ check=False,
1375
+ timeout=timeout,
1376
+ )
1377
+ except FileNotFoundError as exc:
1378
+ raise VaultGitError(f"{args[0]} nao encontrado") from exc
1379
+ except subprocess.TimeoutExpired:
1380
+ return _timeout_result(command, timeout)
1381
+
1382
+
1383
+ def _write_state_file(name: str, value: str) -> Path:
1384
+ state = _state_dir()
1385
+ state.mkdir(parents=True, exist_ok=True)
1386
+ path = state / name
1387
+ path.write_text(value.rstrip() + "\n", encoding="utf-8")
1388
+ return path
1389
+
1390
+
1391
+ def _local_setup_message(
1392
+ status: str,
1393
+ blocked_reason: str | None = None,
1394
+ *,
1395
+ local_changes_present: bool = False,
1396
+ ) -> str:
1397
+ if status == "ready":
1398
+ message = "Proteção local pronta e backup online conectado."
1399
+ elif status == "awaiting_remote_confirmation":
1400
+ message = (
1401
+ "Proteção local pronta. Posso criar um repositório privado para ativar "
1402
+ "o backup online, mas preciso da sua confirmação."
1403
+ )
1404
+ elif status == "blocked_missing_git":
1405
+ message = "Não consegui ativar a proteção local: é preciso instalar Git primeiro."
1406
+ elif status == "blocked_wrong_repo_root":
1407
+ message = "Não alterei nada: a pasta escolhida está dentro de outro repositório."
1408
+ elif status == "blocked_branch_confirmation_required":
1409
+ message = (
1410
+ "Não alterei nada: o vault já usa uma branch diferente de main. "
1411
+ "Preciso de confirmação antes de ajustar isso."
1412
+ )
1413
+ elif blocked_reason == "github_login_required":
1414
+ message = (
1415
+ "Proteção local pronta. Para ativar o backup online, escolha entrar "
1416
+ "na sua conta do GitHub."
1417
+ )
1418
+ elif blocked_reason == "github_cli_missing":
1419
+ message = (
1420
+ "Proteção local pronta. Para ativar o backup online depois, instale o "
1421
+ "GitHub CLI e rode o setup novamente."
1422
+ )
1423
+ else:
1424
+ message = (
1425
+ "Proteção local pronta. Backup online pendente; rode o setup novamente "
1426
+ "depois de corrigir o acesso ao GitHub."
1427
+ )
1428
+ if local_changes_present and status != "blocked_missing_git":
1429
+ message += " Não alterei mudanças locais abertas no vault."
1430
+ return message
1431
+
1432
+
1433
+ def _emit_setup(
1434
+ args: argparse.Namespace,
1435
+ *,
1436
+ status: str,
1437
+ vault_dir: Path | None,
1438
+ local_ready: bool,
1439
+ github_ready: bool,
1440
+ git_identity: GitIdentity | None = None,
1441
+ restore_point_id: str | None = None,
1442
+ restore_point_label: str | None = None,
1443
+ restore_point_status: str | None = None,
1444
+ working_tree_clean: bool | None = None,
1445
+ local_changes_present: bool | None = None,
1446
+ origin_url: str | None = None,
1447
+ proposed_private_repo: str | None = None,
1448
+ blocked_reason: str | None = None,
1449
+ next_action: str | None = None,
1450
+ human_decision_required: bool = False,
1451
+ human_decision_packet: VaultHumanDecisionPacket | None = None,
1452
+ current_branch: str | None = None,
1453
+ return_code: int = 0,
1454
+ ) -> int:
1455
+ changes_present = bool(local_changes_present)
1456
+ message = _local_setup_message(status, blocked_reason, local_changes_present=changes_present)
1457
+ payload: dict[str, object] = {
1458
+ "schema": "medical-notes-workbench.vault-setup.v1",
1459
+ "status": status,
1460
+ "agent": _agent_slug(args.agent),
1461
+ "workflow": args.workflow,
1462
+ "local_ready": local_ready,
1463
+ "github_ready": github_ready,
1464
+ "human_message": message,
1465
+ "human_decision_required": human_decision_required,
1466
+ }
1467
+ if vault_dir is not None:
1468
+ payload["vault_dir"] = str(vault_dir)
1469
+ if restore_point_id:
1470
+ payload["restore_point_id"] = restore_point_id
1471
+ if restore_point_label:
1472
+ payload["restore_point_label"] = restore_point_label
1473
+ if restore_point_status:
1474
+ payload["restore_point_status"] = restore_point_status
1475
+ if working_tree_clean is not None:
1476
+ payload["working_tree_clean"] = working_tree_clean
1477
+ if local_changes_present is not None:
1478
+ payload["local_changes_present"] = local_changes_present
1479
+ if origin_url:
1480
+ payload["origin_url"] = origin_url
1481
+ if proposed_private_repo:
1482
+ payload["proposed_private_repo"] = proposed_private_repo
1483
+ if blocked_reason:
1484
+ payload["blocked_reason"] = blocked_reason
1485
+ if next_action:
1486
+ payload["next_action"] = next_action
1487
+ if human_decision_packet:
1488
+ payload["human_decision_packet"] = human_decision_packet.to_payload()
1489
+ if current_branch:
1490
+ payload["current_branch"] = current_branch
1491
+ if git_identity:
1492
+ _add_git_identity_payload(payload, git_identity)
1493
+ _emit(args, payload, message)
1494
+ return return_code
1495
+
1496
+
1497
+ def _repo_root(vault_dir: Path) -> Path | None:
1498
+ inside = _git(vault_dir, ["rev-parse", "--is-inside-work-tree"], check=False)
1499
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
1500
+ return None
1501
+ root = _git(vault_dir, ["rev-parse", "--show-toplevel"]).stdout.strip()
1502
+ return Path(root).resolve()
1503
+
1504
+
1505
+ def _ensure_local_vault_repo(vault_dir: Path, *, confirm_main_branch: str | None = None) -> bool:
1506
+ root = _repo_root(vault_dir)
1507
+ created_repo = root is None
1508
+ if root is None:
1509
+ init = _git(vault_dir, ["init", "-b", "main"], check=False)
1510
+ if init.returncode != 0:
1511
+ init = _git(vault_dir, ["init"], check=False)
1512
+ if init.returncode != 0:
1513
+ detail = (init.stderr or init.stdout).strip()
1514
+ raise VaultGitError(f"vault_setup: nao consegui criar protecao local: {detail}")
1515
+ root = _repo_root(vault_dir)
1516
+ if root is None or _norm_path(root) != _norm_path(vault_dir):
1517
+ raise VaultGitError("blocked_wrong_repo_root")
1518
+
1519
+ branch = _git(vault_dir, ["symbolic-ref", "--short", "HEAD"], check=False).stdout.strip()
1520
+ if not branch:
1521
+ raise VaultGitError("vault_setup: nao consigo preparar branch main em HEAD destacado")
1522
+ if branch != "main" and created_repo:
1523
+ _git(vault_dir, ["checkout", "-B", "main"])
1524
+ elif branch != "main" and confirm_main_branch == branch:
1525
+ _git(vault_dir, ["branch", "-M", "main"])
1526
+ elif branch != "main":
1527
+ raise VaultGitError(
1528
+ "O vault já tem proteção local, mas está em uma linha de trabalho diferente de main. "
1529
+ "Não renomeei nada sem confirmação.",
1530
+ status="blocked_branch_confirmation_required",
1531
+ blocked_reason="non_main_branch",
1532
+ next_action="confirmar no /mednotes:setup se posso ajustar a branch principal para main",
1533
+ human_decision_required=True,
1534
+ human_decision_packet=VaultHumanDecisionPacket(
1535
+ kind="confirm_main_branch",
1536
+ prompt="Posso ajustar a branch principal do vault para main?",
1537
+ options=(
1538
+ VaultHumanDecisionOption(
1539
+ label="Confirmar main",
1540
+ description="Renomeia a branch atual do vault para main e preserva o histórico existente.",
1541
+ resume_action=f"--confirm-main-branch {branch}",
1542
+ ),
1543
+ ),
1544
+ resume_action=f"--confirm-main-branch {branch}",
1545
+ current_branch=branch,
1546
+ ),
1547
+ )
1548
+ return created_repo
1549
+
1550
+
1551
+ def _has_head(vault_dir: Path) -> bool:
1552
+ return _git(vault_dir, ["rev-parse", "--verify", "HEAD"], check=False).returncode == 0
1553
+
1554
+
1555
+ def _ensure_initial_identity_marker_if_needed(vault_dir: Path) -> None:
1556
+ if _has_head(vault_dir) or _has_worktree_changes(vault_dir):
1557
+ return
1558
+ marker_path = vault_dir / VAULT_IDENTITY_MARKER
1559
+ marker_path.write_text("Managed by Medical Notes Workbench vault setup.\n", encoding="utf-8")
1560
+
1561
+
1562
+ def _prepare_setup_restore_point(
1563
+ vault_dir: Path,
1564
+ args: argparse.Namespace,
1565
+ *,
1566
+ created_repo: bool,
1567
+ ) -> SetupRestorePoint:
1568
+ has_head = _has_head(vault_dir)
1569
+ dirty = _has_worktree_changes(vault_dir)
1570
+ if created_repo or not has_head:
1571
+ _ensure_initial_identity_marker_if_needed(vault_dir)
1572
+ created = _snapshot_dirty_main(
1573
+ vault_dir,
1574
+ agent=args.agent,
1575
+ workflow=args.workflow,
1576
+ run_id=_parallel_run_id(args.run_id),
1577
+ restore_point_label="Proteção local criada a partir do estado atual",
1578
+ )
1579
+ if created:
1580
+ return SetupRestorePoint(
1581
+ restore_point_id=created,
1582
+ status="created_initial_restore_point",
1583
+ label="Proteção local criada a partir do estado atual",
1584
+ working_tree_clean=True,
1585
+ local_changes_present=False,
1586
+ )
1587
+ if _has_head(vault_dir):
1588
+ is_dirty = _has_worktree_changes(vault_dir)
1589
+ return SetupRestorePoint(
1590
+ restore_point_id=_short_sha(vault_dir),
1591
+ status="existing_history",
1592
+ label="Histórico existente preservado",
1593
+ working_tree_clean=not is_dirty,
1594
+ local_changes_present=is_dirty,
1595
+ )
1596
+ if has_head:
1597
+ status = "existing_history_with_local_changes" if dirty else "existing_history"
1598
+ label = "Histórico existente preservado; mudanças locais ainda abertas" if dirty else "Histórico existente preservado"
1599
+ return SetupRestorePoint(
1600
+ restore_point_id=_short_sha(vault_dir),
1601
+ status=status,
1602
+ label=label,
1603
+ working_tree_clean=not dirty,
1604
+ local_changes_present=dirty,
1605
+ )
1606
+ raise VaultGitError("vault_setup: nao consegui criar ponto de restauração inicial")
1607
+
1608
+
1609
+ def _github_repo_name(vault_dir: Path, explicit: str | None) -> str:
1610
+ value = explicit or vault_dir.name
1611
+ name = _slug(value, lower=True)
1612
+ return name or "medical-notes-vault"
1613
+
1614
+
1615
+ def _gh(
1616
+ args: list[str],
1617
+ *,
1618
+ timeout: int = GIT_PROBE_TIMEOUT_SECONDS,
1619
+ capture: bool = True,
1620
+ ) -> subprocess.CompletedProcess[str]:
1621
+ return _run_cmd(["gh", *args], timeout=timeout, capture=capture)
1622
+
1623
+
1624
+ def _github_login_decision_packet() -> VaultHumanDecisionPacket:
1625
+ return VaultHumanDecisionPacket(
1626
+ kind="github_login",
1627
+ prompt="Como deseja resolver o backup online do GitHub?",
1628
+ options=(
1629
+ VaultHumanDecisionOption(
1630
+ label="Entrar no GitHub (recomendado)",
1631
+ description="Abre o fluxo oficial do GitHub CLI e tenta conectar o backup online.",
1632
+ resume_action="--start-github-login",
1633
+ ),
1634
+ VaultHumanDecisionOption(
1635
+ label="Continuar local",
1636
+ description="Mantém a proteção local pronta e deixa o backup online para depois.",
1637
+ resume_action="skip_online_backup_for_now",
1638
+ ),
1639
+ ),
1640
+ )
1641
+
1642
+
1643
+ def _looks_like_github_origin(origin_url: str | None) -> bool:
1644
+ if not origin_url:
1645
+ return False
1646
+ normalized = origin_url.lower()
1647
+ return "github.com/" in normalized or normalized.startswith("git@github.com:")
1648
+
1649
+
1650
+ def _github_login_required_setup(
1651
+ args: argparse.Namespace,
1652
+ *,
1653
+ vault_dir: Path,
1654
+ git_identity: GitIdentity,
1655
+ restore: SetupRestorePoint,
1656
+ origin_url: str | None = None,
1657
+ ) -> int:
1658
+ return _emit_setup(
1659
+ args,
1660
+ status="local_ready_github_pending",
1661
+ vault_dir=vault_dir,
1662
+ local_ready=True,
1663
+ github_ready=False,
1664
+ git_identity=git_identity,
1665
+ restore_point_id=restore.restore_point_id,
1666
+ restore_point_label=restore.label,
1667
+ restore_point_status=restore.status,
1668
+ working_tree_clean=restore.working_tree_clean,
1669
+ local_changes_present=restore.local_changes_present,
1670
+ origin_url=origin_url,
1671
+ blocked_reason="github_login_required",
1672
+ next_action="usar a opção recomendada para entrar no GitHub e concluir o backup online",
1673
+ human_decision_required=True,
1674
+ human_decision_packet=_github_login_decision_packet(),
1675
+ )
1676
+
1677
+
1678
+ def _stdio_is_interactive() -> bool:
1679
+ return bool(sys.stdin.isatty() and sys.stdout.isatty())
1680
+
1681
+
1682
+ def _github_owner() -> str | None:
1683
+ result = _gh(["api", "user", "--jq", ".login"], timeout=GIT_NETWORK_TIMEOUT_SECONDS)
1684
+ if result.returncode != 0:
1685
+ return None
1686
+ return result.stdout.strip() or None
1687
+
1688
+
1689
+ def _origin_url(vault_dir: Path) -> str | None:
1690
+ origin = _git(vault_dir, ["remote", "get-url", "origin"], check=False)
1691
+ if origin.returncode != 0:
1692
+ return None
1693
+ return origin.stdout.strip() or None
1694
+
1695
+
1696
+ def _remote_access_ok(vault_dir: Path) -> bool:
1697
+ origin = _origin_url(vault_dir)
1698
+ if not origin:
1699
+ return False
1700
+ return _git(vault_dir, ["ls-remote", "origin"], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS).returncode == 0
1701
+
1702
+
1703
+ def _push_main_for_setup(vault_dir: Path) -> tuple[bool, str]:
1704
+ result = _git(vault_dir, ["push", "-u", "origin", "main"], check=False, timeout=GIT_NETWORK_TIMEOUT_SECONDS)
1705
+ if result.returncode == 0:
1706
+ return True, ""
1707
+ return False, (result.stderr or result.stdout).strip()
1708
+
1709
+
1710
+ def _create_private_remote(vault_dir: Path, repo: str) -> tuple[bool, str]:
1711
+ result = _gh(
1712
+ [
1713
+ "repo",
1714
+ "create",
1715
+ repo,
1716
+ "--private",
1717
+ "--source",
1718
+ str(vault_dir),
1719
+ "--remote",
1720
+ "origin",
1721
+ "--push",
1722
+ ],
1723
+ timeout=GIT_NETWORK_TIMEOUT_SECONDS,
1724
+ )
1725
+ if result.returncode != 0:
1726
+ return False, (result.stderr or result.stdout).strip()
1727
+ return True, (result.stdout or "").strip()
1728
+
1729
+
1730
+ def run_setup(args: argparse.Namespace) -> int:
1731
+ if shutil.which("git") is None:
1732
+ vault_dir = Path(args.vault_dir).expanduser().resolve() if args.vault_dir else None
1733
+ return _emit_setup(
1734
+ args,
1735
+ status="blocked_missing_git",
1736
+ vault_dir=vault_dir,
1737
+ local_ready=False,
1738
+ github_ready=False,
1739
+ next_action="instalar Git e rodar /mednotes:setup novamente",
1740
+ return_code=1,
1741
+ )
1742
+
1743
+ vault_dir = resolve_vault_dir(args.vault_dir)
1744
+ try:
1745
+ created_repo = _ensure_local_vault_repo(vault_dir, confirm_main_branch=args.confirm_main_branch)
1746
+ except VaultGitError as exc:
1747
+ if str(exc) == "blocked_wrong_repo_root":
1748
+ return _emit_setup(
1749
+ args,
1750
+ status="blocked_wrong_repo_root",
1751
+ vault_dir=vault_dir,
1752
+ local_ready=False,
1753
+ github_ready=False,
1754
+ blocked_reason="wrong_repo_root",
1755
+ next_action="escolher a raiz real do vault e rodar /mednotes:setup novamente",
1756
+ return_code=1,
1757
+ )
1758
+ if exc.status == "blocked_branch_confirmation_required":
1759
+ packet = exc.human_decision_packet
1760
+ current_branch = packet.current_branch if packet is not None else ""
1761
+ return _emit_setup(
1762
+ args,
1763
+ status=exc.status,
1764
+ vault_dir=vault_dir,
1765
+ local_ready=False,
1766
+ github_ready=False,
1767
+ blocked_reason=exc.blocked_reason,
1768
+ next_action=exc.next_action,
1769
+ human_decision_required=True,
1770
+ human_decision_packet=packet,
1771
+ current_branch=current_branch,
1772
+ return_code=1,
1773
+ )
1774
+ raise
1775
+
1776
+ _write_state_file("vault.path", str(vault_dir))
1777
+ restore = _prepare_setup_restore_point(vault_dir, args, created_repo=created_repo)
1778
+ git_identity = _resolve_git_identity(args.agent)
1779
+
1780
+ origin = _origin_url(vault_dir)
1781
+ if origin:
1782
+ if _remote_access_ok(vault_dir):
1783
+ pushed, push_detail = _push_main_for_setup(vault_dir)
1784
+ if pushed:
1785
+ origin = _origin_url(vault_dir) or origin
1786
+ _write_state_file("vault.remote-allowlist", origin)
1787
+ return _emit_setup(
1788
+ args,
1789
+ status="ready",
1790
+ vault_dir=vault_dir,
1791
+ local_ready=True,
1792
+ github_ready=True,
1793
+ git_identity=git_identity,
1794
+ restore_point_id=restore.restore_point_id,
1795
+ restore_point_label=restore.label,
1796
+ restore_point_status=restore.status,
1797
+ working_tree_clean=restore.working_tree_clean,
1798
+ local_changes_present=restore.local_changes_present,
1799
+ origin_url=origin,
1800
+ )
1801
+ return _emit_setup(
1802
+ args,
1803
+ status="local_ready_github_pending",
1804
+ vault_dir=vault_dir,
1805
+ local_ready=True,
1806
+ github_ready=False,
1807
+ git_identity=git_identity,
1808
+ restore_point_id=restore.restore_point_id,
1809
+ restore_point_label=restore.label,
1810
+ restore_point_status=restore.status,
1811
+ working_tree_clean=restore.working_tree_clean,
1812
+ local_changes_present=restore.local_changes_present,
1813
+ origin_url=origin,
1814
+ blocked_reason="github_push_failed",
1815
+ next_action=push_detail or "corrigir permissão/proteção do repositório e rodar /mednotes:setup novamente",
1816
+ )
1817
+ if shutil.which("gh") is not None:
1818
+ auth = _gh(["auth", "status"])
1819
+ if auth.returncode != 0 and args.start_github_login and _stdio_is_interactive():
1820
+ _gh(["auth", "login"], timeout=GITHUB_LOGIN_TIMEOUT_SECONDS, capture=False)
1821
+ auth = _gh(["auth", "status"])
1822
+ if auth.returncode != 0:
1823
+ return _github_login_required_setup(
1824
+ args,
1825
+ vault_dir=vault_dir,
1826
+ git_identity=git_identity,
1827
+ restore=restore,
1828
+ origin_url=origin,
1829
+ )
1830
+ if args.start_github_login:
1831
+ _gh(["auth", "setup-git"])
1832
+ if _remote_access_ok(vault_dir):
1833
+ pushed, push_detail = _push_main_for_setup(vault_dir)
1834
+ if pushed:
1835
+ _write_state_file("vault.remote-allowlist", origin)
1836
+ return _emit_setup(
1837
+ args,
1838
+ status="ready",
1839
+ vault_dir=vault_dir,
1840
+ local_ready=True,
1841
+ github_ready=True,
1842
+ git_identity=git_identity,
1843
+ restore_point_id=restore.restore_point_id,
1844
+ restore_point_label=restore.label,
1845
+ restore_point_status=restore.status,
1846
+ working_tree_clean=restore.working_tree_clean,
1847
+ local_changes_present=restore.local_changes_present,
1848
+ origin_url=origin,
1849
+ )
1850
+ return _emit_setup(
1851
+ args,
1852
+ status="local_ready_github_pending",
1853
+ vault_dir=vault_dir,
1854
+ local_ready=True,
1855
+ github_ready=False,
1856
+ git_identity=git_identity,
1857
+ restore_point_id=restore.restore_point_id,
1858
+ restore_point_label=restore.label,
1859
+ restore_point_status=restore.status,
1860
+ working_tree_clean=restore.working_tree_clean,
1861
+ local_changes_present=restore.local_changes_present,
1862
+ origin_url=origin,
1863
+ blocked_reason="github_push_failed",
1864
+ next_action=push_detail
1865
+ or "corrigir permissão/proteção do repositório e rodar /mednotes:setup novamente",
1866
+ )
1867
+ if shutil.which("gh") is None and _looks_like_github_origin(origin):
1868
+ return _emit_setup(
1869
+ args,
1870
+ status="local_ready_github_pending",
1871
+ vault_dir=vault_dir,
1872
+ local_ready=True,
1873
+ github_ready=False,
1874
+ git_identity=git_identity,
1875
+ restore_point_id=restore.restore_point_id,
1876
+ restore_point_label=restore.label,
1877
+ restore_point_status=restore.status,
1878
+ working_tree_clean=restore.working_tree_clean,
1879
+ local_changes_present=restore.local_changes_present,
1880
+ origin_url=origin,
1881
+ blocked_reason="github_cli_missing",
1882
+ next_action="instalar GitHub CLI para reparar o login do backup online",
1883
+ )
1884
+ decision_required = shutil.which("gh") is not None and _looks_like_github_origin(origin)
1885
+ return _emit_setup(
1886
+ args,
1887
+ status="local_ready_github_pending",
1888
+ vault_dir=vault_dir,
1889
+ local_ready=True,
1890
+ github_ready=False,
1891
+ git_identity=git_identity,
1892
+ restore_point_id=restore.restore_point_id,
1893
+ restore_point_label=restore.label,
1894
+ restore_point_status=restore.status,
1895
+ working_tree_clean=restore.working_tree_clean,
1896
+ local_changes_present=restore.local_changes_present,
1897
+ origin_url=origin,
1898
+ blocked_reason="github_remote_unreachable",
1899
+ next_action=(
1900
+ "usar a opção recomendada para reparar o login do GitHub e concluir o backup online"
1901
+ if decision_required
1902
+ else "corrigir login/rede/permissão do GitHub e rodar /mednotes:setup novamente"
1903
+ ),
1904
+ human_decision_required=decision_required,
1905
+ human_decision_packet=_github_login_decision_packet() if decision_required else None,
1906
+ )
1907
+
1908
+ if shutil.which("gh") is None:
1909
+ return _emit_setup(
1910
+ args,
1911
+ status="local_ready_github_pending",
1912
+ vault_dir=vault_dir,
1913
+ local_ready=True,
1914
+ github_ready=False,
1915
+ git_identity=git_identity,
1916
+ restore_point_id=restore.restore_point_id,
1917
+ restore_point_label=restore.label,
1918
+ restore_point_status=restore.status,
1919
+ working_tree_clean=restore.working_tree_clean,
1920
+ local_changes_present=restore.local_changes_present,
1921
+ blocked_reason="github_cli_missing",
1922
+ next_action="instalar GitHub CLI para ativar backup online",
1923
+ )
1924
+
1925
+ auth = _gh(["auth", "status"])
1926
+ if auth.returncode != 0 and args.start_github_login and _stdio_is_interactive():
1927
+ _gh(["auth", "login"], timeout=GITHUB_LOGIN_TIMEOUT_SECONDS, capture=False)
1928
+ auth = _gh(["auth", "status"])
1929
+ if auth.returncode != 0:
1930
+ return _github_login_required_setup(args, vault_dir=vault_dir, git_identity=git_identity, restore=restore)
1931
+
1932
+ owner = _github_owner()
1933
+ if not owner:
1934
+ return _emit_setup(
1935
+ args,
1936
+ status="local_ready_github_pending",
1937
+ vault_dir=vault_dir,
1938
+ local_ready=True,
1939
+ github_ready=False,
1940
+ git_identity=git_identity,
1941
+ restore_point_id=restore.restore_point_id,
1942
+ restore_point_label=restore.label,
1943
+ restore_point_status=restore.status,
1944
+ working_tree_clean=restore.working_tree_clean,
1945
+ local_changes_present=restore.local_changes_present,
1946
+ blocked_reason="github_user_unknown",
1947
+ next_action="confirmar login do GitHub e rodar /mednotes:setup novamente",
1948
+ )
1949
+
1950
+ proposed = f"{owner}/{_github_repo_name(vault_dir, args.repo_name)}"
1951
+ if args.confirm_create_remote != proposed:
1952
+ return _emit_setup(
1953
+ args,
1954
+ status="awaiting_remote_confirmation",
1955
+ vault_dir=vault_dir,
1956
+ local_ready=True,
1957
+ github_ready=False,
1958
+ git_identity=git_identity,
1959
+ restore_point_id=restore.restore_point_id,
1960
+ restore_point_label=restore.label,
1961
+ restore_point_status=restore.status,
1962
+ working_tree_clean=restore.working_tree_clean,
1963
+ local_changes_present=restore.local_changes_present,
1964
+ proposed_private_repo=proposed,
1965
+ next_action=f"confirmar criação do repositório privado {proposed}",
1966
+ human_decision_required=True,
1967
+ )
1968
+
1969
+ created, detail = _create_private_remote(vault_dir, proposed)
1970
+ origin = _origin_url(vault_dir)
1971
+ if not created or not origin or not _remote_access_ok(vault_dir):
1972
+ return _emit_setup(
1973
+ args,
1974
+ status="local_ready_github_pending",
1975
+ vault_dir=vault_dir,
1976
+ local_ready=True,
1977
+ github_ready=False,
1978
+ git_identity=git_identity,
1979
+ restore_point_id=restore.restore_point_id,
1980
+ restore_point_label=restore.label,
1981
+ restore_point_status=restore.status,
1982
+ working_tree_clean=restore.working_tree_clean,
1983
+ local_changes_present=restore.local_changes_present,
1984
+ origin_url=origin,
1985
+ proposed_private_repo=proposed,
1986
+ blocked_reason="github_remote_create_failed",
1987
+ next_action=detail or "corrigir criação do repositório privado e rodar /mednotes:setup novamente",
1988
+ )
1989
+
1990
+ _write_state_file("vault.remote-allowlist", origin)
1991
+ return _emit_setup(
1992
+ args,
1993
+ status="ready",
1994
+ vault_dir=vault_dir,
1995
+ local_ready=True,
1996
+ github_ready=True,
1997
+ git_identity=git_identity,
1998
+ restore_point_id=restore.restore_point_id,
1999
+ restore_point_label=restore.label,
2000
+ restore_point_status=restore.status,
2001
+ working_tree_clean=restore.working_tree_clean,
2002
+ local_changes_present=restore.local_changes_present,
2003
+ origin_url=origin,
2004
+ proposed_private_repo=proposed,
2005
+ )
2006
+
2007
+
2008
+ def _trailer_value(body: str, key: str) -> str:
2009
+ prefix = f"{key}:"
2010
+ for line in reversed(body.splitlines()):
2011
+ if line.startswith(prefix):
2012
+ return line[len(prefix):].strip()
2013
+ return ""
2014
+
2015
+
2016
+ def _status_entries(vault_dir: Path, base_ref: str, head_ref: str, paths: list[str]) -> list[dict[str, str]]:
2017
+ args = ["diff", "--name-status", f"{base_ref}..{head_ref}", "--"]
2018
+ args.extend(paths)
2019
+ lines = _git(vault_dir, args).stdout.splitlines()
2020
+ entries: list[dict[str, str]] = []
2021
+ for line in lines:
2022
+ parts = line.split("\t")
2023
+ if not parts:
2024
+ continue
2025
+ status = parts[0]
2026
+ if status.startswith("R") and len(parts) >= 3:
2027
+ entries.append({"status": status, "path": parts[1], "new_path": parts[2]})
2028
+ elif len(parts) >= 2:
2029
+ entries.append({"status": status, "path": parts[1]})
2030
+ return entries
2031
+
2032
+
2033
+ def _affected_files(entries: list[dict[str, str]]) -> list[str]:
2034
+ files: list[str] = []
2035
+ for entry in entries:
2036
+ path = entry.get("path", "")
2037
+ new_path = entry.get("new_path", "")
2038
+ if path:
2039
+ files.append(path)
2040
+ if new_path and new_path not in files:
2041
+ files.append(new_path)
2042
+ return files
2043
+
2044
+
2045
+ def run_precommit(args: argparse.Namespace) -> int:
2046
+ vault_dir = resolve_vault_dir(args.vault_dir)
2047
+ context = validate_vault(vault_dir)
2048
+ _print_context("vault_precommit", context)
2049
+ _ensure_main(vault_dir, "vault_precommit")
2050
+
2051
+ if not _has_worktree_changes(vault_dir):
2052
+ print("vault_precommit: working tree limpo, nada a fazer")
2053
+ return 0
2054
+
2055
+ commit_sha = _snapshot_dirty_main(vault_dir, agent=args.agent, workflow=args.workflow)
2056
+ _sync_and_push(vault_dir, "vault_precommit")
2057
+ print(f"vault_precommit: snapshot criado em {commit_sha}")
2058
+ return 0
2059
+
2060
+
2061
+ def run_commit(args: argparse.Namespace) -> int:
2062
+ vault_dir = resolve_vault_dir(args.vault_dir)
2063
+ context = validate_vault(vault_dir)
2064
+ _print_context("vault_commit", context)
2065
+ _ensure_main(vault_dir, "vault_commit")
2066
+
2067
+ _git(vault_dir, ["add", "-A"])
2068
+ if not _has_staged_changes(vault_dir):
2069
+ print("vault_commit: nada a commitar")
2070
+ return 0
2071
+
2072
+ body_prose = _body_file_text(args.body_file, "vault_commit")
2073
+ if not body_prose:
2074
+ body_prose = _default_delivery_record_for_commit(
2075
+ vault_dir,
2076
+ title=args.title,
2077
+ workflow=args.workflow,
2078
+ )
2079
+ operational_details = _operational_details_for_commit(vault_dir)
2080
+
2081
+ run_id = args.run_id or _run_id()
2082
+ trailers = [
2083
+ f"Agent: {args.agent}",
2084
+ f"Workflow: {args.workflow}",
2085
+ f"Run-Id: {run_id}",
2086
+ ]
2087
+ optional_trailers = [
2088
+ ("Tool", args.tool),
2089
+ ("Subagent", args.subagent),
2090
+ ("Trigger-Context", args.trigger_context),
2091
+ ("Receipt", args.receipt),
2092
+ ("Notes-Touched", args.notes_touched),
2093
+ ]
2094
+ for key, value in optional_trailers:
2095
+ if value:
2096
+ trailers.append(f"{key}: {value}")
2097
+
2098
+ messages = []
2099
+ if body_prose:
2100
+ messages.append(body_prose)
2101
+ if operational_details:
2102
+ messages.append(operational_details)
2103
+ messages.append("\n".join(trailers))
2104
+ _commit(
2105
+ vault_dir,
2106
+ title=args.title,
2107
+ messages=messages,
2108
+ identity=_resolve_git_identity(args.agent),
2109
+ )
2110
+ _sync_and_push(vault_dir, "vault_commit")
2111
+ commit_sha = _git(vault_dir, ["rev-parse", "--short", "HEAD"]).stdout.strip()
2112
+ print(f"vault_commit: {commit_sha}")
2113
+ return 0
2114
+
2115
+
2116
+ def run_run_start(args: argparse.Namespace) -> int:
2117
+ vault_dir = resolve_vault_dir(args.vault_dir)
2118
+ context = validate_vault(vault_dir)
2119
+ _ensure_main(vault_dir, "vault_run_start")
2120
+
2121
+ workflow = _normalize_workflow_name(args.workflow)
2122
+ run_id = _parallel_run_id(args.run_id)
2123
+ label = _restore_point_label(workflow, when="before")
2124
+ created_sha = _snapshot_dirty_main(
2125
+ vault_dir,
2126
+ agent=args.agent,
2127
+ workflow=workflow,
2128
+ run_id=run_id,
2129
+ restore_point_label=label,
2130
+ )
2131
+ sync_status = _sync_and_push(vault_dir, "vault_run_start")
2132
+ restore_point_id = created_sha or _short_sha(vault_dir)
2133
+ status = "restore_point_created" if created_sha else "restore_point_ready"
2134
+ message = "Salvei um ponto de restauração antes de começar."
2135
+ guard_lease = _write_guard_lease(vault_dir, agent=args.agent, workflow=workflow, run_id=run_id)
2136
+ payload: dict[str, object] = {
2137
+ "schema": "medical-notes-workbench.vault-run-start.v1",
2138
+ "status": status,
2139
+ "agent": _agent_slug(args.agent),
2140
+ "workflow": workflow,
2141
+ "run_id": run_id,
2142
+ "restore_point_id": restore_point_id,
2143
+ "restore_point_label": label,
2144
+ "vault_dir": str(vault_dir),
2145
+ "backup_online": context.backup_online,
2146
+ "sync_status": sync_status,
2147
+ "guard_lease": guard_lease,
2148
+ "next_finish_step": _run_finish_next_step(agent=args.agent, workflow=workflow, run_id=run_id),
2149
+ "human_message": message,
2150
+ }
2151
+ if context.origin_url:
2152
+ payload["origin_url"] = context.origin_url
2153
+ _emit(args, payload, message)
2154
+ return 0
2155
+
2156
+
2157
+ def run_run_finish(args: argparse.Namespace) -> int:
2158
+ if not str(args.workflow or "").strip():
2159
+ raise VaultGitError(
2160
+ "vault_run_finish: --workflow e obrigatorio.",
2161
+ status="blocked",
2162
+ blocked_reason="workflow_required",
2163
+ next_action=(
2164
+ "Repetir run-finish com --workflow /mednotes:fix-wiki ou o workflow publico correto; "
2165
+ "para este fluxo use: run-finish --agent gemini-cli --workflow /mednotes:fix-wiki "
2166
+ '--run-id <run_id> --title "Reparo da Wiki_Medicina" --public-json --json.'
2167
+ ),
2168
+ required_inputs=["workflow"],
2169
+ )
2170
+ workflow = _normalize_workflow_name(args.workflow)
2171
+ vault_dir = resolve_vault_dir(args.vault_dir)
2172
+ context = validate_vault(vault_dir)
2173
+ if getattr(args, "run_id_provided", False) and not str(args.run_id or "").strip():
2174
+ raise VaultGitError(
2175
+ 'vault_run_finish: --run-id foi fornecido vazio; nao use placeholder como "".',
2176
+ status="blocked_empty_run_id",
2177
+ blocked_reason="empty_run_id",
2178
+ next_action=_empty_run_id_next_action(vault_dir, agent=args.agent, workflow=workflow),
2179
+ )
2180
+ if args.branch:
2181
+ integrate_args = argparse.Namespace(
2182
+ branch=args.branch,
2183
+ agent=args.agent,
2184
+ workflow=workflow,
2185
+ run_id=args.run_id,
2186
+ vault_dir=args.vault_dir,
2187
+ json=args.json,
2188
+ semantic_output=True,
2189
+ )
2190
+ return run_integrate(integrate_args)
2191
+
2192
+ _ensure_main(vault_dir, "vault_run_finish")
2193
+
2194
+ run_id_resolution = _run_finish_run_id(vault_dir, agent=args.agent, workflow=workflow, run_id=args.run_id)
2195
+ run_id = run_id_resolution.run_id
2196
+ _git(vault_dir, ["add", "-A"])
2197
+ label = _restore_point_label(workflow, when="after")
2198
+ if not _has_staged_changes(vault_dir):
2199
+ sync_status = _sync_and_push(vault_dir, "vault_run_finish")
2200
+ guard_lease = _close_guard_lease(vault_dir, agent=args.agent, run_id=run_id)
2201
+ if run_id_resolution.auto_recovered:
2202
+ guard_lease["run_id_auto_recovered"] = True
2203
+ message = "Nenhuma mudança nova para salvar; o ponto de restauração atual continua válido."
2204
+ if sync_status == "synced":
2205
+ message += " O backup online foi conferido."
2206
+ elif sync_status == "skipped_no_remote":
2207
+ message += " O backup online ainda está pendente."
2208
+ elif sync_status.startswith("pending_"):
2209
+ message += " O backup online ficou pendente; a proteção local continua válida."
2210
+ payload: dict[str, object] = {
2211
+ "schema": "medical-notes-workbench.vault-run-finish.v1",
2212
+ "status": "no_changes",
2213
+ "agent": _agent_slug(args.agent),
2214
+ "workflow": workflow,
2215
+ "run_id": run_id,
2216
+ "restore_point_id": _short_sha(vault_dir),
2217
+ "restore_point_label": label,
2218
+ "backup_online": context.backup_online,
2219
+ "sync_status": sync_status,
2220
+ "guard_lease": guard_lease,
2221
+ "human_message": message,
2222
+ }
2223
+ if run_id_resolution.auto_recovered:
2224
+ payload["run_id_recovery"] = {
2225
+ "schema": "medical-notes-workbench.vault-run-id-recovery.v1",
2226
+ "status": "recovered",
2227
+ "requested_run_id": run_id_resolution.requested_run_id,
2228
+ "recovered_run_id": run_id,
2229
+ "reason": run_id_resolution.recovery_reason,
2230
+ }
2231
+ if context.origin_url:
2232
+ payload["origin_url"] = context.origin_url
2233
+ if getattr(args, "public_json", False):
2234
+ payload = _public_run_finish_payload(payload)
2235
+ _emit(args, payload, message)
2236
+ return 0
2237
+
2238
+ title = str(args.title or "").strip() or _default_run_finish_title(workflow)
2239
+
2240
+ body_prose = _body_file_text(args.body_file, "vault_run_finish")
2241
+ if not body_prose:
2242
+ body_prose = _default_delivery_record_for_commit(
2243
+ vault_dir,
2244
+ title=title,
2245
+ workflow=workflow,
2246
+ )
2247
+ operational_details = _operational_details_for_commit(vault_dir)
2248
+ trailers = [
2249
+ f"Agent: {_agent_slug(args.agent)}",
2250
+ f"Workflow: {workflow}",
2251
+ f"Run-Id: {run_id}",
2252
+ "Restore-Point: workflow-result",
2253
+ f"Restore-Point-Label: {label}",
2254
+ ]
2255
+ optional_trailers = [
2256
+ ("Tool", args.tool),
2257
+ ("Subagent", args.subagent),
2258
+ ("Trigger-Context", args.trigger_context),
2259
+ ("Receipt", args.receipt),
2260
+ ("Notes-Touched", args.notes_touched),
2261
+ ]
2262
+ for key, value in optional_trailers:
2263
+ if value:
2264
+ trailers.append(f"{key}: {value}")
2265
+
2266
+ messages = []
2267
+ if body_prose:
2268
+ messages.append(body_prose)
2269
+ if operational_details:
2270
+ messages.append(operational_details)
2271
+ messages.append("\n".join(trailers))
2272
+ identity = _commit(
2273
+ vault_dir,
2274
+ title=title,
2275
+ messages=messages,
2276
+ identity=_resolve_git_identity(args.agent),
2277
+ )
2278
+ sync_status = _sync_and_push(vault_dir, "vault_run_finish")
2279
+ restore_point_id = _short_sha(vault_dir)
2280
+ guard_lease = _close_guard_lease(vault_dir, agent=args.agent, run_id=run_id)
2281
+ if run_id_resolution.auto_recovered:
2282
+ guard_lease["run_id_auto_recovered"] = True
2283
+ message = "Ponto de restauração salvo com o resultado do workflow."
2284
+ payload = {
2285
+ "schema": "medical-notes-workbench.vault-run-finish.v1",
2286
+ "status": "recorded",
2287
+ "agent": _agent_slug(args.agent),
2288
+ "workflow": workflow,
2289
+ "run_id": run_id,
2290
+ "restore_point_id": restore_point_id,
2291
+ "restore_point_label": label,
2292
+ "vault_dir": str(vault_dir),
2293
+ "backup_online": context.backup_online,
2294
+ "sync_status": sync_status,
2295
+ "guard_lease": guard_lease,
2296
+ "human_message": message,
2297
+ }
2298
+ if run_id_resolution.auto_recovered:
2299
+ payload["run_id_recovery"] = {
2300
+ "schema": "medical-notes-workbench.vault-run-id-recovery.v1",
2301
+ "status": "recovered",
2302
+ "requested_run_id": run_id_resolution.requested_run_id,
2303
+ "recovered_run_id": run_id,
2304
+ "reason": run_id_resolution.recovery_reason,
2305
+ }
2306
+ _add_git_identity_payload(payload, identity)
2307
+ if context.origin_url:
2308
+ payload["origin_url"] = context.origin_url
2309
+ if getattr(args, "public_json", False):
2310
+ payload = _public_run_finish_payload(payload)
2311
+ _emit(args, payload, message)
2312
+ return 0
2313
+
2314
+
2315
+ def run_branch_start(args: argparse.Namespace) -> int:
2316
+ vault_dir = resolve_vault_dir(args.vault_dir)
2317
+ context = validate_vault(vault_dir, require_remote=True)
2318
+ if not args.json:
2319
+ _print_context("vault_branch_start", context)
2320
+ _ensure_main(vault_dir, "vault_branch_start")
2321
+
2322
+ main_snapshot = _snapshot_dirty_main(vault_dir, agent=args.agent, workflow=args.workflow)
2323
+ _sync_and_push(vault_dir, "vault_branch_start")
2324
+
2325
+ run_id = _parallel_run_id(args.run_id)
2326
+ branch = _parallel_branch(args.agent, run_id)
2327
+ _validate_branch_ref(branch)
2328
+ worktree_dir = _worktree_dir(args.agent, run_id)
2329
+ if worktree_dir.exists():
2330
+ raise VaultGitError(f"vault_branch_start: worktree ja existe: {worktree_dir}")
2331
+ worktree_dir.parent.mkdir(parents=True, exist_ok=True)
2332
+
2333
+ branch_exists = _git(vault_dir, ["show-ref", "--verify", f"refs/heads/{branch}"], check=False)
2334
+ if branch_exists.returncode == 0:
2335
+ raise VaultGitError(f"vault_branch_start: branch local ja existe: {branch}")
2336
+ remote_exists = _git(vault_dir, ["ls-remote", "--exit-code", "--heads", "origin", branch], check=False)
2337
+ if remote_exists.returncode == 0:
2338
+ raise VaultGitError(f"vault_branch_start: branch remota ja existe: {branch}")
2339
+
2340
+ _git(vault_dir, ["worktree", "add", "-b", branch, str(worktree_dir), "HEAD"])
2341
+ payload: dict[str, object] = {
2342
+ "schema": "medical-notes-workbench.vault-branch-start.v1",
2343
+ "status": "created",
2344
+ "agent": _agent_slug(args.agent),
2345
+ "workflow": args.workflow,
2346
+ "run_id": run_id,
2347
+ "branch": branch,
2348
+ "worktree_dir": str(worktree_dir),
2349
+ "vault_dir": str(vault_dir),
2350
+ "origin_url": context.origin_url,
2351
+ "main_snapshot": main_snapshot,
2352
+ }
2353
+ _emit(
2354
+ args,
2355
+ payload,
2356
+ f"vault_branch_start: branch={branch} worktree={worktree_dir}",
2357
+ )
2358
+ return 0
2359
+
2360
+
2361
+ def _resolve_branch_worktree(args: argparse.Namespace, run_id: str) -> Path:
2362
+ if args.vault_dir:
2363
+ return resolve_vault_dir(args.vault_dir)
2364
+ candidate = _worktree_dir(args.agent, run_id)
2365
+ if candidate.is_dir():
2366
+ return candidate.resolve()
2367
+ cwd = Path.cwd().resolve()
2368
+ if cwd.is_dir():
2369
+ return cwd
2370
+ raise VaultGitError(
2371
+ "vault_branch_commit: nao consegui resolver o worktree paralelo.\n"
2372
+ "Use --run-id <id> criado pelo branch-start ou --vault-dir <worktree>."
2373
+ )
2374
+
2375
+
2376
+ def _current_branch(vault_dir: Path) -> str:
2377
+ return _git(vault_dir, ["symbolic-ref", "--short", "HEAD"], check=False).stdout.strip()
2378
+
2379
+
2380
+ def _resolve_branch_commit_context(args: argparse.Namespace) -> tuple[Path, str, str, VaultContext]:
2381
+ if args.run_id:
2382
+ run_id = _parallel_run_id(args.run_id)
2383
+ branch = _parallel_branch(args.agent, run_id)
2384
+ _validate_branch_ref(branch)
2385
+ worktree_dir = _resolve_branch_worktree(args, run_id)
2386
+ context = validate_vault(worktree_dir, require_remote=True)
2387
+ return worktree_dir, branch, run_id, context
2388
+
2389
+ worktree_dir = resolve_vault_dir(args.vault_dir) if args.vault_dir else Path.cwd().resolve()
2390
+ context = validate_vault(worktree_dir, require_remote=True)
2391
+ branch = _current_branch(worktree_dir)
2392
+ expected_prefix = f"vault/{_agent_slug(args.agent)}/"
2393
+ if not branch.startswith(expected_prefix):
2394
+ shown = branch or "detached"
2395
+ raise VaultGitError(
2396
+ "vault_branch_commit: --run-id ausente, entao o worktree atual precisa estar "
2397
+ f"em branch {expected_prefix}<run-id>; HEAD={shown}"
2398
+ )
2399
+ _validate_branch_ref(branch)
2400
+ return worktree_dir, branch, _run_id_from_branch(branch), context
2401
+
2402
+
2403
+ def run_branch_commit(args: argparse.Namespace) -> int:
2404
+ worktree_dir, branch, run_id, context = _resolve_branch_commit_context(args)
2405
+ if not args.json:
2406
+ _print_context("vault_branch_commit", context)
2407
+ _ensure_branch(worktree_dir, branch, "vault_branch_commit")
2408
+
2409
+ _git(worktree_dir, ["add", "-A"])
2410
+ if not _has_staged_changes(worktree_dir):
2411
+ payload: dict[str, object] = {
2412
+ "schema": "medical-notes-workbench.vault-branch-commit.v1",
2413
+ "status": "no_changes",
2414
+ "agent": _agent_slug(args.agent),
2415
+ "workflow": args.workflow,
2416
+ "run_id": run_id,
2417
+ "branch": branch,
2418
+ "worktree_dir": str(worktree_dir),
2419
+ }
2420
+ _emit(args, payload, f"vault_branch_commit: nada a commitar em {branch}")
2421
+ return 0
2422
+
2423
+ body_prose = _body_file_text(args.body_file, "vault_branch_commit")
2424
+ if not body_prose:
2425
+ body_prose = _default_delivery_record_for_commit(
2426
+ worktree_dir,
2427
+ title=args.title,
2428
+ workflow=args.workflow,
2429
+ )
2430
+ operational_details = _operational_details_for_commit(worktree_dir)
2431
+ trailers = [
2432
+ f"Agent: {_agent_slug(args.agent)}",
2433
+ f"Workflow: {args.workflow}",
2434
+ f"Run-Id: {run_id}",
2435
+ f"Branch: {branch}",
2436
+ ]
2437
+ optional_trailers = [
2438
+ ("Tool", args.tool),
2439
+ ("Subagent", args.subagent),
2440
+ ("Trigger-Context", args.trigger_context),
2441
+ ("Receipt", args.receipt),
2442
+ ("Notes-Touched", args.notes_touched),
2443
+ ]
2444
+ for key, value in optional_trailers:
2445
+ if value:
2446
+ trailers.append(f"{key}: {value}")
2447
+
2448
+ messages = []
2449
+ if body_prose:
2450
+ messages.append(body_prose)
2451
+ if operational_details:
2452
+ messages.append(operational_details)
2453
+ messages.append("\n".join(trailers))
2454
+ identity = _commit(
2455
+ worktree_dir,
2456
+ title=args.title,
2457
+ messages=messages,
2458
+ identity=_resolve_git_identity(args.agent),
2459
+ )
2460
+ _push_branch(worktree_dir, branch, "vault_branch_commit", required=True)
2461
+ commit_sha = _git(worktree_dir, ["rev-parse", "--short", "HEAD"]).stdout.strip()
2462
+ payload = {
2463
+ "schema": "medical-notes-workbench.vault-branch-commit.v1",
2464
+ "status": "committed",
2465
+ "agent": _agent_slug(args.agent),
2466
+ "workflow": args.workflow,
2467
+ "run_id": run_id,
2468
+ "branch": branch,
2469
+ "worktree_dir": str(worktree_dir),
2470
+ "commit": commit_sha,
2471
+ "pushed": True,
2472
+ }
2473
+ _add_git_identity_payload(payload, identity)
2474
+ _emit(args, payload, f"vault_branch_commit: {commit_sha} branch={branch}")
2475
+ return 0
2476
+
2477
+
2478
+ def _fetch_branch(vault_dir: Path, branch: str) -> str:
2479
+ fetch = _git(
2480
+ vault_dir,
2481
+ ["fetch", "origin", f"{branch}:refs/remotes/origin/{branch}"],
2482
+ check=False,
2483
+ )
2484
+ if fetch.returncode == 0:
2485
+ return f"origin/{branch}"
2486
+ local = _git(vault_dir, ["show-ref", "--verify", f"refs/heads/{branch}"], check=False)
2487
+ if local.returncode == 0:
2488
+ return branch
2489
+ detail = (fetch.stderr or fetch.stdout).strip()
2490
+ raise VaultGitError(f"vault_integrate: nao consegui buscar {branch} em origin: {detail}")
2491
+
2492
+
2493
+ def _merge_message(branch: str, agent: str, workflow: str, run_id: str) -> str:
2494
+ label = _restore_point_label(workflow, when="after")
2495
+ return (
2496
+ f"integra(vault): mescla {branch}\n\n"
2497
+ "Integra branch paralela do vault com merge textual limpo do Git.\n\n"
2498
+ f"Integrated-Branch: {branch}\n"
2499
+ f"Integrated-Agent: {_agent_slug(agent)}\n"
2500
+ f"Integrated-Workflow: {workflow}\n"
2501
+ f"Integrated-Run-Id: {run_id}\n"
2502
+ "Restore-Point: workflow-result\n"
2503
+ f"Restore-Point-Label: {label}"
2504
+ )
2505
+
2506
+
2507
+ def _validate_integrated_tree(vault_dir: Path) -> None:
2508
+ status = _git(vault_dir, ["status", "--porcelain=v1"]).stdout.strip()
2509
+ if status:
2510
+ raise VaultGitError(
2511
+ "vault_integrate: merge parecia limpo, mas a arvore ficou suja; "
2512
+ f"bloqueando push.\n{status}"
2513
+ )
2514
+ unmerged = _git(vault_dir, ["diff", "--name-only", "--diff-filter=U"], check=False)
2515
+ if unmerged.stdout.strip():
2516
+ raise VaultGitError(
2517
+ "vault_integrate: merge deixou arquivos conflitados; bloqueando push.\n"
2518
+ + unmerged.stdout.strip()
2519
+ )
2520
+
2521
+
2522
+ def run_integrate(args: argparse.Namespace) -> int:
2523
+ vault_dir = resolve_vault_dir(args.vault_dir)
2524
+ context = validate_vault(vault_dir, require_remote=True)
2525
+ semantic_output = bool(getattr(args, "semantic_output", False))
2526
+ if not args.json and not semantic_output:
2527
+ _print_context("vault_integrate", context)
2528
+ _ensure_main(vault_dir, "vault_integrate")
2529
+ if _has_worktree_changes(vault_dir):
2530
+ raise VaultGitError(
2531
+ "vault_integrate: main esta sujo. Rode precommit/commit ou limpe o vault antes de integrar."
2532
+ )
2533
+
2534
+ branch = args.branch
2535
+ _validate_branch_ref(branch)
2536
+ run_id = _parallel_run_id(args.run_id or _run_id_from_branch(branch))
2537
+ _sync_main(vault_dir, "vault_integrate")
2538
+ merge_ref = _fetch_branch(vault_dir, branch)
2539
+ head_before = _git(vault_dir, ["rev-parse", "HEAD"]).stdout.strip()
2540
+ identity = _resolve_git_identity(args.agent)
2541
+ merge = _git(
2542
+ vault_dir,
2543
+ [
2544
+ "-c",
2545
+ f"user.name={identity.name}",
2546
+ "-c",
2547
+ f"user.email={identity.email}",
2548
+ "merge",
2549
+ "--no-ff",
2550
+ "-m",
2551
+ _merge_message(branch, args.agent, args.workflow, run_id),
2552
+ merge_ref,
2553
+ ],
2554
+ check=False,
2555
+ extra_env=_git_identity_env(identity),
2556
+ )
2557
+ if merge.returncode != 0:
2558
+ conflicts = [
2559
+ line.strip()
2560
+ for line in _git(vault_dir, ["diff", "--name-only", "--diff-filter=U"], check=False).stdout.splitlines()
2561
+ if line.strip()
2562
+ ]
2563
+ _git(vault_dir, ["merge", "--abort"], check=False)
2564
+ if conflicts:
2565
+ if semantic_output:
2566
+ message = (
2567
+ "Nada foi alterado. Encontrei conflito entre mudanças paralelas; "
2568
+ "revise os arquivos listados e tente de novo."
2569
+ )
2570
+ payload = {
2571
+ "schema": "medical-notes-workbench.vault-run-finish.v1",
2572
+ "status": "blocked_conflict",
2573
+ "agent": _agent_slug(args.agent),
2574
+ "workflow": args.workflow,
2575
+ "run_id": run_id,
2576
+ "conflicts": conflicts,
2577
+ "human_message": message,
2578
+ "next_action": "revisar conflitos listados e repetir o fechamento do run",
2579
+ "human_decision_required": True,
2580
+ }
2581
+ _emit(
2582
+ args,
2583
+ payload,
2584
+ message + "\n" + _format_block("Arquivos que precisam de revisão:", conflicts),
2585
+ )
2586
+ return 1
2587
+ payload: dict[str, object] = {
2588
+ "schema": "medical-notes-workbench.vault-integrate.v1",
2589
+ "status": "blocked_conflict",
2590
+ "branch": branch,
2591
+ "agent": _agent_slug(args.agent),
2592
+ "workflow": args.workflow,
2593
+ "run_id": run_id,
2594
+ "conflicts": conflicts,
2595
+ "next_action": (
2596
+ "resolver conflito clinico/manualmente ou ajustar a branch e rodar integrate de novo"
2597
+ ),
2598
+ }
2599
+ _emit(
2600
+ args,
2601
+ payload,
2602
+ "vault_integrate: conflito detectado; merge abortado.\n"
2603
+ + _format_block("Arquivos conflitados:", conflicts)
2604
+ + "\nResolva manualmente ou ajuste a branch e rode integrate novamente.",
2605
+ )
2606
+ return 1
2607
+ detail = (merge.stderr or merge.stdout).strip()
2608
+ raise VaultGitError(f"vault_integrate: merge falhou: {detail}")
2609
+
2610
+ _validate_integrated_tree(vault_dir)
2611
+ _push_branch(vault_dir, "main", "vault_integrate", required=True)
2612
+ head_after = _git(vault_dir, ["rev-parse", "HEAD"]).stdout.strip()
2613
+ status = "already_integrated" if head_after == head_before else "merged"
2614
+ if semantic_output:
2615
+ semantic_status = "already_recorded" if status == "already_integrated" else "integrated"
2616
+ label = _restore_point_label(args.workflow, when="after")
2617
+ message = "Ponto de restauração salvo com o resultado do workflow."
2618
+ payload = {
2619
+ "schema": "medical-notes-workbench.vault-run-finish.v1",
2620
+ "status": semantic_status,
2621
+ "agent": _agent_slug(args.agent),
2622
+ "workflow": args.workflow,
2623
+ "run_id": run_id,
2624
+ "restore_point_id": head_after[:12],
2625
+ "restore_point_label": label,
2626
+ "human_message": message,
2627
+ "pushed": True,
2628
+ }
2629
+ _add_git_identity_payload(payload, identity)
2630
+ _emit(args, payload, message)
2631
+ return 0
2632
+ payload = {
2633
+ "schema": "medical-notes-workbench.vault-integrate.v1",
2634
+ "status": status,
2635
+ "branch": branch,
2636
+ "agent": _agent_slug(args.agent),
2637
+ "workflow": args.workflow,
2638
+ "run_id": run_id,
2639
+ "merge_commit": head_after[:12],
2640
+ "pushed": True,
2641
+ }
2642
+ _add_git_identity_payload(payload, identity)
2643
+ _emit(args, payload, f"vault_integrate: {status} {branch} em main ({head_after[:12]})")
2644
+ return 0
2645
+
2646
+
2647
+ def _timeline_items(vault_dir: Path, limit: int, *, since: str | None = None, until: str | None = None) -> list[dict[str, str]]:
2648
+ args = [
2649
+ "log",
2650
+ f"--max-count={limit}",
2651
+ "--date=iso-strict",
2652
+ "--format=%H%x1f%ai%x1f%an%x1f%s%x1f%B%x1e",
2653
+ ]
2654
+ if since:
2655
+ args.append(f"--since={since}")
2656
+ if until:
2657
+ args.append(f"--until={until}")
2658
+ raw = _git(
2659
+ vault_dir,
2660
+ args,
2661
+ ).stdout
2662
+ items: list[dict[str, str]] = []
2663
+ for record in raw.split("\x1e"):
2664
+ record = record.strip()
2665
+ if not record:
2666
+ continue
2667
+ parts = record.split("\x1f", 4)
2668
+ if len(parts) != 5:
2669
+ continue
2670
+ full_sha, created_at, author, subject, body = parts
2671
+ workflow = (
2672
+ _trailer_value(body, "Workflow")
2673
+ or _trailer_value(body, "Integrated-Workflow")
2674
+ or _trailer_value(body, "Triggered-By-Workflow")
2675
+ )
2676
+ run_id = _trailer_value(body, "Run-Id") or _trailer_value(body, "Integrated-Run-Id")
2677
+ label = _trailer_value(body, "Restore-Point-Label")
2678
+ if not label:
2679
+ if subject.startswith("snapshot:"):
2680
+ label = _restore_point_label(workflow or "um workflow", when="before")
2681
+ elif subject.startswith("restaura("):
2682
+ label = f"Restauração aplicada por {workflow or '/mednotes:history'}"
2683
+ elif workflow:
2684
+ label = _restore_point_label(workflow, when="after")
2685
+ else:
2686
+ label = "Ponto de restauração do vault"
2687
+ items.append(
2688
+ {
2689
+ "id": full_sha[:12],
2690
+ "label": label,
2691
+ "workflow": workflow,
2692
+ "run_id": run_id,
2693
+ "created_at": created_at,
2694
+ "author": author,
2695
+ }
2696
+ )
2697
+ return items
2698
+
2699
+
2700
+ def run_timeline(args: argparse.Namespace) -> int:
2701
+ vault_dir = resolve_vault_dir(args.vault_dir)
2702
+ context = validate_vault(vault_dir)
2703
+ limit = max(1, int(args.limit or 10))
2704
+ items = _timeline_items(vault_dir, limit, since=args.since, until=args.until)
2705
+ backup = _backup_status_payload(vault_dir, context)
2706
+ payload: dict[str, object] = {
2707
+ "schema": "medical-notes-workbench.vault-timeline.v1",
2708
+ "status": "completed",
2709
+ "restore_points": items,
2710
+ "count": len(items),
2711
+ "since": args.since or "",
2712
+ "until": args.until or "",
2713
+ "backup_online": context.backup_online,
2714
+ **backup,
2715
+ }
2716
+ if context.origin_url:
2717
+ payload["origin_url"] = context.origin_url
2718
+ if args.json:
2719
+ _emit(args, payload, "")
2720
+ return 0
2721
+ lines = ["Pontos de restauração:"]
2722
+ if not items:
2723
+ lines.append("- nenhum ponto encontrado")
2724
+ for item in items:
2725
+ lines.append(f"- {item['id']} — {item['label']} — {item['created_at']}")
2726
+ backup_status = str(backup["backup_status"])
2727
+ if backup_status == "synced":
2728
+ lines.append("Backup online: atualizado.")
2729
+ elif backup_status == "local_checkpoints_pending":
2730
+ count = backup["local_checkpoints_pending_count"]
2731
+ lines.append(f"Backup online: pendente para {count} ponto(s) local(is).")
2732
+ elif backup_status == "skipped_no_remote":
2733
+ lines.append("Backup online: pendente de configuração.")
2734
+ elif backup_status == "unavailable":
2735
+ lines.append("Backup online: não conferido agora; proteção local continua válida.")
2736
+ elif backup_status == "remote_changes_pending":
2737
+ lines.append("Backup online: há mudanças externas para sincronizar antes de continuar.")
2738
+ elif backup_status == "diverged":
2739
+ lines.append("Backup online: precisa de revisão antes de sincronizar.")
2740
+ print("\n".join(lines))
2741
+ return 0
2742
+
2743
+
2744
+ def _read_restore_plan(path_value: str) -> dict[str, object]:
2745
+ path = Path(path_value).expanduser()
2746
+ if not path.is_file():
2747
+ raise VaultGitError(f"vault_restore: plano nao encontrado: {path}")
2748
+ try:
2749
+ data = json.loads(path.read_text(encoding="utf-8"))
2750
+ except (OSError, json.JSONDecodeError) as exc:
2751
+ raise VaultGitError(f"vault_restore: plano invalido: {path}") from exc
2752
+ if not isinstance(data, dict) or data.get("schema") != "medical-notes-workbench.vault-restore-plan.v1":
2753
+ raise VaultGitError(f"vault_restore: schema de plano invalido em {path}")
2754
+ return data
2755
+
2756
+
2757
+ def run_restore_preview(args: argparse.Namespace) -> int:
2758
+ vault_dir = resolve_vault_dir(args.vault_dir)
2759
+ context = validate_vault(vault_dir)
2760
+ _ensure_main(vault_dir, "vault_restore_preview")
2761
+ restore_to = _git(vault_dir, ["rev-parse", args.to]).stdout.strip()
2762
+ current_head = _head(vault_dir)
2763
+ paths = list(args.path or [])
2764
+ entries = _status_entries(vault_dir, restore_to, current_head, paths)
2765
+ affected = _affected_files(entries)
2766
+ seed = json.dumps(
2767
+ {
2768
+ "vault_dir": str(vault_dir),
2769
+ "restore_to": restore_to,
2770
+ "current_head": current_head,
2771
+ "paths": paths,
2772
+ "reason": args.reason or "",
2773
+ "created_at": _now_iso(),
2774
+ },
2775
+ sort_keys=True,
2776
+ )
2777
+ plan_id = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:12]
2778
+ plan_dir = _restore_plan_dir()
2779
+ plan_dir.mkdir(parents=True, exist_ok=True)
2780
+ plan_path = plan_dir / f"{plan_id}.json"
2781
+ message = "Nada foi alterado ainda. Confirme para aplicar."
2782
+ payload: dict[str, object] = {
2783
+ "schema": "medical-notes-workbench.vault-restore-plan.v1",
2784
+ "status": "preview_ready",
2785
+ "plan_id": plan_id,
2786
+ "created_at": _now_iso(),
2787
+ "vault_dir": str(vault_dir),
2788
+ "backup_online": context.backup_online,
2789
+ "restore_to": restore_to,
2790
+ "current_head": current_head,
2791
+ "reason": args.reason or "",
2792
+ "entries": entries,
2793
+ "affected_files": affected,
2794
+ "plan_path": str(plan_path),
2795
+ "human_message": message,
2796
+ }
2797
+ if context.origin_url:
2798
+ payload["origin_url"] = context.origin_url
2799
+ plan_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
2800
+ _emit(
2801
+ args,
2802
+ payload,
2803
+ "Estas notas seriam restauradas:\n"
2804
+ + _format_block("Arquivos afetados:", affected)
2805
+ + f"\n{message}",
2806
+ )
2807
+ return 0
2808
+
2809
+
2810
+ def _apply_restore_entries(vault_dir: Path, restore_to: str, entries: list[dict[str, str]]) -> None:
2811
+ for entry in entries:
2812
+ status = str(entry.get("status") or "")
2813
+ path = str(entry.get("path") or "")
2814
+ new_path = str(entry.get("new_path") or "")
2815
+ if status.startswith("R"):
2816
+ if new_path:
2817
+ _git(vault_dir, ["rm", "-f", "--", new_path], check=False)
2818
+ if path:
2819
+ _git(vault_dir, ["restore", "--source", restore_to, "--", path])
2820
+ elif status == "A":
2821
+ if path:
2822
+ _git(vault_dir, ["rm", "-f", "--", path], check=False)
2823
+ elif path:
2824
+ _git(vault_dir, ["restore", "--source", restore_to, "--", path])
2825
+
2826
+
2827
+ def run_restore_apply(args: argparse.Namespace) -> int:
2828
+ plan = _read_restore_plan(args.plan)
2829
+ plan_id = str(plan.get("plan_id") or "")
2830
+ if args.confirm != plan_id:
2831
+ payload: dict[str, object] = {
2832
+ "schema": "medical-notes-workbench.vault-restore-apply.v1",
2833
+ "status": "blocked_confirmation_required",
2834
+ "plan_id": plan_id,
2835
+ "human_message": "Nada foi alterado. Confirme o preview antes de restaurar.",
2836
+ }
2837
+ _emit(args, payload, "Nada foi alterado. Confirme o preview antes de restaurar.")
2838
+ return 1
2839
+
2840
+ vault_dir = resolve_vault_dir(args.vault_dir or str(plan.get("vault_dir") or ""))
2841
+ context = validate_vault(vault_dir)
2842
+ _ensure_main(vault_dir, "vault_restore_apply")
2843
+
2844
+ current_head = _head(vault_dir)
2845
+ expected_head = str(plan.get("current_head") or "")
2846
+ if current_head != expected_head:
2847
+ payload = {
2848
+ "schema": "medical-notes-workbench.vault-restore-apply.v1",
2849
+ "status": "blocked_stale_preview",
2850
+ "plan_id": plan_id,
2851
+ "expected_head": expected_head,
2852
+ "current_head": current_head,
2853
+ "human_message": "Nada foi alterado. O preview ficou antigo; gere um novo preview de restauração.",
2854
+ }
2855
+ _emit(args, payload, str(payload["human_message"]))
2856
+ return 1
2857
+
2858
+ run_id = _parallel_run_id(args.run_id or f"restore-{plan_id}")
2859
+ pre_restore_point_id = ""
2860
+ if _has_worktree_changes(vault_dir):
2861
+ pre_restore_point_id = _snapshot_dirty_main(
2862
+ vault_dir,
2863
+ agent=args.agent,
2864
+ workflow=args.workflow,
2865
+ run_id=run_id,
2866
+ restore_point_label="Ponto de restauração antes da restauração",
2867
+ ) or ""
2868
+
2869
+ guard_lease = _write_guard_lease(vault_dir, agent=args.agent, workflow=args.workflow, run_id=run_id)
2870
+ restore_to = str(plan.get("restore_to") or "")
2871
+ entries_raw = plan.get("entries") if isinstance(plan.get("entries"), list) else []
2872
+ entries = [entry for entry in entries_raw if isinstance(entry, dict)]
2873
+ affected_raw = plan.get("affected_files")
2874
+ affected = [str(path) for path in affected_raw] if isinstance(affected_raw, list) else []
2875
+ _apply_restore_entries(vault_dir, restore_to, entries) # type: ignore[arg-type]
2876
+ _git(vault_dir, ["add", "-A"])
2877
+ if not _has_staged_changes(vault_dir):
2878
+ guard_lease = _close_guard_lease(vault_dir, agent=args.agent, run_id=run_id)
2879
+ payload = {
2880
+ "schema": "medical-notes-workbench.vault-restore-apply.v1",
2881
+ "status": "no_changes",
2882
+ "plan_id": plan_id,
2883
+ "pre_restore_point_id": pre_restore_point_id,
2884
+ "guard_lease": guard_lease,
2885
+ "human_message": "Nada precisou ser restaurado; o vault já estava igual ao preview.",
2886
+ }
2887
+ _emit(args, payload, str(payload["human_message"]))
2888
+ return 0
2889
+
2890
+ reason = str(plan.get("reason") or "restauração solicitada pelo usuário")
2891
+ label = "Ponto de restauração depois da restauração"
2892
+ body = (
2893
+ f"Restauração aplicada a partir de preview confirmado.\n\n"
2894
+ f"{_format_block('Arquivos restaurados:', affected)}\n\n"
2895
+ f"Motivo informado: {reason}\n\n"
2896
+ f"Agent: {_agent_slug(args.agent)}\n"
2897
+ f"Workflow: {args.workflow}\n"
2898
+ f"Run-Id: {run_id}\n"
2899
+ f"Restore-Plan: {plan_id}\n"
2900
+ f"Restore-To: {restore_to[:12]}\n"
2901
+ "Restore-Point: restore-apply\n"
2902
+ f"Restore-Point-Label: {label}"
2903
+ )
2904
+ identity = _commit(
2905
+ vault_dir,
2906
+ title=f"restaura(vault): volta para ponto de restauração {restore_to[:12]}",
2907
+ messages=[body],
2908
+ identity=_resolve_git_identity(args.agent),
2909
+ )
2910
+ sync_status = _sync_and_push(vault_dir, "vault_restore_apply")
2911
+ restore_point_id = _short_sha(vault_dir)
2912
+ guard_lease = _close_guard_lease(vault_dir, agent=args.agent, run_id=run_id)
2913
+ message = "Pronto, restaurei o vault e salvei um novo ponto de restauração."
2914
+ payload = {
2915
+ "schema": "medical-notes-workbench.vault-restore-apply.v1",
2916
+ "status": "restored",
2917
+ "plan_id": plan_id,
2918
+ "pre_restore_point_id": pre_restore_point_id,
2919
+ "restore_point_id": restore_point_id,
2920
+ "affected_files": affected,
2921
+ "backup_online": context.backup_online,
2922
+ "sync_status": sync_status,
2923
+ "guard_lease": guard_lease,
2924
+ "human_message": message,
2925
+ }
2926
+ _add_git_identity_payload(payload, identity)
2927
+ if context.origin_url:
2928
+ payload["origin_url"] = context.origin_url
2929
+ _emit(args, payload, message)
2930
+ return 0
2931
+
2932
+
2933
+ def build_parser() -> argparse.ArgumentParser:
2934
+ parser = argparse.ArgumentParser(
2935
+ description="Registra mutacoes do vault Obsidian conforme a politica de version control."
2936
+ )
2937
+ subparsers = parser.add_subparsers(dest="command", required=True)
2938
+
2939
+ setup = subparsers.add_parser(
2940
+ "setup",
2941
+ help="Prepara protecao local do vault e guia backup online pelo GitHub.",
2942
+ )
2943
+ setup.add_argument("--vault-dir")
2944
+ setup.add_argument("--agent", required=True)
2945
+ setup.add_argument("--workflow", required=True)
2946
+ setup.add_argument("--run-id")
2947
+ setup.add_argument("--repo-name")
2948
+ setup.add_argument("--confirm-create-remote")
2949
+ setup.add_argument("--confirm-main-branch")
2950
+ setup.add_argument("--start-github-login", action="store_true")
2951
+ setup.add_argument("--json", action="store_true")
2952
+ setup.set_defaults(func=run_setup)
2953
+
2954
+ precommit = subparsers.add_parser("precommit", help="Cria snapshot pre-agente se o vault estiver sujo.")
2955
+ precommit.add_argument("--agent", required=True)
2956
+ precommit.add_argument("--workflow", required=True)
2957
+ precommit.add_argument("--vault-dir")
2958
+ precommit.set_defaults(func=run_precommit)
2959
+
2960
+ commit = subparsers.add_parser("commit", help="Cria commit identificado para mutacoes do agente.")
2961
+ commit.add_argument("--agent", required=True)
2962
+ commit.add_argument("--workflow", required=True)
2963
+ commit.add_argument("--title", required=True)
2964
+ commit.add_argument("--body-file")
2965
+ commit.add_argument("--tool")
2966
+ commit.add_argument("--subagent")
2967
+ commit.add_argument("--run-id")
2968
+ commit.add_argument("--trigger-context")
2969
+ commit.add_argument("--receipt")
2970
+ commit.add_argument("--notes-touched")
2971
+ commit.add_argument("--vault-dir")
2972
+ commit.set_defaults(func=run_commit)
2973
+
2974
+ run_start = subparsers.add_parser(
2975
+ "run-start",
2976
+ help="Prepara um ponto de restauração invisível antes de mutação real.",
2977
+ )
2978
+ run_start.add_argument("--agent", required=True)
2979
+ run_start.add_argument("--workflow", required=True)
2980
+ run_start.add_argument("--run-id")
2981
+ run_start.add_argument("--vault-dir")
2982
+ run_start.add_argument("--json", action="store_true")
2983
+ run_start.add_argument("--public-json", action="store_true", help=argparse.SUPPRESS)
2984
+ run_start.set_defaults(func=run_run_start)
2985
+
2986
+ run_finish = subparsers.add_parser(
2987
+ "run-finish",
2988
+ help="Fecha um run mutante e salva o ponto de restauração resultante.",
2989
+ )
2990
+ run_finish.add_argument("--agent", required=True)
2991
+ run_finish.add_argument("--workflow")
2992
+ run_finish.add_argument("--title")
2993
+ run_finish.add_argument("--body-file")
2994
+ run_finish.add_argument("--tool")
2995
+ run_finish.add_argument("--subagent")
2996
+ run_finish.set_defaults(run_id_provided=False)
2997
+ run_finish.add_argument("--run-id", action=MarkProvidedAction)
2998
+ run_finish.add_argument("--trigger-context")
2999
+ run_finish.add_argument("--receipt")
3000
+ run_finish.add_argument("--notes-touched")
3001
+ run_finish.add_argument("--branch")
3002
+ run_finish.add_argument("--vault-dir")
3003
+ run_finish.add_argument("--json", action="store_true")
3004
+ run_finish.add_argument("--public-json", action="store_true")
3005
+ run_finish.set_defaults(func=run_run_finish)
3006
+
3007
+ timeline = subparsers.add_parser(
3008
+ "timeline",
3009
+ help="Lista pontos de restauração em linguagem humana.",
3010
+ )
3011
+ timeline.add_argument("--limit", type=int, default=10)
3012
+ timeline.add_argument("--since")
3013
+ timeline.add_argument("--until")
3014
+ timeline.add_argument("--vault-dir")
3015
+ timeline.add_argument("--json", action="store_true")
3016
+ timeline.set_defaults(func=run_timeline)
3017
+
3018
+ restore_preview = subparsers.add_parser(
3019
+ "restore-preview",
3020
+ help="Mostra o que seria restaurado sem alterar o vault.",
3021
+ )
3022
+ restore_preview.add_argument("--to", required=True)
3023
+ restore_preview.add_argument("--path", action="append")
3024
+ restore_preview.add_argument("--reason")
3025
+ restore_preview.add_argument("--vault-dir")
3026
+ restore_preview.add_argument("--json", action="store_true")
3027
+ restore_preview.set_defaults(func=run_restore_preview)
3028
+
3029
+ restore_apply = subparsers.add_parser(
3030
+ "restore-apply",
3031
+ help="Aplica um preview de restauração confirmado.",
3032
+ )
3033
+ restore_apply.add_argument("--plan", required=True)
3034
+ restore_apply.add_argument("--confirm")
3035
+ restore_apply.add_argument("--agent", required=True)
3036
+ restore_apply.add_argument("--workflow", required=True)
3037
+ restore_apply.add_argument("--run-id")
3038
+ restore_apply.add_argument("--vault-dir")
3039
+ restore_apply.add_argument("--json", action="store_true")
3040
+ restore_apply.set_defaults(func=run_restore_apply)
3041
+
3042
+ guard_status = subparsers.add_parser(
3043
+ "guard-status",
3044
+ help="Mostra leases ativos da trava de segurança do vault.",
3045
+ )
3046
+ guard_status.add_argument("--vault-dir")
3047
+ guard_status.add_argument("--json", action="store_true")
3048
+ guard_status.set_defaults(func=run_guard_status)
3049
+
3050
+ branch_start = subparsers.add_parser(
3051
+ "branch-start",
3052
+ help="Cria branch/worktree isolado para um agente ou run paralelo.",
3053
+ )
3054
+ branch_start.add_argument("--agent", required=True)
3055
+ branch_start.add_argument("--workflow", required=True)
3056
+ branch_start.add_argument("--run-id")
3057
+ branch_start.add_argument("--vault-dir")
3058
+ branch_start.add_argument("--json", action="store_true")
3059
+ branch_start.set_defaults(func=run_branch_start)
3060
+
3061
+ branch_commit = subparsers.add_parser(
3062
+ "branch-commit",
3063
+ help="Commita e empurra mudancas do worktree paralelo.",
3064
+ )
3065
+ branch_commit.add_argument("--agent", required=True)
3066
+ branch_commit.add_argument("--workflow", required=True)
3067
+ branch_commit.add_argument("--title", required=True)
3068
+ branch_commit.add_argument("--body-file")
3069
+ branch_commit.add_argument("--tool")
3070
+ branch_commit.add_argument("--subagent")
3071
+ branch_commit.add_argument("--run-id")
3072
+ branch_commit.add_argument("--trigger-context")
3073
+ branch_commit.add_argument("--receipt")
3074
+ branch_commit.add_argument("--notes-touched")
3075
+ branch_commit.add_argument("--vault-dir")
3076
+ branch_commit.add_argument("--json", action="store_true")
3077
+ branch_commit.set_defaults(func=run_branch_commit)
3078
+
3079
+ integrate = subparsers.add_parser(
3080
+ "integrate",
3081
+ help="Integra branch paralela em main com merge textual limpo do Git.",
3082
+ )
3083
+ integrate.add_argument("--branch", required=True)
3084
+ integrate.add_argument("--agent", required=True)
3085
+ integrate.add_argument("--workflow", required=True)
3086
+ integrate.add_argument("--run-id")
3087
+ integrate.add_argument("--vault-dir")
3088
+ integrate.add_argument("--json", action="store_true")
3089
+ integrate.set_defaults(func=run_integrate)
3090
+ return parser
3091
+
3092
+
3093
+ def main(argv: list[str] | None = None) -> int:
3094
+ parser = build_parser()
3095
+ args = parser.parse_args(argv)
3096
+ try:
3097
+ return args.func(args)
3098
+ except VaultGitError as exc:
3099
+ if getattr(args, "json", False):
3100
+ print(json.dumps(exc.to_payload(), ensure_ascii=False, sort_keys=True))
3101
+ return 1
3102
+ print(str(exc), file=sys.stderr)
3103
+ return 1
3104
+
3105
+
3106
+ if __name__ == "__main__":
3107
+ raise SystemExit(main())