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.
- package/.opencode/agents/med-chat-triager.md +204 -0
- package/.opencode/agents/med-flashcard-maker.md +63 -0
- package/.opencode/agents/med-knowledge-architect.md +230 -0
- package/.opencode/agents/med-link-graph-curator.md +177 -0
- package/.opencode/agents/med-publish-guard.md +62 -0
- package/.opencode/commands/flashcards.md +25 -0
- package/.opencode/commands/mednotes/create.md +25 -0
- package/.opencode/commands/mednotes/enrich.md +27 -0
- package/.opencode/commands/mednotes/fix-wiki.md +27 -0
- package/.opencode/commands/mednotes/history.md +22 -0
- package/.opencode/commands/mednotes/link-body.md +25 -0
- package/.opencode/commands/mednotes/link-related.md +27 -0
- package/.opencode/commands/mednotes/link.md +27 -0
- package/.opencode/commands/mednotes/pdf-library.md +27 -0
- package/.opencode/commands/mednotes/process-chats.md +23 -0
- package/.opencode/commands/mednotes/setup.md +21 -0
- package/.opencode/commands/mednotes/status.md +27 -0
- package/.opencode/commands/mednotes/telemetry.md +27 -0
- package/.opencode/commands/report.md +26 -0
- package/.opencode/mednotes/AGENTS.md +57 -0
- package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
- package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
- package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
- package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
- package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
- package/.opencode/mednotes/contracts/.gitkeep +1 -0
- package/.opencode/mednotes/contracts/agents.json +116 -0
- package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
- package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
- package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
- package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
- package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
- package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
- package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
- package/.opencode/mednotes/docs/extension-docs.md +40 -0
- package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
- package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
- package/.opencode/mednotes/docs/merge-policy.md +110 -0
- package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
- package/.opencode/mednotes/docs/semantic-linker.md +141 -0
- package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
- package/.opencode/mednotes/docs/triage-policy.md +187 -0
- package/.opencode/mednotes/docs/vault-version-control.md +758 -0
- package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
- package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
- package/.opencode/mednotes/hooks/hooks.json +79 -0
- package/.opencode/mednotes/package-lock.json +6361 -0
- package/.opencode/mednotes/package.json +15 -0
- package/.opencode/mednotes/pyproject.toml +48 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
- package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
- package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
- package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
- package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
- package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
- package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
- package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
- package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
- package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
- package/.opencode/mednotes/scripts/run_python.mjs +109 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
- package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
- package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
- package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
- package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
- package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
- package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
- package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
- package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
- package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
- package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
- package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
- package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
- package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
- package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
- package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
- package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
- package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
- package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
- package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
- package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
- package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
- package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
- package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
- package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
- package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
- package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
- package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
- package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
- package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
- package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
- package/.opencode/mednotes/uv.lock +932 -0
- package/.opencode/mednotes.generated.json +395 -0
- package/.opencode/opencode.json +31 -0
- package/.opencode/plugins/mednotes-fsm.mjs +7 -0
- package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
- package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
- package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
- package/AGENTS.md +57 -0
- package/README.md +194 -0
- package/adapters/antigravity/agents.json +80 -0
- package/adapters/antigravity/templates/med-chat-triager.md +214 -0
- package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
- package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
- package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
- package/adapters/antigravity/templates/med-publish-guard.md +71 -0
- package/adapters/gemini-cli/gemini-extension.json +14 -0
- package/adapters/gemini-cli/package.json +15 -0
- package/adapters/gemini-cli/pyproject.toml +48 -0
- package/bin/mednotes-opencode.mjs +155 -0
- package/contracts/agents.json +116 -0
- package/core/agents/med-chat-triager.md +197 -0
- package/core/agents/med-flashcard-maker.md +56 -0
- package/core/agents/med-knowledge-architect.md +224 -0
- package/core/agents/med-link-graph-curator.md +171 -0
- package/core/agents/med-publish-guard.md +55 -0
- package/core/commands/flashcards.toml +22 -0
- package/core/commands/mednotes/create.toml +22 -0
- package/core/commands/mednotes/enrich.toml +24 -0
- package/core/commands/mednotes/fix-wiki.toml +24 -0
- package/core/commands/mednotes/history.toml +19 -0
- package/core/commands/mednotes/link-body.toml +22 -0
- package/core/commands/mednotes/link-related.toml +24 -0
- package/core/commands/mednotes/link.toml +24 -0
- package/core/commands/mednotes/pdf-library.toml +24 -0
- package/core/commands/mednotes/process-chats.toml +20 -0
- package/core/commands/mednotes/setup.toml +18 -0
- package/core/commands/mednotes/status.toml +24 -0
- package/core/commands/mednotes/telemetry.toml +24 -0
- package/core/commands/report.toml +23 -0
- package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/core/skills/create-medical-flashcards/SKILL.md +113 -0
- package/core/skills/create-medical-note/SKILL.md +90 -0
- package/core/skills/enrich-medical-note/SKILL.md +120 -0
- package/core/skills/fix-medical-wiki/SKILL.md +559 -0
- package/core/skills/link-medical-wiki/SKILL.md +224 -0
- package/core/skills/obsidian-cli/SKILL.md +118 -0
- package/core/skills/obsidian-markdown/SKILL.md +207 -0
- package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/core/skills/obsidian-ops/SKILL.md +136 -0
- package/core/skills/pdf-library/SKILL.md +45 -0
- package/core/skills/process-medical-chats/SKILL.md +246 -0
- package/core/skills/workflow-report/SKILL.md +100 -0
- 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())
|