mednotes-opencode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (430) hide show
  1. package/.opencode/agents/med-chat-triager.md +204 -0
  2. package/.opencode/agents/med-flashcard-maker.md +63 -0
  3. package/.opencode/agents/med-knowledge-architect.md +230 -0
  4. package/.opencode/agents/med-link-graph-curator.md +177 -0
  5. package/.opencode/agents/med-publish-guard.md +62 -0
  6. package/.opencode/commands/flashcards.md +25 -0
  7. package/.opencode/commands/mednotes/create.md +25 -0
  8. package/.opencode/commands/mednotes/enrich.md +27 -0
  9. package/.opencode/commands/mednotes/fix-wiki.md +27 -0
  10. package/.opencode/commands/mednotes/history.md +22 -0
  11. package/.opencode/commands/mednotes/link-body.md +25 -0
  12. package/.opencode/commands/mednotes/link-related.md +27 -0
  13. package/.opencode/commands/mednotes/link.md +27 -0
  14. package/.opencode/commands/mednotes/pdf-library.md +27 -0
  15. package/.opencode/commands/mednotes/process-chats.md +23 -0
  16. package/.opencode/commands/mednotes/setup.md +21 -0
  17. package/.opencode/commands/mednotes/status.md +27 -0
  18. package/.opencode/commands/mednotes/telemetry.md +27 -0
  19. package/.opencode/commands/report.md +26 -0
  20. package/.opencode/mednotes/AGENTS.md +57 -0
  21. package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
  22. package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
  23. package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
  24. package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
  25. package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
  26. package/.opencode/mednotes/contracts/.gitkeep +1 -0
  27. package/.opencode/mednotes/contracts/agents.json +116 -0
  28. package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
  29. package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
  30. package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
  31. package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
  32. package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
  33. package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
  34. package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
  35. package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
  36. package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
  37. package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
  38. package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
  39. package/.opencode/mednotes/docs/extension-docs.md +40 -0
  40. package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
  41. package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
  42. package/.opencode/mednotes/docs/merge-policy.md +110 -0
  43. package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
  44. package/.opencode/mednotes/docs/semantic-linker.md +141 -0
  45. package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
  46. package/.opencode/mednotes/docs/triage-policy.md +187 -0
  47. package/.opencode/mednotes/docs/vault-version-control.md +758 -0
  48. package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
  49. package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
  50. package/.opencode/mednotes/hooks/hooks.json +79 -0
  51. package/.opencode/mednotes/package-lock.json +6361 -0
  52. package/.opencode/mednotes/package.json +15 -0
  53. package/.opencode/mednotes/pyproject.toml +48 -0
  54. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
  55. package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
  56. package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
  57. package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
  58. package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
  59. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
  60. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
  61. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  62. package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  63. package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
  64. package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
  65. package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
  66. package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  67. package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
  68. package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
  69. package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
  70. package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
  71. package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
  72. package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
  73. package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
  74. package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
  75. package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
  76. package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
  77. package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
  78. package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
  79. package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
  80. package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
  81. package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
  82. package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
  83. package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
  84. package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
  85. package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
  86. package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
  87. package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
  88. package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
  89. package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
  90. package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
  91. package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
  92. package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
  93. package/.opencode/mednotes/scripts/run_python.mjs +109 -0
  94. package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
  95. package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
  96. package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
  97. package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
  98. package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
  99. package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
  100. package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
  101. package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
  102. package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
  103. package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
  104. package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
  105. package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
  106. package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
  107. package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
  108. package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
  109. package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  110. package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  111. package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  112. package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
  113. package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
  114. package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
  115. package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
  116. package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
  117. package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
  118. package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
  119. package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
  120. package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
  121. package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
  122. package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
  123. package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
  124. package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
  125. package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
  126. package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
  127. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
  128. package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
  129. package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
  130. package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
  131. package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
  132. package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
  133. package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
  134. package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
  135. package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
  136. package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
  137. package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
  138. package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
  139. package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
  140. package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
  141. package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
  142. package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
  143. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
  144. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
  145. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
  146. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
  147. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
  148. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
  149. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
  150. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
  151. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
  152. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
  153. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
  154. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
  155. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
  156. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
  157. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
  158. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
  159. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
  160. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
  161. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
  162. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
  163. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
  164. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
  165. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
  166. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
  167. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
  168. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
  169. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
  170. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
  171. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
  172. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
  173. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
  174. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
  175. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
  176. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
  177. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
  178. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
  179. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
  180. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
  181. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
  182. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
  183. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
  184. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
  185. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
  186. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
  187. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
  188. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
  189. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
  190. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
  191. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
  192. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
  193. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
  194. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
  195. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
  196. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
  197. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
  198. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
  199. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
  200. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
  201. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
  202. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
  203. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
  204. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
  205. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
  206. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
  207. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
  208. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
  209. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
  210. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
  211. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
  212. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
  213. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
  214. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
  215. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
  216. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
  217. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
  218. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
  219. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
  220. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
  221. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
  222. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
  223. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
  224. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
  225. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
  226. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
  227. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
  228. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
  229. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
  230. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
  231. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
  232. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
  233. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
  234. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
  235. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
  236. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
  237. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
  238. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
  239. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
  240. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
  241. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
  242. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
  243. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
  244. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
  245. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
  246. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
  247. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
  248. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
  249. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
  250. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
  251. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
  252. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
  253. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
  254. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
  255. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
  256. package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
  257. package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
  258. package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
  259. package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
  260. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
  261. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
  262. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
  263. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
  264. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
  265. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
  266. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
  267. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
  268. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
  269. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
  270. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
  271. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
  272. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
  273. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
  274. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
  275. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
  276. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
  277. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
  278. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
  279. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
  280. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
  281. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
  282. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
  283. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
  284. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
  285. package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
  286. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
  287. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
  288. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
  289. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
  290. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
  291. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
  292. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
  293. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
  294. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
  295. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
  296. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
  297. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
  298. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
  299. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
  300. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
  301. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
  302. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
  303. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
  304. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
  305. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
  306. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
  307. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
  308. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
  309. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
  310. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
  311. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
  312. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
  313. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
  314. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
  315. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
  316. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
  317. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
  318. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
  319. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
  320. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
  321. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
  322. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
  323. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
  324. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
  325. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
  326. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
  327. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
  328. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
  329. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
  330. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
  331. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
  332. package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
  333. package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
  334. package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
  335. package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
  336. package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
  337. package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
  338. package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
  339. package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
  340. package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
  341. package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
  342. package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
  343. package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
  344. package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
  345. package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
  346. package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
  347. package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
  348. package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
  349. package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
  350. package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
  351. package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
  352. package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
  353. package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
  354. package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
  355. package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
  356. package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
  357. package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
  358. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
  359. package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
  360. package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
  361. package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
  362. package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
  363. package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
  364. package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
  365. package/.opencode/mednotes/uv.lock +932 -0
  366. package/.opencode/mednotes.generated.json +395 -0
  367. package/.opencode/opencode.json +31 -0
  368. package/.opencode/plugins/mednotes-fsm.mjs +7 -0
  369. package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
  370. package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
  371. package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
  372. package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
  373. package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
  374. package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
  375. package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
  376. package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
  377. package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
  378. package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
  379. package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
  380. package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
  381. package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
  382. package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
  383. package/AGENTS.md +57 -0
  384. package/README.md +194 -0
  385. package/adapters/antigravity/agents.json +80 -0
  386. package/adapters/antigravity/templates/med-chat-triager.md +214 -0
  387. package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
  388. package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
  389. package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
  390. package/adapters/antigravity/templates/med-publish-guard.md +71 -0
  391. package/adapters/gemini-cli/gemini-extension.json +14 -0
  392. package/adapters/gemini-cli/package.json +15 -0
  393. package/adapters/gemini-cli/pyproject.toml +48 -0
  394. package/bin/mednotes-opencode.mjs +155 -0
  395. package/contracts/agents.json +116 -0
  396. package/core/agents/med-chat-triager.md +197 -0
  397. package/core/agents/med-flashcard-maker.md +56 -0
  398. package/core/agents/med-knowledge-architect.md +224 -0
  399. package/core/agents/med-link-graph-curator.md +171 -0
  400. package/core/agents/med-publish-guard.md +55 -0
  401. package/core/commands/flashcards.toml +22 -0
  402. package/core/commands/mednotes/create.toml +22 -0
  403. package/core/commands/mednotes/enrich.toml +24 -0
  404. package/core/commands/mednotes/fix-wiki.toml +24 -0
  405. package/core/commands/mednotes/history.toml +19 -0
  406. package/core/commands/mednotes/link-body.toml +22 -0
  407. package/core/commands/mednotes/link-related.toml +24 -0
  408. package/core/commands/mednotes/link.toml +24 -0
  409. package/core/commands/mednotes/pdf-library.toml +24 -0
  410. package/core/commands/mednotes/process-chats.toml +20 -0
  411. package/core/commands/mednotes/setup.toml +18 -0
  412. package/core/commands/mednotes/status.toml +24 -0
  413. package/core/commands/mednotes/telemetry.toml +24 -0
  414. package/core/commands/report.toml +23 -0
  415. package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
  416. package/core/skills/create-medical-flashcards/SKILL.md +113 -0
  417. package/core/skills/create-medical-note/SKILL.md +90 -0
  418. package/core/skills/enrich-medical-note/SKILL.md +120 -0
  419. package/core/skills/fix-medical-wiki/SKILL.md +559 -0
  420. package/core/skills/link-medical-wiki/SKILL.md +224 -0
  421. package/core/skills/obsidian-cli/SKILL.md +118 -0
  422. package/core/skills/obsidian-markdown/SKILL.md +207 -0
  423. package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
  424. package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
  425. package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
  426. package/core/skills/obsidian-ops/SKILL.md +136 -0
  427. package/core/skills/pdf-library/SKILL.md +45 -0
  428. package/core/skills/process-medical-chats/SKILL.md +246 -0
  429. package/core/skills/workflow-report/SKILL.md +100 -0
  430. package/package.json +45 -0
@@ -0,0 +1,1855 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Annotated, Literal
6
+
7
+ from pydantic import ConfigDict, Field, StrictStr, field_validator, model_validator
8
+ from pydantic import ValidationError as PydanticValidationError
9
+ from pydantic.json_schema import SkipJsonSchema
10
+ from statemachine import StateChart
11
+ from statemachine.states import States
12
+
13
+ from mednotes.domains.wiki.contracts.effect_payloads import (
14
+ RelatedNotesRecoveryStateEffectPayload,
15
+ WaitExternalEffectPayload,
16
+ )
17
+ from mednotes.domains.wiki.contracts.related_notes_runtime import LinkRelatedSyncResult, RelatedNotesRecoveryState
18
+ from mednotes.domains.wiki.contracts.workflow_outcomes import DecisionEvidence, WorkflowDecision
19
+ from mednotes.domains.wiki.flows.link_related.link_related_machine import (
20
+ LinkRelatedBoundaryEvent,
21
+ LinkRelatedMachine,
22
+ LinkRelatedRuntimeObservation,
23
+ LinkRelatedRuntimeObservedEvent,
24
+ category_for_link_related_state,
25
+ )
26
+ from mednotes.domains.wiki.flows.link_related.link_related_machine import (
27
+ LinkRelatedState as MachineLinkRelatedState,
28
+ )
29
+ from mednotes.kernel.agent_directive import (
30
+ AgentDirective,
31
+ agent_directive_from_progress_view_model,
32
+ assert_agent_directive_matches_progress,
33
+ )
34
+ from mednotes.kernel.base import ContractModel, JsonObject, JsonObjectAdapter
35
+ from mednotes.kernel.effect_intent import WorkflowEffect, WorkflowEffectKind
36
+ from mednotes.kernel.fsm_event import WorkflowEventLike
37
+ from mednotes.kernel.fsm_model import WorkflowModel
38
+ from mednotes.kernel.fsm_transition_result import WorkflowTransitionResult
39
+ from mednotes.kernel.progress import (
40
+ WorkflowProgressCounts,
41
+ WorkflowProgressEventType,
42
+ WorkflowProgressState,
43
+ WorkflowProgressStatus,
44
+ WorkflowProgressViewModel,
45
+ build_progress_view_model,
46
+ progress_state_from_view_model,
47
+ )
48
+ from mednotes.kernel.public_report import (
49
+ WorkflowPrimaryObjectiveSummary,
50
+ WorkflowPublicReport,
51
+ WorkflowReports,
52
+ assert_public_report_matches_progress,
53
+ public_progress_followup_line,
54
+ )
55
+ from mednotes.kernel.state_machine import (
56
+ WorkflowStateCategory,
57
+ WorkflowStateMachineSnapshot,
58
+ WorkflowTransition,
59
+ send_workflow_event,
60
+ )
61
+ from mednotes.kernel.workflow import (
62
+ HumanDecisionPacket,
63
+ ReceiptStatus,
64
+ VersionControlSafety,
65
+ WorkflowReceiptPayload,
66
+ assert_diagnostic_context_evidence_only,
67
+ diagnostic_context_evidence_only,
68
+ )
69
+
70
+ LINK_RELATED_WORKFLOW = "/mednotes:link-related"
71
+ LINK_RELATED_SCHEMA = "medical-notes-workbench.link-related-fsm-result.v1"
72
+ LINK_RELATED_RECEIPT_SCHEMA = "medical-notes-workbench.link-related-receipt.v1"
73
+ LINK_RELATED_AGENT_DIRECTIVE_FIELD = "agent_directive"
74
+
75
+ _PHASE = "related_notes_recovery"
76
+ _WAITING_QUOTA_STATE = "waiting_for_external_quota"
77
+ _RECOVERY_BLOCKED_STATE = "related_notes_recovery_blocked"
78
+ _RECOVERING_STATE = "recovering_related_notes"
79
+
80
+ LINK_RELATED_ALLOWED_ROOT_KEYS = frozenset(
81
+ {
82
+ "schema",
83
+ "workflow",
84
+ "run_id",
85
+ "state_machine_snapshot",
86
+ "progress_view_model",
87
+ "decision",
88
+ "human_decision_packet",
89
+ "receipt",
90
+ "reports",
91
+ "agent_directive",
92
+ "artifacts",
93
+ "version_control_safety",
94
+ "diagnostic_context",
95
+ "error_context",
96
+ }
97
+ )
98
+ LINK_RELATED_FORBIDDEN_ROOT_KEYS = frozenset(
99
+ {
100
+ "status",
101
+ "phase",
102
+ "blocked_reason",
103
+ "next_action",
104
+ "required_inputs",
105
+ "human_decision_required",
106
+ "manual_instruction_allowed",
107
+ "selected_recovery_mode",
108
+ "retry_command",
109
+ "wiki_dir",
110
+ "export_path",
111
+ "planned_note_count",
112
+ "proposed_link_count",
113
+ "cleared_link_count",
114
+ "skipped_edge_count",
115
+ "applied_note_count",
116
+ "updates",
117
+ "skipped_edges",
118
+ "related_notes_recovery_state",
119
+ }
120
+ )
121
+
122
+
123
+ def category_for_state(state: str) -> WorkflowStateCategory:
124
+ """Map link-related leaf states to the public FSM category."""
125
+
126
+ return category_for_link_related_state(MachineLinkRelatedState(state))
127
+
128
+
129
+ class LinkRelatedFsmFacts(ContractModel):
130
+ run_id: str = Field(min_length=1)
131
+ initial_state: MachineLinkRelatedState
132
+ event: LinkRelatedBoundaryEvent
133
+ changed_files: list[str] = Field(default_factory=list)
134
+ mutated: bool = False
135
+ artifacts: JsonObject = Field(default_factory=dict)
136
+ version_control_safety: VersionControlSafety
137
+ error_context: JsonObject = Field(default_factory=dict)
138
+
139
+ @model_validator(mode="after")
140
+ def _event_must_match_fsm_entry(self) -> LinkRelatedFsmFacts:
141
+ if self.event.workflow != LINK_RELATED_WORKFLOW:
142
+ raise ValueError(f"link-related event workflow must be {LINK_RELATED_WORKFLOW}")
143
+ if self.event.run_id != self.run_id:
144
+ raise ValueError("link-related event run_id must match LinkRelatedFsmFacts.run_id")
145
+ if self.event.current_state != self.initial_state.value:
146
+ raise ValueError("link-related event current_state must match initial_state")
147
+ return self
148
+
149
+
150
+ class _LinkRelatedRuntimeFacts(ContractModel):
151
+ """Typed runtime bridge from Related Notes sync output to a machine event."""
152
+
153
+ run_id: str = Field(min_length=1)
154
+ mode: Literal["dry_run", "apply", "recover_export"]
155
+ sync_result: LinkRelatedSyncResult = Field(default_factory=LinkRelatedSyncResult)
156
+ version_control_safety: VersionControlSafety
157
+ next_action: str = ""
158
+ human_decision_packet: HumanDecisionPacket | None = None
159
+ error_context: JsonObject = Field(default_factory=dict)
160
+
161
+ @field_validator("sync_result", mode="before")
162
+ @classmethod
163
+ def _coerce_sync_result(cls, value: object) -> LinkRelatedSyncResult:
164
+ return LinkRelatedSyncResult.from_payload(value)
165
+
166
+ @model_validator(mode="after")
167
+ def _observation_must_be_modeled(self) -> _LinkRelatedRuntimeFacts:
168
+ observation = _link_related_runtime_observation(self)
169
+ if not (
170
+ observation.failed
171
+ or observation.export_missing
172
+ or observation.export_stale
173
+ or observation.preview_ready
174
+ or observation.applied
175
+ or observation.blocked
176
+ or observation.waiting_external
177
+ ):
178
+ raise ValueError("effect_payload_contract_invalid: unmodeled related-notes runtime status")
179
+ return self
180
+
181
+
182
+ class _LinkRelatedOperationDecisionFields(ContractModel):
183
+ """Typed lens for the optional ask-human packet embedded in adapter output."""
184
+
185
+ model_config = ConfigDict(extra="ignore")
186
+
187
+ human_decision_packet: HumanDecisionPacket | None = None
188
+
189
+
190
+ class _LinkRelatedReportOperationFields(ContractModel):
191
+ """Small projection lens for audit-only Related Notes operation payloads."""
192
+
193
+ model_config = ConfigDict(extra="ignore")
194
+
195
+ status: StrictStr = ""
196
+ phase: StrictStr = ""
197
+ blocked_reason: StrictStr = ""
198
+ next_action: StrictStr = ""
199
+ planned_note_count: int = Field(default=0, ge=0, strict=True)
200
+ proposed_link_count: int = Field(default=0, ge=0, strict=True)
201
+ cleared_link_count: int = Field(default=0, ge=0, strict=True)
202
+ skipped_edge_count: int = Field(default=0, ge=0, strict=True)
203
+ applied_note_count: int = Field(default=0, ge=0, strict=True)
204
+ updates: list[JsonObject] = Field(default_factory=list)
205
+ skipped_edges: list[JsonObject] = Field(default_factory=list)
206
+ export_relocation: JsonObject = Field(default_factory=dict)
207
+ related_notes_recovery_state: JsonObject = Field(default_factory=dict)
208
+
209
+
210
+ class _LinkRelatedErrorPayloadFields(ContractModel):
211
+ """Typed lens for error evidence stored in the adapter operation payload."""
212
+
213
+ model_config = ConfigDict(extra="ignore")
214
+
215
+ validation_errors: list[object] = Field(default_factory=list)
216
+ contract_errors: list[object] = Field(default_factory=list)
217
+ hash_errors: list[object] = Field(default_factory=list)
218
+ stale_notes: list[object] = Field(default_factory=list)
219
+ forbidden_keys: list[object] = Field(default_factory=list)
220
+ detail: object = ""
221
+ selected_recovery_mode: object = ""
222
+ command_returncode: object = ""
223
+ error: object = ""
224
+ parse_error: object = ""
225
+ skipped_reason: object = ""
226
+
227
+ @property
228
+ def has_error_detail(self) -> bool:
229
+ return any(
230
+ (
231
+ self.validation_errors,
232
+ self.contract_errors,
233
+ self.hash_errors,
234
+ self.stale_notes,
235
+ self.forbidden_keys,
236
+ self.detail,
237
+ self.error,
238
+ self.parse_error,
239
+ self.skipped_reason,
240
+ )
241
+ )
242
+
243
+
244
+ class _LinkRelatedPayloadProgressViewFields(ContractModel):
245
+ status: StrictStr
246
+ state: StrictStr = ""
247
+
248
+
249
+ class _LinkRelatedPayloadSnapshotFields(ContractModel):
250
+ current_category: StrictStr
251
+
252
+
253
+ class _LinkRelatedPayloadReceiptFields(ContractModel):
254
+ status: StrictStr
255
+
256
+
257
+ class _LinkRelatedPayloadFields(ContractModel):
258
+ workflow: StrictStr
259
+ progress_view_model: _LinkRelatedPayloadProgressViewFields
260
+ state_machine_snapshot: _LinkRelatedPayloadSnapshotFields
261
+ receipt: _LinkRelatedPayloadReceiptFields
262
+ diagnostic_context: JsonObject = Field(default_factory=dict)
263
+
264
+
265
+ class LinkRelatedFsmResult(ContractModel):
266
+ schema_id: Literal["medical-notes-workbench.link-related-fsm-result.v1"] = Field(
267
+ default=LINK_RELATED_SCHEMA,
268
+ alias="schema",
269
+ )
270
+ workflow: Literal["/mednotes:link-related"] = LINK_RELATED_WORKFLOW
271
+ run_id: str = Field(min_length=1)
272
+ progress_state: SkipJsonSchema[WorkflowProgressState]
273
+ progress_view_model: WorkflowProgressViewModel
274
+ state_machine_snapshot: WorkflowStateMachineSnapshot
275
+ decision: WorkflowDecision | None = None
276
+ human_decision_packet: HumanDecisionPacket | None = None
277
+ receipt: WorkflowReceiptPayload
278
+ reports: WorkflowReports
279
+ agent_directive: JsonObject
280
+ artifacts: JsonObject = Field(default_factory=dict)
281
+ version_control_safety: VersionControlSafety
282
+ diagnostic_context: JsonObject = Field(default_factory=dict)
283
+ error_context: JsonObject = Field(default_factory=dict)
284
+
285
+ @model_validator(mode="before")
286
+ @classmethod
287
+ def _hydrate_progress_state_from_public_payload(cls, value: object) -> object:
288
+ """Accept public payloads where progress_state is intentionally hidden."""
289
+
290
+ if not isinstance(value, dict) or "progress_state" in value or "progress_view_model" not in value:
291
+ return value
292
+ hydrated = dict(value)
293
+ progress_view = WorkflowProgressViewModel.model_validate(value["progress_view_model"])
294
+ hydrated["progress_state"] = progress_state_from_view_model(progress_view).to_payload()
295
+ return hydrated
296
+
297
+ @model_validator(mode="after")
298
+ def _progress_view_model_matches_state(self) -> LinkRelatedFsmResult:
299
+ expected = build_progress_view_model(self.progress_state).to_payload()
300
+ if self.progress_view_model.to_payload() != expected:
301
+ raise ValueError("progress_view_model must match progress_state")
302
+ return self
303
+
304
+ def to_payload(self) -> JsonObject:
305
+ payload: JsonObject = {
306
+ "schema": self.schema_id,
307
+ "workflow": self.workflow,
308
+ "run_id": self.run_id,
309
+ "state_machine_snapshot": self.state_machine_snapshot.to_payload(),
310
+ "progress_view_model": self.progress_view_model.to_payload(),
311
+ "decision": self.decision.to_payload() if self.decision is not None else None,
312
+ "human_decision_packet": self.human_decision_packet.to_payload()
313
+ if self.human_decision_packet is not None
314
+ else None,
315
+ "receipt": self.receipt.to_payload(),
316
+ "reports": self.reports.to_payload(),
317
+ "agent_directive": dict(self.agent_directive),
318
+ "artifacts": dict(self.artifacts),
319
+ "version_control_safety": self.version_control_safety.to_payload(),
320
+ "error_context": dict(self.error_context),
321
+ }
322
+ if self.diagnostic_context:
323
+ payload["diagnostic_context"] = dict(self.diagnostic_context)
324
+ payload = JsonObjectAdapter.validate_python(payload)
325
+ assert_link_related_fsm_payload(payload)
326
+ return payload
327
+
328
+
329
+ def build_link_related_fsm_result(facts: LinkRelatedFsmFacts) -> LinkRelatedFsmResult:
330
+ """Project one typed LinkRelatedMachine event into the public payload."""
331
+
332
+ return build_link_related_fsm_result_from_model(
333
+ _link_related_model_after_event(facts.initial_state, facts.event),
334
+ version_control_safety=facts.version_control_safety,
335
+ error_context=facts.error_context,
336
+ artifacts=facts.artifacts,
337
+ changed_files=facts.changed_files,
338
+ mutated=facts.mutated,
339
+ )
340
+
341
+
342
+ def link_related_fsm_payload_from_sync_result(
343
+ result: JsonObject,
344
+ *,
345
+ run_id: str,
346
+ mode: Literal["dry_run", "apply", "recover_export"],
347
+ version_control_safety: VersionControlSafety | dict[str, object],
348
+ ) -> JsonObject:
349
+ return build_link_related_fsm_result(
350
+ link_related_fsm_facts_from_sync_result(
351
+ result,
352
+ run_id=run_id,
353
+ mode=mode,
354
+ version_control_safety=version_control_safety,
355
+ )
356
+ ).to_payload()
357
+
358
+
359
+ def build_link_related_fsm_result_from_model(
360
+ model: WorkflowModel,
361
+ *,
362
+ version_control_safety: VersionControlSafety | dict[str, object],
363
+ error_context: JsonObject | None = None,
364
+ artifacts: JsonObject | None = None,
365
+ changed_files: list[str] | None = None,
366
+ mutated: bool | None = None,
367
+ ) -> LinkRelatedFsmResult:
368
+ """Project the real LinkRelatedMachine model without reading operation payloads."""
369
+
370
+ _validate_link_related_machine_model(model)
371
+ state = MachineLinkRelatedState(model.state)
372
+ category = category_for_link_related_state(state)
373
+ progress_state = _progress_state_from_model(model, state, category)
374
+ progress_view_model = build_progress_view_model(progress_state)
375
+ snapshot = _snapshot_from_model(model, state, category)
376
+ safety = _version_control_safety(version_control_safety)
377
+ receipt = _receipt_from_model(
378
+ model,
379
+ progress_state=progress_state,
380
+ progress_view_model=progress_view_model,
381
+ snapshot=snapshot,
382
+ version_control_safety=safety,
383
+ changed_files=changed_files or [],
384
+ mutated=mutated,
385
+ )
386
+ reports_model = _reports_from_model(model, state, progress_state)
387
+ public_report = reports_model.public_report
388
+ diagnostic_context = _diagnostic_context_from_model(model, state, category)
389
+ agent_directive = agent_directive_from_progress_view_model(
390
+ progress_view_model,
391
+ schema="medical-notes-workbench.agent-directive.v1",
392
+ reason=_machine_reason_code(model, state),
393
+ effects=model.pending_effects,
394
+ blockers=_machine_blockers(category, model, state),
395
+ resume=progress_state.resume_action,
396
+ report_requires=["related_notes"],
397
+ summary=public_report.summary_text(),
398
+ instructions=_machine_agent_instructions(category),
399
+ ).to_payload()
400
+ machine_error_context = error_context or _error_context_from_model(model, state, category)
401
+ return LinkRelatedFsmResult(
402
+ run_id=model.run_id,
403
+ progress_state=progress_state,
404
+ progress_view_model=progress_view_model,
405
+ state_machine_snapshot=snapshot,
406
+ decision=model.last_transition.decision if model.last_transition is not None else None,
407
+ human_decision_packet=model.last_transition.human_decision_packet if model.last_transition is not None else None,
408
+ receipt=receipt,
409
+ reports=reports_model,
410
+ agent_directive=JsonObjectAdapter.validate_python(agent_directive),
411
+ artifacts=artifacts or {},
412
+ version_control_safety=safety,
413
+ diagnostic_context=diagnostic_context,
414
+ error_context=machine_error_context,
415
+ )
416
+
417
+
418
+ def link_related_fsm_payload_from_model(
419
+ model: WorkflowModel,
420
+ *,
421
+ version_control_safety: VersionControlSafety | dict[str, object],
422
+ ) -> JsonObject:
423
+ """JSON boundary for the machine-driven link-related FSM projection."""
424
+
425
+ return build_link_related_fsm_result_from_model(
426
+ model,
427
+ version_control_safety=version_control_safety,
428
+ ).to_payload()
429
+
430
+
431
+ def link_related_fsm_facts_from_sync_result(
432
+ result: JsonObject,
433
+ *,
434
+ run_id: str,
435
+ mode: Literal["dry_run", "apply", "recover_export"],
436
+ version_control_safety: VersionControlSafety | dict[str, object],
437
+ ) -> LinkRelatedFsmFacts:
438
+ sync_result = LinkRelatedSyncResult.from_payload(result)
439
+ explicit_error_context = sync_result.error_context
440
+ operation_decision = _LinkRelatedOperationDecisionFields.model_validate(sync_result.operation_payload)
441
+ runtime_facts = _LinkRelatedRuntimeFacts(
442
+ run_id=run_id,
443
+ mode=mode,
444
+ sync_result=sync_result,
445
+ version_control_safety=version_control_safety,
446
+ next_action=sync_result.next_action,
447
+ human_decision_packet=operation_decision.human_decision_packet,
448
+ error_context=(
449
+ explicit_error_context.to_payload()
450
+ if explicit_error_context is not None
451
+ else _link_related_error_context_from_result(sync_result)
452
+ ),
453
+ )
454
+ observation = _link_related_runtime_observation(runtime_facts)
455
+ initial_state = _link_related_runtime_source_state(runtime_facts, observation)
456
+ reason = _link_related_runtime_reason_code(runtime_facts, fallback=_link_related_observation_fallback_reason(observation))
457
+ event = LinkRelatedRuntimeObservedEvent(
458
+ workflow=LINK_RELATED_WORKFLOW,
459
+ run_id=run_id,
460
+ current_state=initial_state.value,
461
+ observation=observation,
462
+ audit_evidence=_link_related_runtime_audit_evidence(runtime_facts, reason),
463
+ )
464
+ changed_files = _link_related_changed_files(sync_result)
465
+ return LinkRelatedFsmFacts(
466
+ run_id=run_id,
467
+ initial_state=initial_state,
468
+ event=event,
469
+ changed_files=changed_files,
470
+ mutated=mode == "apply" and bool(changed_files or sync_result.applied_note_count),
471
+ artifacts=_link_related_artifacts(sync_result),
472
+ version_control_safety=version_control_safety,
473
+ error_context=runtime_facts.error_context,
474
+ )
475
+
476
+
477
+ def _link_related_model_after_event(initial_state: MachineLinkRelatedState, event: WorkflowEventLike) -> WorkflowModel:
478
+ model = WorkflowModel.start(
479
+ workflow=LINK_RELATED_WORKFLOW,
480
+ run_id=event.run_id,
481
+ initial_state=initial_state.value,
482
+ )
483
+ send_workflow_event(
484
+ LinkRelatedMachine(model=model, state_field=WorkflowModel.STATECHART_STATE_FIELD),
485
+ event,
486
+ )
487
+ return model
488
+
489
+
490
+ def _link_related_runtime_source_state(
491
+ facts: _LinkRelatedRuntimeFacts,
492
+ observation: LinkRelatedRuntimeObservation,
493
+ ) -> MachineLinkRelatedState:
494
+ """Choose only the legal source state; LinkRelatedMachine still chooses the leaf."""
495
+
496
+ if facts.mode == "apply":
497
+ return MachineLinkRelatedState.APPLYING_RELATED_NOTES
498
+ if facts.mode == "recover_export":
499
+ return MachineLinkRelatedState.STALE_EXPORT
500
+ if observation.blocked and observation.preview_ready:
501
+ return MachineLinkRelatedState.PREVIEW_READY
502
+ return MachineLinkRelatedState.CHECKING_EXPORT
503
+
504
+
505
+ def _link_related_runtime_reason_code(facts: _LinkRelatedRuntimeFacts, *, fallback: str) -> str:
506
+ result = facts.sync_result
507
+ for value in (result.blocked_reason, result.skipped_reason, result.error, result.parse_error):
508
+ cleaned = value.strip()
509
+ if cleaned:
510
+ return cleaned
511
+ return fallback
512
+
513
+
514
+ def _link_related_export_missing(facts: _LinkRelatedRuntimeFacts) -> bool:
515
+ return _link_related_runtime_reason_code(facts, fallback="") == "related_notes_export_missing"
516
+
517
+
518
+ def _link_related_export_stale(facts: _LinkRelatedRuntimeFacts) -> bool:
519
+ return _link_related_runtime_reason_code(facts, fallback="") in {
520
+ "related_notes_export_stale",
521
+ "related_notes_export_still_stale",
522
+ "related_notes_hash_mismatch",
523
+ "related_notes_vault_mismatch",
524
+ }
525
+
526
+
527
+ def _link_related_runtime_audit_evidence(
528
+ facts: _LinkRelatedRuntimeFacts,
529
+ reason: str,
530
+ ) -> JsonObject:
531
+ result = facts.sync_result
532
+ recovery = result.related_notes_recovery_state
533
+ report_operation = _link_related_report_operation(result)
534
+ related_notes_evidence: JsonObject = {
535
+ "status": result.status,
536
+ "blocked_reason": _link_related_runtime_reason_code(facts, fallback=""),
537
+ "selected_recovery_mode": result.selected_recovery_mode,
538
+ "manual_instruction_allowed": result.manual_instruction_allowed,
539
+ }
540
+ for key in ("automatic_recovery_unavailable_reason", "export_relocation"):
541
+ if key in result.operation_payload:
542
+ related_notes_evidence[key] = result.operation_payload[key]
543
+ return JsonObjectAdapter.validate_python(
544
+ {
545
+ "mode": facts.mode,
546
+ "runtime_status": result.status,
547
+ "runtime_phase": result.phase,
548
+ "runtime_reason": reason,
549
+ "related_notes": related_notes_evidence,
550
+ "report_operation": report_operation.model_dump(),
551
+ "counts": {
552
+ "planned_note_count": result.planned_note_count,
553
+ "proposed_link_count": result.proposed_link_count,
554
+ "cleared_link_count": result.cleared_link_count,
555
+ "skipped_edge_count": result.skipped_edge_count,
556
+ "applied_note_count": result.applied_note_count,
557
+ "fresh_record_count": recovery.fresh_record_count,
558
+ "stale_record_count": recovery.stale_record_count,
559
+ "remaining_count": recovery.remaining_count,
560
+ },
561
+ "settings": {
562
+ "min_score": result.min_score,
563
+ "max_links": result.max_links,
564
+ },
565
+ }
566
+ )
567
+
568
+
569
+ def _link_related_report_operation(result: LinkRelatedSyncResult) -> _LinkRelatedReportOperationFields:
570
+ """Project adapter details into report-only facts without making them FSM state."""
571
+
572
+ return _LinkRelatedReportOperationFields.model_validate(
573
+ {
574
+ **result.operation_payload,
575
+ "status": result.status,
576
+ "phase": result.phase,
577
+ "blocked_reason": result.blocked_reason,
578
+ "next_action": result.next_action,
579
+ "planned_note_count": result.planned_note_count,
580
+ "proposed_link_count": result.proposed_link_count,
581
+ "cleared_link_count": result.cleared_link_count,
582
+ "skipped_edge_count": result.skipped_edge_count,
583
+ "applied_note_count": result.applied_note_count,
584
+ }
585
+ )
586
+
587
+
588
+ def _validate_link_related_machine_model(model: WorkflowModel) -> None:
589
+ if model.workflow != LINK_RELATED_WORKFLOW:
590
+ raise ValueError(f"link-related FSM projector requires workflow={LINK_RELATED_WORKFLOW}")
591
+ MachineLinkRelatedState(model.state)
592
+
593
+
594
+ def _progress_state_from_model(
595
+ model: WorkflowModel,
596
+ state: MachineLinkRelatedState,
597
+ category: WorkflowStateCategory,
598
+ ) -> WorkflowProgressState:
599
+ status = _machine_progress_status(category)
600
+ current, total, counts = _machine_counts(model, state, status)
601
+ return WorkflowProgressState(
602
+ workflow=LINK_RELATED_WORKFLOW,
603
+ run_id=model.run_id,
604
+ state=state.value,
605
+ phase=_machine_phase_for_state(state),
606
+ event_type=_machine_event_type(status),
607
+ message=_machine_message_for_state(state),
608
+ status=status,
609
+ current=current,
610
+ total=total,
611
+ counts=counts,
612
+ resume_action=_machine_resume_action(model, state),
613
+ resume_supported=status
614
+ in {
615
+ WorkflowProgressStatus.WAITING_AGENT,
616
+ WorkflowProgressStatus.WAITING_EXTERNAL,
617
+ WorkflowProgressStatus.WAITING_HUMAN,
618
+ WorkflowProgressStatus.BLOCKED,
619
+ },
620
+ can_continue_now=status
621
+ in {
622
+ WorkflowProgressStatus.RUNNING,
623
+ WorkflowProgressStatus.WAITING_AGENT,
624
+ },
625
+ decision=model.last_transition.decision.decision_summary()
626
+ if model.last_transition is not None and model.last_transition.decision is not None
627
+ else None,
628
+ technical_context=_machine_technical_context(model, state, category),
629
+ )
630
+
631
+
632
+ def _machine_counts(
633
+ model: WorkflowModel,
634
+ state: MachineLinkRelatedState,
635
+ status: WorkflowProgressStatus,
636
+ ) -> tuple[int, int, WorkflowProgressCounts]:
637
+ event = _last_machine_event(model)
638
+ planned = _event_int(event, "planned_note_count")
639
+ fresh = _event_int(event, "fresh_record_count")
640
+ stale = _event_int(event, "stale_record_count")
641
+ remaining = _event_int(event, "remaining_count")
642
+ changed = _event_int(event, "changed_file_count")
643
+
644
+ if state == MachineLinkRelatedState.WAITING_EXTERNAL_QUOTA:
645
+ total = fresh + remaining
646
+ return (
647
+ fresh,
648
+ total,
649
+ WorkflowProgressCounts(
650
+ planned_items=total,
651
+ processed_items=fresh,
652
+ remaining_items=remaining,
653
+ blocked_items=remaining,
654
+ deferred_items=remaining,
655
+ ),
656
+ )
657
+ if state == MachineLinkRelatedState.COMPLETED:
658
+ total = max(changed, planned)
659
+ return (
660
+ total,
661
+ total,
662
+ WorkflowProgressCounts(
663
+ planned_items=total,
664
+ processed_items=total,
665
+ mutated_files=changed,
666
+ written_files=changed,
667
+ ),
668
+ )
669
+ if state == MachineLinkRelatedState.PREVIEW_READY:
670
+ return (
671
+ planned,
672
+ planned,
673
+ WorkflowProgressCounts(
674
+ planned_items=planned,
675
+ processed_items=planned,
676
+ ),
677
+ )
678
+ if state == MachineLinkRelatedState.WAITING_HUMAN_CONFIRMATION:
679
+ return (
680
+ 0,
681
+ planned,
682
+ WorkflowProgressCounts(
683
+ planned_items=planned,
684
+ remaining_items=planned,
685
+ blocked_items=planned,
686
+ ),
687
+ )
688
+ if state in {MachineLinkRelatedState.EXPORT_REQUIRED, MachineLinkRelatedState.STALE_EXPORT}:
689
+ blocked = max(planned, stale, 1)
690
+ return (
691
+ 0,
692
+ blocked,
693
+ WorkflowProgressCounts(
694
+ planned_items=planned,
695
+ remaining_items=blocked,
696
+ blocked_items=blocked,
697
+ ),
698
+ )
699
+ if status in {WorkflowProgressStatus.BLOCKED, WorkflowProgressStatus.FAILED}:
700
+ blocked = max(planned, remaining, stale, 1)
701
+ return (
702
+ 0,
703
+ blocked,
704
+ WorkflowProgressCounts(
705
+ planned_items=planned,
706
+ remaining_items=blocked,
707
+ blocked_items=blocked,
708
+ ),
709
+ )
710
+ return 0, planned, WorkflowProgressCounts(planned_items=planned)
711
+
712
+
713
+ def _snapshot_from_model(
714
+ model: WorkflowModel,
715
+ state: MachineLinkRelatedState,
716
+ category: WorkflowStateCategory,
717
+ ) -> WorkflowStateMachineSnapshot:
718
+ return WorkflowStateMachineSnapshot(
719
+ workflow=LINK_RELATED_WORKFLOW,
720
+ run_id=model.run_id,
721
+ current_state=state.value,
722
+ current_category=category,
723
+ transitions=[_machine_snapshot_transition(transition) for transition in model.transition_log],
724
+ metadata={"reason": _machine_reason_code(model, state), "source": "LinkRelatedMachine"},
725
+ )
726
+
727
+
728
+ def _machine_snapshot_transition(transition: WorkflowTransitionResult) -> WorkflowTransition:
729
+ return WorkflowTransition(
730
+ workflow=transition.workflow,
731
+ from_state=transition.from_state,
732
+ to_state=transition.to_state,
733
+ to_category=category_for_link_related_state(MachineLinkRelatedState(transition.to_state)),
734
+ trigger=transition.trigger,
735
+ effects=list(transition.effects),
736
+ decision=transition.decision,
737
+ resume_action=transition.resume_action,
738
+ )
739
+
740
+
741
+ def _receipt_from_model(
742
+ model: WorkflowModel,
743
+ *,
744
+ progress_state: WorkflowProgressState,
745
+ progress_view_model: WorkflowProgressViewModel,
746
+ snapshot: WorkflowStateMachineSnapshot,
747
+ version_control_safety: VersionControlSafety,
748
+ changed_files: list[str],
749
+ mutated: bool | None,
750
+ ) -> WorkflowReceiptPayload:
751
+ return WorkflowReceiptPayload(
752
+ schema=LINK_RELATED_RECEIPT_SCHEMA,
753
+ workflow=LINK_RELATED_WORKFLOW,
754
+ run_id=model.run_id,
755
+ status=_machine_receipt_status(progress_state.status),
756
+ mutated=mutated if mutated is not None else version_control_safety.changed_file_count > 0,
757
+ next_action="" if progress_state.status == WorkflowProgressStatus.COMPLETED else progress_state.resume_action,
758
+ human_decision_required=progress_state.status == WorkflowProgressStatus.WAITING_HUMAN,
759
+ human_decision_packet=model.last_transition.human_decision_packet if model.last_transition is not None else None,
760
+ changed_files=changed_files,
761
+ version_control_safety=version_control_safety,
762
+ progress_state=progress_state,
763
+ progress_view_model=progress_view_model,
764
+ state_machine_snapshot=snapshot,
765
+ )
766
+
767
+
768
+ def _reports_from_model(
769
+ model: WorkflowModel,
770
+ state: MachineLinkRelatedState,
771
+ progress_state: WorkflowProgressState,
772
+ ) -> WorkflowReports:
773
+ summary = _machine_message_for_state(state)
774
+ public_lines = [summary]
775
+ followup_line = public_progress_followup_line(progress_state)
776
+ if followup_line:
777
+ public_lines.append(followup_line)
778
+ public_report = WorkflowPublicReport(
779
+ workflow=LINK_RELATED_WORKFLOW,
780
+ run_id=model.run_id,
781
+ headline=summary,
782
+ lines=public_lines,
783
+ )
784
+ related_notes_report = _related_notes_report_from_model(model, state, progress_state)
785
+ return WorkflowReports(
786
+ summary=summary,
787
+ public_report=public_report,
788
+ details={
789
+ "primary_objective_summary": _primary_objective_summary(model, state, progress_state).to_payload(),
790
+ "related_notes": related_notes_report,
791
+ },
792
+ )
793
+
794
+
795
+ def _primary_objective_summary(
796
+ model: WorkflowModel,
797
+ state: MachineLinkRelatedState,
798
+ progress_state: WorkflowProgressState,
799
+ ) -> WorkflowPrimaryObjectiveSummary:
800
+ """State-owned answer to whether Related Notes were actually updated."""
801
+
802
+ completed = state == MachineLinkRelatedState.COMPLETED or (
803
+ state == MachineLinkRelatedState.PREVIEW_READY and progress_state.counts.planned_items == 0
804
+ )
805
+ changed_count = max(progress_state.counts.mutated_files, progress_state.counts.written_files)
806
+ if state == MachineLinkRelatedState.PREVIEW_READY and completed:
807
+ mutation_summary = "Notas Relacionadas conferidas; nenhuma alteração era necessária."
808
+ elif state == MachineLinkRelatedState.PREVIEW_READY:
809
+ mutation_summary = "Prévia de Notas Relacionadas pronta; nada foi alterado ainda."
810
+ elif changed_count > 0:
811
+ mutation_summary = f"{changed_count} nota(s) tiveram Notas Relacionadas atualizadas."
812
+ else:
813
+ mutation_summary = "Nenhuma seção de Notas Relacionadas foi alterada nesta etapa."
814
+ return WorkflowPrimaryObjectiveSummary(
815
+ workflow=LINK_RELATED_WORKFLOW,
816
+ run_id=model.run_id,
817
+ objective="Atualizar a seção Notas Relacionadas a partir do export oficial.",
818
+ completed=completed,
819
+ status=state.value,
820
+ mutation_state="changed" if changed_count > 0 else "unchanged",
821
+ mutation_summary=mutation_summary,
822
+ remaining_work_summary=_link_related_remaining_work_summary(state, completed),
823
+ next_step_summary=_link_related_next_step_summary(progress_state, completed),
824
+ blocked_reason="" if completed else state.value,
825
+ )
826
+
827
+
828
+ def _link_related_remaining_work_summary(state: MachineLinkRelatedState, completed: bool) -> str:
829
+ if completed:
830
+ if state == MachineLinkRelatedState.PREVIEW_READY:
831
+ return "Export conferido; não havia alterações de Notas Relacionadas para aplicar."
832
+ return "Notas Relacionadas foram atualizadas e conferidas."
833
+ if state == MachineLinkRelatedState.PREVIEW_READY:
834
+ return "Ainda falta confirmar/aplicar a prévia para alterar a Wiki."
835
+ return _machine_message_for_state(state)
836
+
837
+
838
+ def _link_related_next_step_summary(progress_state: WorkflowProgressState, completed: bool) -> str:
839
+ if completed:
840
+ return "Nenhuma ação pendente para Notas Relacionadas."
841
+ return progress_state.resume_action or "Retomar /mednotes:link-related pela rota oficial."
842
+
843
+
844
+ def _related_notes_report_from_model(
845
+ model: WorkflowModel,
846
+ state: MachineLinkRelatedState,
847
+ progress_state: WorkflowProgressState,
848
+ ) -> JsonObject:
849
+ """Build the human/report projection without making it state truth.
850
+
851
+ The state remains owned by LinkRelatedMachine; this report carries the
852
+ adapter's typed operational details so users/tests can audit planned or
853
+ applied Related Notes changes without reintroducing non-FSM root fields.
854
+ """
855
+
856
+ evidence = _machine_audit_evidence(model)
857
+ raw_operation = evidence.get("report_operation")
858
+ operation = (
859
+ _LinkRelatedReportOperationFields.model_validate(raw_operation)
860
+ if isinstance(raw_operation, dict)
861
+ else None
862
+ )
863
+ counts = _dict_from(evidence.get("counts")) or progress_state.counts.to_payload()
864
+ settings = _dict_from(evidence.get("settings"))
865
+ report: JsonObject = {
866
+ "schema": "medical-notes-workbench.link-related-machine-report.v1",
867
+ "source": "LinkRelatedMachine",
868
+ "counts": counts,
869
+ }
870
+ if settings:
871
+ report["settings"] = settings
872
+ if operation is not None:
873
+ operation_counts = {
874
+ "planned_note_count": operation.planned_note_count,
875
+ "proposed_link_count": operation.proposed_link_count,
876
+ "cleared_link_count": operation.cleared_link_count,
877
+ "skipped_edge_count": operation.skipped_edge_count,
878
+ "applied_note_count": operation.applied_note_count,
879
+ }
880
+ report["counts"] = {**counts, **operation_counts}
881
+ report["planned_changes"] = {
882
+ "updates": operation.updates,
883
+ "skipped_edges": operation.skipped_edges,
884
+ }
885
+ recovery_state = RelatedNotesRecoveryState.from_payload(operation.related_notes_recovery_state)
886
+ report["related_notes"] = {
887
+ "export_relocation": operation.export_relocation,
888
+ "recovery_progress": {
889
+ "fresh_record_count": recovery_state.fresh_record_count,
890
+ "partial_record_count": recovery_state.partial_record_count,
891
+ "stale_record_count": recovery_state.stale_record_count,
892
+ "record_count": recovery_state.record_count,
893
+ "total_note_count": recovery_state.total_note_count,
894
+ "remaining_count": recovery_state.remaining_count,
895
+ "embedded_count": recovery_state.embedded_count,
896
+ "reused_count": recovery_state.reused_count,
897
+ "attempt_count": recovery_state.attempt_count,
898
+ },
899
+ }
900
+ return JsonObjectAdapter.validate_python(report)
901
+
902
+
903
+ def _dict_from(value: object) -> JsonObject:
904
+ if isinstance(value, dict):
905
+ return JsonObjectAdapter.validate_python(value)
906
+ return {}
907
+
908
+
909
+ def _list_from(value: object) -> list[object]:
910
+ if isinstance(value, list):
911
+ return list(value)
912
+ return []
913
+
914
+
915
+ def _int_from(value: object) -> int:
916
+ if isinstance(value, int) and not isinstance(value, bool):
917
+ return value
918
+ return 0
919
+
920
+
921
+ def _diagnostic_context_from_model(
922
+ model: WorkflowModel,
923
+ state: MachineLinkRelatedState,
924
+ category: WorkflowStateCategory,
925
+ ) -> JsonObject:
926
+ if category == WorkflowStateCategory.COMPLETED:
927
+ return {}
928
+ context: JsonObject = {
929
+ "schema": "medical-notes-workbench.link-related-fsm-diagnostic-context.v2",
930
+ "state": state.value,
931
+ "category": category.value,
932
+ "reason": _machine_reason_code(model, state),
933
+ "source": "LinkRelatedMachine",
934
+ }
935
+ evidence = _machine_audit_evidence(model)
936
+ for key, value in evidence.items():
937
+ if key == "report_operation":
938
+ continue
939
+ if key not in context:
940
+ context[key] = value
941
+ return diagnostic_context_evidence_only(context)
942
+
943
+
944
+ def _machine_audit_evidence(model: WorkflowModel) -> JsonObject:
945
+ if not model.event_log:
946
+ return {}
947
+ event = _LinkRelatedMachineEventEvidence.model_validate(model.event_log[-1])
948
+ return JsonObjectAdapter.validate_python(event.audit_evidence)
949
+
950
+
951
+ def _machine_technical_context(
952
+ model: WorkflowModel,
953
+ state: MachineLinkRelatedState,
954
+ category: WorkflowStateCategory,
955
+ ) -> JsonObject:
956
+ event = _last_machine_event(model)
957
+ return JsonObjectAdapter.validate_python(
958
+ {
959
+ "reason": _machine_reason_code(model, state),
960
+ "category": category.value,
961
+ "source": "LinkRelatedMachine",
962
+ "trigger": _machine_trigger(model),
963
+ "fresh_record_count": _event_int(event, "fresh_record_count"),
964
+ "stale_record_count": _event_int(event, "stale_record_count"),
965
+ "remaining_count": _event_int(event, "remaining_count"),
966
+ "planned_note_count": _event_int(event, "planned_note_count"),
967
+ "changed_file_count": _event_int(event, "changed_file_count"),
968
+ }
969
+ )
970
+
971
+
972
+ def _machine_progress_status(category: WorkflowStateCategory) -> WorkflowProgressStatus:
973
+ match category:
974
+ case WorkflowStateCategory.PREPARING | WorkflowStateCategory.RUNNING:
975
+ return WorkflowProgressStatus.RUNNING
976
+ case WorkflowStateCategory.WAITING_AGENT:
977
+ return WorkflowProgressStatus.WAITING_AGENT
978
+ case WorkflowStateCategory.WAITING_EXTERNAL:
979
+ return WorkflowProgressStatus.WAITING_EXTERNAL
980
+ case WorkflowStateCategory.WAITING_HUMAN:
981
+ return WorkflowProgressStatus.WAITING_HUMAN
982
+ case WorkflowStateCategory.BLOCKED:
983
+ return WorkflowProgressStatus.BLOCKED
984
+ case WorkflowStateCategory.FAILED:
985
+ return WorkflowProgressStatus.FAILED
986
+ case WorkflowStateCategory.COMPLETED:
987
+ return WorkflowProgressStatus.COMPLETED
988
+ case WorkflowStateCategory.COMPLETED_WITH_WARNINGS:
989
+ return WorkflowProgressStatus.COMPLETED_WITH_WARNINGS
990
+
991
+
992
+ def _machine_receipt_status(status: WorkflowProgressStatus) -> ReceiptStatus:
993
+ match status:
994
+ case WorkflowProgressStatus.RUNNING:
995
+ return "running"
996
+ case WorkflowProgressStatus.COMPLETED:
997
+ return "completed"
998
+ case WorkflowProgressStatus.COMPLETED_WITH_WARNINGS:
999
+ return "completed_with_warnings"
1000
+ case WorkflowProgressStatus.WAITING_AGENT:
1001
+ return "waiting_agent"
1002
+ case WorkflowProgressStatus.WAITING_EXTERNAL:
1003
+ return "waiting_external"
1004
+ case WorkflowProgressStatus.WAITING_HUMAN:
1005
+ return "waiting_human"
1006
+ case WorkflowProgressStatus.FAILED:
1007
+ return "failed"
1008
+ case WorkflowProgressStatus.BLOCKED:
1009
+ return "blocked"
1010
+ case _:
1011
+ return "blocked"
1012
+
1013
+
1014
+ def _machine_event_type(status: WorkflowProgressStatus) -> WorkflowProgressEventType:
1015
+ match status:
1016
+ case WorkflowProgressStatus.COMPLETED | WorkflowProgressStatus.COMPLETED_WITH_WARNINGS:
1017
+ return WorkflowProgressEventType.WORKFLOW_COMPLETED
1018
+ case WorkflowProgressStatus.FAILED:
1019
+ return WorkflowProgressEventType.WORKFLOW_FAILED
1020
+ case WorkflowProgressStatus.WAITING_EXTERNAL:
1021
+ return WorkflowProgressEventType.EXTERNAL_WAIT_STARTED
1022
+ case WorkflowProgressStatus.WAITING_HUMAN | WorkflowProgressStatus.BLOCKED:
1023
+ return WorkflowProgressEventType.DECISION_EMITTED
1024
+ case _:
1025
+ return WorkflowProgressEventType.STATE_ENTERED
1026
+
1027
+
1028
+ def _machine_phase_for_state(state: MachineLinkRelatedState) -> str:
1029
+ match state:
1030
+ case MachineLinkRelatedState.CHECKING_EXPORT | MachineLinkRelatedState.EXPORT_REQUIRED:
1031
+ return "related_notes_export"
1032
+ case MachineLinkRelatedState.STALE_EXPORT:
1033
+ return "related_notes_export_recovery"
1034
+ case MachineLinkRelatedState.PREVIEW_READY | MachineLinkRelatedState.WAITING_HUMAN_CONFIRMATION:
1035
+ return "related_notes_preview"
1036
+ case MachineLinkRelatedState.APPLYING_RELATED_NOTES | MachineLinkRelatedState.COMPLETED:
1037
+ return "related_notes_apply"
1038
+ case MachineLinkRelatedState.WAITING_EXTERNAL_QUOTA:
1039
+ return "related_notes_recovery"
1040
+ case MachineLinkRelatedState.APPLY_CANCELLED:
1041
+ return "related_notes_apply_cancelled"
1042
+ case MachineLinkRelatedState.RELATED_NOTES_EXPORT_BLOCKED:
1043
+ return "related_notes_export_blocked"
1044
+ case MachineLinkRelatedState.RELATED_NOTES_PREVIEW_BLOCKED:
1045
+ return "related_notes_preview_blocked"
1046
+ case MachineLinkRelatedState.RELATED_NOTES_APPLY_BLOCKED:
1047
+ return "related_notes_apply_blocked"
1048
+ case MachineLinkRelatedState.FAILED:
1049
+ return "related_notes_failed"
1050
+
1051
+
1052
+ def _machine_message_for_state(state: MachineLinkRelatedState) -> str:
1053
+ match state:
1054
+ case MachineLinkRelatedState.EXPORT_REQUIRED:
1055
+ return "Export do Related Notes precisa ser gerado antes da sincronização."
1056
+ case MachineLinkRelatedState.STALE_EXPORT:
1057
+ return "Export do Related Notes ficou desatualizado."
1058
+ case MachineLinkRelatedState.PREVIEW_READY:
1059
+ return "Prévia das Notas Relacionadas pronta; nada foi alterado."
1060
+ case MachineLinkRelatedState.WAITING_HUMAN_CONFIRMATION:
1061
+ return "Preciso de confirmação antes de atualizar Notas Relacionadas."
1062
+ case MachineLinkRelatedState.APPLYING_RELATED_NOTES:
1063
+ return "Atualização das Notas Relacionadas está em execução."
1064
+ case MachineLinkRelatedState.WAITING_EXTERNAL_QUOTA:
1065
+ return "Notas Relacionadas aguardam cota externa para continuar."
1066
+ case MachineLinkRelatedState.COMPLETED:
1067
+ return "Notas Relacionadas atualizadas e conferidas."
1068
+ case MachineLinkRelatedState.APPLY_CANCELLED:
1069
+ return "Atualização das Notas Relacionadas cancelada antes de alterar o vault."
1070
+ case MachineLinkRelatedState.RELATED_NOTES_EXPORT_BLOCKED:
1071
+ return "Export das Notas Relacionadas bloqueado."
1072
+ case MachineLinkRelatedState.RELATED_NOTES_PREVIEW_BLOCKED:
1073
+ return "Prévia das Notas Relacionadas bloqueada."
1074
+ case MachineLinkRelatedState.RELATED_NOTES_APPLY_BLOCKED:
1075
+ return "Aplicação das Notas Relacionadas bloqueada."
1076
+ case MachineLinkRelatedState.FAILED:
1077
+ return "Notas Relacionadas falharam antes de concluir."
1078
+ case _:
1079
+ return "Workflow de Notas Relacionadas em andamento."
1080
+
1081
+
1082
+ def _machine_resume_action(model: WorkflowModel, state: MachineLinkRelatedState) -> str:
1083
+ if state == MachineLinkRelatedState.COMPLETED:
1084
+ return ""
1085
+ if model.last_transition is not None and model.last_transition.resume_action:
1086
+ return model.last_transition.resume_action
1087
+ match state:
1088
+ case MachineLinkRelatedState.EXPORT_REQUIRED | MachineLinkRelatedState.STALE_EXPORT:
1089
+ return "link-related:recover-export"
1090
+ case MachineLinkRelatedState.WAITING_HUMAN_CONFIRMATION:
1091
+ return "link-related:confirm-apply"
1092
+ case MachineLinkRelatedState.APPLYING_RELATED_NOTES:
1093
+ return "link-related:apply"
1094
+ case MachineLinkRelatedState.WAITING_EXTERNAL_QUOTA:
1095
+ return "link-related:retry-export"
1096
+ case (
1097
+ MachineLinkRelatedState.APPLY_CANCELLED
1098
+ | MachineLinkRelatedState.RELATED_NOTES_EXPORT_BLOCKED
1099
+ | MachineLinkRelatedState.RELATED_NOTES_PREVIEW_BLOCKED
1100
+ | MachineLinkRelatedState.RELATED_NOTES_APPLY_BLOCKED
1101
+ | MachineLinkRelatedState.FAILED
1102
+ ):
1103
+ return "link-related:diagnose"
1104
+ case _:
1105
+ return ""
1106
+
1107
+
1108
+ def _machine_reason_code(model: WorkflowModel, state: MachineLinkRelatedState) -> str:
1109
+ if model.last_transition is not None:
1110
+ return model.last_transition.reason_code
1111
+ return state.value
1112
+
1113
+
1114
+ def _machine_trigger(model: WorkflowModel) -> str:
1115
+ if model.last_transition is not None:
1116
+ return model.last_transition.trigger
1117
+ return ""
1118
+
1119
+
1120
+ def _machine_blockers(
1121
+ category: WorkflowStateCategory,
1122
+ model: WorkflowModel,
1123
+ state: MachineLinkRelatedState,
1124
+ ) -> list[str]:
1125
+ if category in {
1126
+ WorkflowStateCategory.WAITING_AGENT,
1127
+ WorkflowStateCategory.WAITING_EXTERNAL,
1128
+ WorkflowStateCategory.WAITING_HUMAN,
1129
+ WorkflowStateCategory.BLOCKED,
1130
+ WorkflowStateCategory.FAILED,
1131
+ }:
1132
+ return [_machine_reason_code(model, state)]
1133
+ return []
1134
+
1135
+
1136
+ def _error_context_from_model(
1137
+ model: WorkflowModel,
1138
+ state: MachineLinkRelatedState,
1139
+ category: WorkflowStateCategory,
1140
+ ) -> JsonObject:
1141
+ """Synthesize recovery context from the LinkRelatedMachine leaf state."""
1142
+
1143
+ if category not in {WorkflowStateCategory.BLOCKED, WorkflowStateCategory.FAILED}:
1144
+ return {}
1145
+ reason = _machine_reason_code(model, state) or state.value
1146
+ return JsonObjectAdapter.validate_python(
1147
+ {
1148
+ "blocked_reason": reason,
1149
+ "root_cause": reason,
1150
+ "affected_artifact": state.value,
1151
+ "next_action": _machine_resume_action(model, state) or "link-related:diagnose",
1152
+ "retry_scope": "link-related",
1153
+ }
1154
+ )
1155
+
1156
+
1157
+ def _machine_agent_instructions(category: WorkflowStateCategory) -> list[str]:
1158
+ if category == WorkflowStateCategory.WAITING_AGENT:
1159
+ return ["Execute somente os efeitos em agent_directive.control.effects e retome /mednotes:link-related pelo resultado tipado."]
1160
+ if category == WorkflowStateCategory.WAITING_EXTERNAL:
1161
+ return ["Aguarde a condição externa indicada antes de retomar /mednotes:link-related."]
1162
+ if category == WorkflowStateCategory.WAITING_HUMAN:
1163
+ return ["Peça a decisão humana fechada antes de atualizar Notas Relacionadas."]
1164
+ if category in {WorkflowStateCategory.BLOCKED, WorkflowStateCategory.FAILED}:
1165
+ return ["Use a decisão e o resume_action da FSM para recuperar /mednotes:link-related."]
1166
+ return ["Use a LinkRelatedMachine como fonte de verdade do estado de Notas Relacionadas."]
1167
+
1168
+
1169
+ def _last_machine_event(model: WorkflowModel) -> JsonObject | None:
1170
+ if not model.event_log:
1171
+ return None
1172
+ return model.event_log[-1]
1173
+
1174
+
1175
+ class _LinkRelatedMachineEventEvidence(ContractModel):
1176
+ """Typed lens over persisted link-related machine event evidence."""
1177
+
1178
+ model_config = ConfigDict(extra="ignore")
1179
+
1180
+ audit_evidence: JsonObject = Field(default_factory=dict)
1181
+
1182
+
1183
+ def _event_int(event: object, field_name: str) -> int:
1184
+ if isinstance(event, dict):
1185
+ if field_name in event:
1186
+ value = event[field_name]
1187
+ elif "observation" in event and isinstance(event["observation"], dict):
1188
+ observation = LinkRelatedRuntimeObservation.model_validate(event["observation"])
1189
+ value = getattr(observation, field_name, 0)
1190
+ else:
1191
+ value = 0
1192
+ else:
1193
+ value = getattr(event, field_name, 0) if event is not None else 0
1194
+ if isinstance(value, bool):
1195
+ return 0
1196
+ if isinstance(value, int) and value >= 0:
1197
+ return value
1198
+ return 0
1199
+
1200
+
1201
+ def _version_control_safety(value: VersionControlSafety | dict[str, object]) -> VersionControlSafety:
1202
+ if isinstance(value, VersionControlSafety):
1203
+ return value
1204
+ return VersionControlSafety.model_validate(value)
1205
+
1206
+
1207
+ def _link_related_runtime_observation(facts: _LinkRelatedRuntimeFacts) -> LinkRelatedRuntimeObservation:
1208
+ """Convert adapter output to facts; the machine owns final state priority."""
1209
+
1210
+ result = facts.sync_result
1211
+ recovery = result.related_notes_recovery_state
1212
+ reason = _link_related_runtime_reason_code(facts, fallback="")
1213
+ current = _fresh_current(recovery)
1214
+ total = recovery.total_note_count
1215
+ return LinkRelatedRuntimeObservation(
1216
+ mode=facts.mode,
1217
+ failed=_link_related_observed_failed(facts),
1218
+ export_missing=reason == "related_notes_export_missing",
1219
+ export_stale=reason
1220
+ in {
1221
+ "related_notes_export_stale",
1222
+ "related_notes_export_still_stale",
1223
+ "related_notes_hash_mismatch",
1224
+ "related_notes_vault_mismatch",
1225
+ },
1226
+ preview_ready=facts.mode != "apply" and result.status in {"preview_ready", "completed", "recovered"},
1227
+ applied=facts.mode == "apply" and result.status == "completed",
1228
+ blocked=result.status == "blocked" or bool(reason and not _link_related_waiting_external_from_recovery(result)),
1229
+ waiting_external=_link_related_waiting_external_from_recovery(result),
1230
+ planned_note_count=result.planned_note_count,
1231
+ proposed_link_count=result.proposed_link_count,
1232
+ cleared_link_count=result.cleared_link_count,
1233
+ applied_note_count=result.applied_note_count,
1234
+ fresh_record_count=current,
1235
+ stale_record_count=recovery.stale_record_count,
1236
+ remaining_count=_remaining_count(recovery, current=current, total=total),
1237
+ reason_code=reason,
1238
+ next_action=_link_related_default_next_action(facts, reason or result.status or "related_notes"),
1239
+ export_path=result.export_path or result.default_export_name,
1240
+ related_notes_recovery_state=recovery.to_payload(),
1241
+ )
1242
+
1243
+
1244
+ def _link_related_observed_failed(facts: _LinkRelatedRuntimeFacts) -> bool:
1245
+ result = facts.sync_result
1246
+ return bool(result.error.strip() or result.parse_error.strip() or result.status == "failed")
1247
+
1248
+
1249
+ def _link_related_waiting_external_from_recovery(result: LinkRelatedSyncResult) -> bool:
1250
+ recovery = result.related_notes_recovery_state
1251
+ reason = result.blocked_reason or recovery.blocked_reason
1252
+ return (
1253
+ result.status == "blocked"
1254
+ and recovery.status == "waiting_for_retry"
1255
+ and reason
1256
+ in {
1257
+ "related_notes_headless_quota_exhausted",
1258
+ "related_notes_headless_time_budget_exhausted",
1259
+ }
1260
+ )
1261
+
1262
+
1263
+ def _link_related_observation_fallback_reason(observation: LinkRelatedRuntimeObservation) -> str:
1264
+ if observation.failed:
1265
+ return "related_notes_failed"
1266
+ if observation.export_missing:
1267
+ return "related_notes_export_missing"
1268
+ if observation.export_stale:
1269
+ return "related_notes_export_stale"
1270
+ if observation.waiting_external:
1271
+ return "related_notes_quota_wait"
1272
+ if observation.blocked:
1273
+ return "related_notes_blocked"
1274
+ if observation.applied:
1275
+ return "completed"
1276
+ if observation.preview_ready:
1277
+ return "preview_ready"
1278
+ return "related_notes"
1279
+
1280
+
1281
+ def _link_related_default_next_action(facts: _LinkRelatedRuntimeFacts, reason: str) -> str:
1282
+ if facts.next_action.strip():
1283
+ return facts.next_action.strip()
1284
+ result_next = facts.sync_result.next_action.strip()
1285
+ if result_next:
1286
+ return result_next
1287
+ match reason:
1288
+ case "preview_ready":
1289
+ if facts.sync_result.planned_note_count == 0:
1290
+ return ""
1291
+ return "Revisar a prévia e confirmar a atualização das Notas Relacionadas."
1292
+ case "waiting_external_related_notes":
1293
+ return "Aguardar a condição externa e retomar /mednotes:link-related pela rota oficial."
1294
+ case "related_notes_blocked":
1295
+ return "Corrigir o bloqueio informado e repetir /mednotes:link-related pela rota oficial."
1296
+ case "failed":
1297
+ return "Revisar o erro e retomar /mednotes:link-related pela rota oficial."
1298
+ case "completed" | "recovered":
1299
+ return ""
1300
+ raise AssertionError(f"unsupported link-related reason: {reason}")
1301
+
1302
+
1303
+ def _link_related_artifacts(sync_result: LinkRelatedSyncResult) -> JsonObject:
1304
+ artifacts: JsonObject = {}
1305
+ if sync_result.export_path:
1306
+ artifacts["export_path"] = sync_result.export_path
1307
+ if sync_result.receipt_path:
1308
+ artifacts["receipt_path"] = sync_result.receipt_path
1309
+ return artifacts
1310
+
1311
+
1312
+ def _link_related_changed_files(sync_result: LinkRelatedSyncResult) -> list[str]:
1313
+ return [
1314
+ update.path
1315
+ for update in sync_result.updates
1316
+ if update.changed and update.path
1317
+ ]
1318
+
1319
+
1320
+ def _link_related_error_context_from_result(result: LinkRelatedSyncResult) -> JsonObject:
1321
+ evidence = _LinkRelatedErrorPayloadFields.model_validate(result.operation_payload)
1322
+ blocked_reason = (
1323
+ result.blocked_reason.strip()
1324
+ or result.skipped_reason.strip()
1325
+ or result.error.strip()
1326
+ or result.parse_error.strip()
1327
+ )
1328
+ if not blocked_reason and not evidence.has_error_detail:
1329
+ return {}
1330
+ next_action = result.next_action or "Revisar o erro e retomar /mednotes:link-related pela rota oficial."
1331
+ context: JsonObject = {
1332
+ "blocked_reason": blocked_reason,
1333
+ "root_cause": blocked_reason or "related_notes_error",
1334
+ "affected_artifact": "related_notes_export",
1335
+ "next_action": next_action,
1336
+ }
1337
+ for key in (
1338
+ "validation_errors",
1339
+ "contract_errors",
1340
+ "hash_errors",
1341
+ "stale_notes",
1342
+ "forbidden_keys",
1343
+ "detail",
1344
+ "selected_recovery_mode",
1345
+ "command_returncode",
1346
+ "error",
1347
+ "parse_error",
1348
+ "skipped_reason",
1349
+ ):
1350
+ value = getattr(evidence, key)
1351
+ if value not in (None, "", [], {}):
1352
+ context[key] = value
1353
+ return JsonObjectAdapter.validate_python(context)
1354
+
1355
+
1356
+ def assert_link_related_fsm_payload(payload: JsonObject) -> None:
1357
+ payload = JsonObjectAdapter.validate_python(payload)
1358
+ forbidden_keys = set(payload) & LINK_RELATED_FORBIDDEN_ROOT_KEYS
1359
+ if forbidden_keys:
1360
+ raise ValueError(f"link-related FSM payload contains non-FSM root fields: {sorted(forbidden_keys)}")
1361
+ required_root_keys = LINK_RELATED_ALLOWED_ROOT_KEYS - {"diagnostic_context"}
1362
+ missing_keys = required_root_keys - set(payload)
1363
+ if missing_keys:
1364
+ raise ValueError(f"link-related FSM payload missing canonical root fields: {sorted(missing_keys)}")
1365
+ unexpected_keys = set(payload) - LINK_RELATED_ALLOWED_ROOT_KEYS
1366
+ if unexpected_keys:
1367
+ raise ValueError(f"link-related FSM payload contains unexpected root fields: {sorted(unexpected_keys)}")
1368
+ fields = _link_related_payload_fields(payload)
1369
+ assert_diagnostic_context_evidence_only(fields.diagnostic_context)
1370
+ if "agent_directive" in fields.diagnostic_context:
1371
+ raise ValueError("link-related FSM diagnostic_context must not contain agent_directive")
1372
+ if fields.workflow != LINK_RELATED_WORKFLOW:
1373
+ raise ValueError("link-related FSM payload has invalid workflow")
1374
+ if fields.progress_view_model.status != fields.state_machine_snapshot.current_category:
1375
+ raise ValueError("link-related FSM status must match state_machine_snapshot category")
1376
+ if fields.receipt.status != fields.progress_view_model.status:
1377
+ raise ValueError("link-related FSM receipt status must match progress view status")
1378
+ if fields.progress_view_model.status in {
1379
+ WorkflowStateCategory.BLOCKED.value,
1380
+ WorkflowStateCategory.FAILED.value,
1381
+ } and not payload["error_context"]:
1382
+ raise ValueError("link-related FSM blocked/failed payload requires error_context")
1383
+ reports_model = WorkflowReports.model_validate(payload["reports"])
1384
+ snapshot = WorkflowStateMachineSnapshot.model_validate(payload["state_machine_snapshot"])
1385
+ progress_view_model = WorkflowProgressViewModel.model_validate(payload["progress_view_model"])
1386
+ assert_public_report_matches_progress(
1387
+ reports_model.public_report,
1388
+ workflow=LINK_RELATED_WORKFLOW,
1389
+ run_id=str(payload["run_id"]),
1390
+ progress_view_model=progress_view_model,
1391
+ label="link-related FSM",
1392
+ )
1393
+ assert_agent_directive_matches_progress(
1394
+ AgentDirective.model_validate(payload[LINK_RELATED_AGENT_DIRECTIVE_FIELD]),
1395
+ workflow=LINK_RELATED_WORKFLOW,
1396
+ run_id=str(payload["run_id"]),
1397
+ progress_view_model=progress_view_model,
1398
+ snapshot=snapshot,
1399
+ allowed_effect_kinds=_allowed_agent_effect_kinds_for_category(snapshot.current_category),
1400
+ label="link-related FSM",
1401
+ )
1402
+ _assert_link_related_snapshot(snapshot)
1403
+
1404
+
1405
+ def _allowed_agent_effect_kinds_for_category(category: WorkflowStateCategory) -> set[WorkflowEffectKind]:
1406
+ """Keep Related Notes recovery effects tied to the current FSM lane."""
1407
+
1408
+ match category:
1409
+ case WorkflowStateCategory.WAITING_AGENT:
1410
+ return {WorkflowEffectKind.RUN_SUBWORKFLOW}
1411
+ case WorkflowStateCategory.WAITING_EXTERNAL:
1412
+ return {WorkflowEffectKind.WAIT_EXTERNAL}
1413
+ case WorkflowStateCategory.WAITING_HUMAN:
1414
+ return {WorkflowEffectKind.ASK_HUMAN}
1415
+ case _:
1416
+ return set()
1417
+
1418
+
1419
+ def _assert_link_related_snapshot(snapshot: WorkflowStateMachineSnapshot) -> None:
1420
+ if snapshot.workflow != LINK_RELATED_WORKFLOW:
1421
+ raise ValueError("link-related FSM snapshot has invalid workflow")
1422
+ if snapshot.current_category != category_for_state(snapshot.current_state):
1423
+ raise ValueError("link-related FSM snapshot category does not match state")
1424
+ edges = _link_related_machine_edges()
1425
+ for transition in snapshot.transitions:
1426
+ if transition.to_category != category_for_state(transition.to_state):
1427
+ raise ValueError("link-related FSM transition category does not match state")
1428
+ edge = (transition.trigger, transition.from_state, transition.to_state)
1429
+ if edge not in edges:
1430
+ raise ValueError(f"unauthorized FSM transition: {edge}")
1431
+
1432
+
1433
+ def _link_related_machine_edges() -> set[tuple[str, str, str]]:
1434
+ """Return every transition edge declared by the canonical LinkRelatedMachine."""
1435
+
1436
+ edges: set[tuple[str, str, str]] = set()
1437
+ for event in LinkRelatedMachine.events:
1438
+ for transition in event._transitions:
1439
+ for target in transition._targets:
1440
+ edges.add((event.id, str(transition.source.value), str(target.value)))
1441
+ return edges
1442
+
1443
+
1444
+ def _link_related_payload_fields(payload: JsonObject) -> _LinkRelatedPayloadFields:
1445
+ raw_fields: JsonObject = {
1446
+ "workflow": payload["workflow"],
1447
+ "progress_view_model": _json_object_subset(payload, "progress_view_model", ("status",)),
1448
+ "state_machine_snapshot": _json_object_subset(payload, "state_machine_snapshot", ("current_category",)),
1449
+ "receipt": _json_object_subset(payload, "receipt", ("status",)),
1450
+ "diagnostic_context": payload["diagnostic_context"] if "diagnostic_context" in payload else {},
1451
+ }
1452
+ try:
1453
+ return _LinkRelatedPayloadFields.model_validate(raw_fields)
1454
+ except PydanticValidationError as exc:
1455
+ first = exc.errors()[0] if exc.errors() else {}
1456
+ loc = ".".join(str(part) for part in first.get("loc", ())) or "$"
1457
+ msg = str(first.get("msg") or str(exc))
1458
+ raise ValueError(f"link-related FSM payload invalid: {loc}: {msg}") from exc
1459
+
1460
+
1461
+ def _json_object_subset(payload: JsonObject, field_name: str, keys: tuple[str, ...]) -> JsonObject:
1462
+ try:
1463
+ source = JsonObjectAdapter.validate_python(payload[field_name])
1464
+ except PydanticValidationError as exc:
1465
+ raise ValueError(f"link-related FSM payload invalid: {field_name} must be an object") from exc
1466
+ return {key: source[key] for key in keys if key in source}
1467
+
1468
+
1469
+ def link_related_cli_exit_code(payload: JsonObject) -> int:
1470
+ progress = _LinkRelatedPayloadProgressViewFields.model_validate(
1471
+ _json_object_subset(payload, "progress_view_model", ("status", "state"))
1472
+ )
1473
+ status = progress.status
1474
+ match status:
1475
+ case "completed" | "completed_with_warnings":
1476
+ return 0
1477
+ case "waiting_human" if progress.state in {
1478
+ MachineLinkRelatedState.PREVIEW_READY.value,
1479
+ MachineLinkRelatedState.WAITING_HUMAN_CONFIRMATION.value,
1480
+ }:
1481
+ return 0
1482
+ case "waiting_agent" | "waiting_external" | "waiting_human" | "blocked":
1483
+ return 3
1484
+ case "failed":
1485
+ return 5
1486
+ case _:
1487
+ return 1
1488
+
1489
+
1490
+ class RelatedNotesRecoveryMachineState(StrEnum):
1491
+ """Small StateChart used when Related Notes recovery is embedded by a parent workflow."""
1492
+
1493
+ RECOVERING_RELATED_NOTES = _RECOVERING_STATE
1494
+ WAITING_EXTERNAL_QUOTA = _WAITING_QUOTA_STATE
1495
+ RELATED_NOTES_RECOVERY_BLOCKED = _RECOVERY_BLOCKED_STATE
1496
+
1497
+
1498
+ class RelatedNotesRecoveryEvent(ContractModel):
1499
+ """Base event for the embedded Related Notes recovery StateChart."""
1500
+
1501
+ workflow: str = Field(min_length=1)
1502
+ run_id: str = Field(min_length=1)
1503
+ current_state: str = Field(min_length=1)
1504
+
1505
+
1506
+ class RelatedNotesRecoveryQuotaWaitEvent(RelatedNotesRecoveryEvent):
1507
+ name: Literal["related_notes_quota_wait"] = "related_notes_quota_wait"
1508
+ reason_code: str = Field(default="related_notes_quota_wait", min_length=1)
1509
+ next_action: str = Field(min_length=1)
1510
+ related_notes_recovery_state: RelatedNotesRecoveryStateEffectPayload = Field(
1511
+ default_factory=RelatedNotesRecoveryStateEffectPayload
1512
+ )
1513
+
1514
+ @field_validator("related_notes_recovery_state", mode="before")
1515
+ @classmethod
1516
+ def _coerce_recovery_state(cls, value: object) -> RelatedNotesRecoveryStateEffectPayload:
1517
+ return RelatedNotesRecoveryStateEffectPayload.from_payload(value)
1518
+
1519
+
1520
+ class RelatedNotesRecoveryQuotaReadyEvent(RelatedNotesRecoveryEvent):
1521
+ name: Literal["related_notes_quota_ready"] = "related_notes_quota_ready"
1522
+ restored_by: str = Field(min_length=1)
1523
+
1524
+
1525
+ class RelatedNotesRecoveryBlockedEvent(RelatedNotesRecoveryEvent):
1526
+ name: Literal["related_notes_recovery_blocked"] = "related_notes_recovery_blocked"
1527
+ reason_code: str = Field(min_length=1)
1528
+ next_action: str = Field(min_length=1)
1529
+
1530
+
1531
+ RelatedNotesRecoveryBoundaryEvent = Annotated[
1532
+ RelatedNotesRecoveryQuotaWaitEvent | RelatedNotesRecoveryQuotaReadyEvent | RelatedNotesRecoveryBlockedEvent,
1533
+ Field(discriminator="name"),
1534
+ ]
1535
+
1536
+
1537
+ class RelatedNotesRecoveryMachine(StateChart[WorkflowModel]):
1538
+ """StateChart for resumable Related Notes recovery embedded in parent workflows."""
1539
+
1540
+ allow_event_without_transition = False
1541
+ catch_errors_as_events = False
1542
+ states = States.from_enum(
1543
+ RelatedNotesRecoveryMachineState,
1544
+ initial=RelatedNotesRecoveryMachineState.RECOVERING_RELATED_NOTES,
1545
+ final={RelatedNotesRecoveryMachineState.RELATED_NOTES_RECOVERY_BLOCKED},
1546
+ use_enum_instance=False,
1547
+ )
1548
+
1549
+ related_notes_quota_wait = states.RECOVERING_RELATED_NOTES.to(
1550
+ states.WAITING_EXTERNAL_QUOTA,
1551
+ on="_on_quota_wait",
1552
+ )
1553
+ related_notes_quota_ready = states.WAITING_EXTERNAL_QUOTA.to(
1554
+ states.RECOVERING_RELATED_NOTES,
1555
+ on="_on_transition",
1556
+ )
1557
+ related_notes_recovery_blocked = states.RECOVERING_RELATED_NOTES.to(
1558
+ states.RELATED_NOTES_RECOVERY_BLOCKED,
1559
+ on="_on_blocked",
1560
+ )
1561
+
1562
+ def category_for_state(self, state: str) -> WorkflowStateCategory:
1563
+ return _category_for_recovery_state(RelatedNotesRecoveryMachineState(state))
1564
+
1565
+ def _on_transition(
1566
+ self,
1567
+ workflow_event: RelatedNotesRecoveryEvent,
1568
+ target: object,
1569
+ ) -> WorkflowTransitionResult:
1570
+ return _recovery_transition(
1571
+ workflow_event,
1572
+ _recovery_target_state(target),
1573
+ reason_code=str(getattr(workflow_event, "name", "")),
1574
+ )
1575
+
1576
+ def _on_quota_wait(
1577
+ self,
1578
+ workflow_event: RelatedNotesRecoveryQuotaWaitEvent,
1579
+ target: object,
1580
+ ) -> WorkflowTransitionResult:
1581
+ to_state = _recovery_target_state(target)
1582
+ effect = WorkflowEffect(
1583
+ workflow=workflow_event.workflow,
1584
+ run_id=workflow_event.run_id,
1585
+ effect_id="related-notes-recovery-quota-wait",
1586
+ origin_state=to_state.value,
1587
+ kind=WorkflowEffectKind.WAIT_EXTERNAL,
1588
+ target="related_notes.quota",
1589
+ payload=WaitExternalEffectPayload(
1590
+ related_notes_recovery_state=workflow_event.related_notes_recovery_state,
1591
+ next_action=workflow_event.next_action,
1592
+ ).to_payload(),
1593
+ requires_receipt=False,
1594
+ no_resource_mutation=True,
1595
+ resume_action=workflow_event.next_action,
1596
+ )
1597
+ return _recovery_transition(
1598
+ workflow_event,
1599
+ to_state,
1600
+ reason_code=workflow_event.reason_code,
1601
+ effects=[effect],
1602
+ resume_action=workflow_event.next_action,
1603
+ )
1604
+
1605
+ def _on_blocked(
1606
+ self,
1607
+ workflow_event: RelatedNotesRecoveryBlockedEvent,
1608
+ target: object,
1609
+ ) -> WorkflowTransitionResult:
1610
+ to_state = _recovery_target_state(target)
1611
+ return _recovery_transition(
1612
+ workflow_event,
1613
+ to_state,
1614
+ reason_code=workflow_event.reason_code,
1615
+ decision=_hard_block_decision(
1616
+ reason_code=workflow_event.reason_code,
1617
+ next_action=workflow_event.next_action,
1618
+ ),
1619
+ )
1620
+
1621
+
1622
+ def _category_for_recovery_state(state: RelatedNotesRecoveryMachineState) -> WorkflowStateCategory:
1623
+ match state:
1624
+ case RelatedNotesRecoveryMachineState.RECOVERING_RELATED_NOTES:
1625
+ return WorkflowStateCategory.RUNNING
1626
+ case RelatedNotesRecoveryMachineState.WAITING_EXTERNAL_QUOTA:
1627
+ return WorkflowStateCategory.WAITING_EXTERNAL
1628
+ case RelatedNotesRecoveryMachineState.RELATED_NOTES_RECOVERY_BLOCKED:
1629
+ return WorkflowStateCategory.BLOCKED
1630
+
1631
+
1632
+ def _recovery_target_state(target: object) -> RelatedNotesRecoveryMachineState:
1633
+ value = getattr(target, "value", target)
1634
+ return RelatedNotesRecoveryMachineState(str(value))
1635
+
1636
+
1637
+ def _recovery_transition(
1638
+ workflow_event: RelatedNotesRecoveryEvent,
1639
+ to_state: RelatedNotesRecoveryMachineState,
1640
+ *,
1641
+ reason_code: str,
1642
+ effects: list[WorkflowEffect] | None = None,
1643
+ decision: WorkflowDecision | None = None,
1644
+ resume_action: str = "",
1645
+ ) -> WorkflowTransitionResult:
1646
+ return WorkflowTransitionResult(
1647
+ workflow=workflow_event.workflow,
1648
+ run_id=workflow_event.run_id,
1649
+ from_state=workflow_event.current_state,
1650
+ to_state=to_state.value,
1651
+ trigger=str(getattr(workflow_event, "name", "")),
1652
+ reason_code=reason_code,
1653
+ effects=list(effects or []),
1654
+ decision=decision,
1655
+ resume_action=resume_action,
1656
+ )
1657
+
1658
+
1659
+ def _related_notes_recovery_model_after_event(
1660
+ *,
1661
+ workflow: str,
1662
+ run_id: str,
1663
+ event: RelatedNotesRecoveryBoundaryEvent,
1664
+ ) -> WorkflowModel:
1665
+ model = WorkflowModel.start(
1666
+ workflow=workflow,
1667
+ run_id=run_id,
1668
+ initial_state=RelatedNotesRecoveryMachineState.RECOVERING_RELATED_NOTES.value,
1669
+ )
1670
+ send_workflow_event(
1671
+ RelatedNotesRecoveryMachine(model=model, state_field=WorkflowModel.STATECHART_STATE_FIELD),
1672
+ event,
1673
+ )
1674
+ return model
1675
+
1676
+
1677
+ def _recovery_snapshot_transition(transition: WorkflowTransitionResult) -> WorkflowTransition:
1678
+ return WorkflowTransition(
1679
+ workflow=transition.workflow,
1680
+ from_state=transition.from_state,
1681
+ to_state=transition.to_state,
1682
+ to_category=_category_for_recovery_state(RelatedNotesRecoveryMachineState(transition.to_state)),
1683
+ trigger=transition.trigger,
1684
+ effects=list(transition.effects),
1685
+ decision=transition.decision,
1686
+ resume_action=transition.resume_action,
1687
+ )
1688
+
1689
+
1690
+ @dataclass(frozen=True)
1691
+ class RelatedNotesRecoveryMachineProjection:
1692
+ """Recovery progress lens derived only from `RelatedNotesRecoveryMachine`.
1693
+
1694
+ The input is typed Related Notes recovery evidence, and the carrier state is
1695
+ the recovery StateChart. This object serializes that machine view; it does
1696
+ not define a parallel recovery status.
1697
+ """
1698
+
1699
+ progress_state: WorkflowProgressState
1700
+ progress_view_model: WorkflowProgressViewModel
1701
+ snapshot: WorkflowStateMachineSnapshot
1702
+
1703
+ def to_payload(self) -> JsonObject:
1704
+ return JsonObjectAdapter.validate_python(
1705
+ {
1706
+ "progress_view_model": self.progress_view_model.to_payload(),
1707
+ "state_machine_snapshot": self.snapshot.to_payload(),
1708
+ }
1709
+ )
1710
+
1711
+
1712
+ def build_related_notes_recovery_projection(
1713
+ *,
1714
+ workflow: str,
1715
+ run_id: str,
1716
+ recovery_state: object,
1717
+ next_action: str,
1718
+ ) -> RelatedNotesRecoveryMachineProjection:
1719
+ typed_recovery = RelatedNotesRecoveryState.from_payload(recovery_state)
1720
+ reason_code = typed_recovery.blocked_reason or "related_notes_recovery_blocked"
1721
+ waiting_external = typed_recovery.status == "waiting_for_retry"
1722
+ current = _fresh_current(typed_recovery)
1723
+ total = typed_recovery.total_note_count
1724
+ remaining = _remaining_count(typed_recovery, current=current, total=total)
1725
+ if total and current > total:
1726
+ current = max(0, total - remaining) if remaining else total
1727
+ counts = WorkflowProgressCounts(
1728
+ planned_items=total,
1729
+ processed_items=current,
1730
+ cache_hits=typed_recovery.reused_count,
1731
+ api_calls=typed_recovery.embedded_count,
1732
+ remaining_items=remaining,
1733
+ blocked_items=0 if waiting_external else remaining or total,
1734
+ )
1735
+
1736
+ if waiting_external:
1737
+ event: RelatedNotesRecoveryBoundaryEvent = RelatedNotesRecoveryQuotaWaitEvent(
1738
+ workflow=workflow,
1739
+ run_id=run_id,
1740
+ current_state=RelatedNotesRecoveryMachineState.RECOVERING_RELATED_NOTES.value,
1741
+ reason_code=reason_code,
1742
+ next_action=next_action,
1743
+ related_notes_recovery_state=typed_recovery,
1744
+ )
1745
+ resume_supported = typed_recovery.resume_supported
1746
+ can_continue_now = False
1747
+ else:
1748
+ event = RelatedNotesRecoveryBlockedEvent(
1749
+ workflow=workflow,
1750
+ run_id=run_id,
1751
+ current_state=RelatedNotesRecoveryMachineState.RECOVERING_RELATED_NOTES.value,
1752
+ reason_code=reason_code,
1753
+ next_action=next_action,
1754
+ )
1755
+ resume_supported = False
1756
+ can_continue_now = False
1757
+
1758
+ model = _related_notes_recovery_model_after_event(workflow=workflow, run_id=run_id, event=event)
1759
+ state = RelatedNotesRecoveryMachineState(model.state)
1760
+ category = _category_for_recovery_state(state)
1761
+ status = _machine_progress_status(category)
1762
+ event_type = (
1763
+ WorkflowProgressEventType.EXTERNAL_WAIT_STARTED
1764
+ if status == WorkflowProgressStatus.WAITING_EXTERNAL
1765
+ else WorkflowProgressEventType.DECISION_EMITTED
1766
+ )
1767
+ transition = model.last_transition
1768
+ if transition is None:
1769
+ raise ValueError("related-notes recovery event did not produce a machine transition")
1770
+ decision = transition.decision
1771
+ resume_action = transition.resume_action
1772
+
1773
+ progress_state = WorkflowProgressState(
1774
+ workflow=workflow,
1775
+ run_id=run_id,
1776
+ state=state.value,
1777
+ phase=_PHASE,
1778
+ event_type=event_type,
1779
+ message=_message_for(waiting_external=waiting_external, reason_code=reason_code),
1780
+ status=status,
1781
+ current=current,
1782
+ total=total,
1783
+ counts=counts,
1784
+ resume_action=resume_action,
1785
+ resume_supported=resume_supported,
1786
+ can_continue_now=can_continue_now,
1787
+ decision=decision.decision_summary() if decision is not None else None,
1788
+ technical_context={
1789
+ "recovery_state_status": typed_recovery.status,
1790
+ "blocked_reason": reason_code,
1791
+ "attempt_count": typed_recovery.attempt_count,
1792
+ "fresh_record_count": typed_recovery.fresh_record_count,
1793
+ "stale_record_count": typed_recovery.stale_record_count,
1794
+ "total_note_count": total,
1795
+ "remaining_count": remaining,
1796
+ },
1797
+ )
1798
+ snapshot = WorkflowStateMachineSnapshot(
1799
+ workflow=workflow,
1800
+ run_id=run_id,
1801
+ current_state=state.value,
1802
+ current_category=category,
1803
+ transitions=[_recovery_snapshot_transition(item) for item in model.transition_log],
1804
+ metadata={
1805
+ "source": "RelatedNotesRecoveryMachine",
1806
+ "recovery_state_schema": typed_recovery.schema_id,
1807
+ "blocked_reason": reason_code,
1808
+ },
1809
+ )
1810
+
1811
+ return RelatedNotesRecoveryMachineProjection(
1812
+ progress_state=progress_state,
1813
+ progress_view_model=build_progress_view_model(progress_state),
1814
+ snapshot=snapshot,
1815
+ )
1816
+
1817
+
1818
+ def _hard_block_decision(*, reason_code: str, next_action: str) -> WorkflowDecision:
1819
+ return WorkflowDecision(
1820
+ kind="hard_block",
1821
+ phase=_PHASE,
1822
+ reason_code=reason_code,
1823
+ public_summary="A recuperacao do Related Notes esta bloqueada antes de alterar a Wiki.",
1824
+ developer_summary="Related Notes recovery emitted a hard block before vault mutation.",
1825
+ evidence=[
1826
+ DecisionEvidence(
1827
+ summary="A recuperacao informou bloqueio operacional.",
1828
+ technical_code=reason_code,
1829
+ source="related_notes_recovery_state",
1830
+ )
1831
+ ],
1832
+ next_action=next_action,
1833
+ )
1834
+
1835
+
1836
+ def _fresh_current(payload: RelatedNotesRecoveryState | dict[str, object]) -> int:
1837
+ state = RelatedNotesRecoveryState.from_payload(payload)
1838
+ return state.fresh_record_count or state.partial_record_count
1839
+
1840
+
1841
+ def _remaining_count(payload: RelatedNotesRecoveryState | dict[str, object], *, current: int, total: int) -> int:
1842
+ value = RelatedNotesRecoveryState.from_payload(payload).remaining_count
1843
+ if value:
1844
+ return min(value, total) if total else value
1845
+ return max(0, total - current)
1846
+
1847
+
1848
+ def _message_for(*, waiting_external: bool, reason_code: str) -> str:
1849
+ if waiting_external:
1850
+ if reason_code == "related_notes_headless_quota_exhausted":
1851
+ return "Related Notes aguardando cota externa para retomar pela rota oficial."
1852
+ if reason_code == "related_notes_headless_time_budget_exhausted":
1853
+ return "Related Notes pausou a indexação para evitar uma execução longa; a próxima tentativa retoma do índice parcial."
1854
+ return "Related Notes aguardando condição externa para retomar pela rota oficial."
1855
+ return f"Related Notes bloqueado: {reason_code}."