mednotes-opencode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opencode/agents/med-chat-triager.md +204 -0
- package/.opencode/agents/med-flashcard-maker.md +63 -0
- package/.opencode/agents/med-knowledge-architect.md +230 -0
- package/.opencode/agents/med-link-graph-curator.md +177 -0
- package/.opencode/agents/med-publish-guard.md +62 -0
- package/.opencode/commands/flashcards.md +25 -0
- package/.opencode/commands/mednotes/create.md +25 -0
- package/.opencode/commands/mednotes/enrich.md +27 -0
- package/.opencode/commands/mednotes/fix-wiki.md +27 -0
- package/.opencode/commands/mednotes/history.md +22 -0
- package/.opencode/commands/mednotes/link-body.md +25 -0
- package/.opencode/commands/mednotes/link-related.md +27 -0
- package/.opencode/commands/mednotes/link.md +27 -0
- package/.opencode/commands/mednotes/pdf-library.md +27 -0
- package/.opencode/commands/mednotes/process-chats.md +23 -0
- package/.opencode/commands/mednotes/setup.md +21 -0
- package/.opencode/commands/mednotes/status.md +27 -0
- package/.opencode/commands/mednotes/telemetry.md +27 -0
- package/.opencode/commands/report.md +26 -0
- package/.opencode/mednotes/AGENTS.md +57 -0
- package/.opencode/mednotes/agents/med-chat-triager.md +197 -0
- package/.opencode/mednotes/agents/med-flashcard-maker.md +56 -0
- package/.opencode/mednotes/agents/med-knowledge-architect.md +224 -0
- package/.opencode/mednotes/agents/med-link-graph-curator.md +171 -0
- package/.opencode/mednotes/agents/med-publish-guard.md +55 -0
- package/.opencode/mednotes/contracts/.gitkeep +1 -0
- package/.opencode/mednotes/contracts/agents.json +116 -0
- package/.opencode/mednotes/contracts/opencode-plugin.json +70 -0
- package/.opencode/mednotes/docs/agent-prompt-hardening.md +567 -0
- package/.opencode/mednotes/docs/agent-role-contracts.md +94 -0
- package/.opencode/mednotes/docs/anki-mcp-twenty-rules.md +214 -0
- package/.opencode/mednotes/docs/anki-templates/README.md +39 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.back.html +23 -0
- package/.opencode/mednotes/docs/anki-templates/cloze.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/qa.back.html +24 -0
- package/.opencode/mednotes/docs/anki-templates/qa.front.html +14 -0
- package/.opencode/mednotes/docs/anki-templates/style.css +182 -0
- package/.opencode/mednotes/docs/atomicity-splitting-policy.md +113 -0
- package/.opencode/mednotes/docs/extension-docs.md +40 -0
- package/.opencode/mednotes/docs/flashcard-ingestion.md +278 -0
- package/.opencode/mednotes/docs/knowledge-architect.md +208 -0
- package/.opencode/mednotes/docs/merge-policy.md +110 -0
- package/.opencode/mednotes/docs/public-vocabulary.md +104 -0
- package/.opencode/mednotes/docs/semantic-linker.md +141 -0
- package/.opencode/mednotes/docs/taxonomy-policy.md +90 -0
- package/.opencode/mednotes/docs/triage-policy.md +187 -0
- package/.opencode/mednotes/docs/vault-version-control.md +758 -0
- package/.opencode/mednotes/docs/vocabulary-db-recovery.md +58 -0
- package/.opencode/mednotes/docs/workflow-output-contract.md +779 -0
- package/.opencode/mednotes/hooks/hooks.json +79 -0
- package/.opencode/mednotes/package-lock.json +6361 -0
- package/.opencode/mednotes/package.json +15 -0
- package/.opencode/mednotes/pyproject.toml +48 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/bootstrap_windows_python_uv.ps1 +172 -0
- package/.opencode/mednotes/scripts/enrich_notes.py +23 -0
- package/.opencode/mednotes/scripts/full_reset_windows_python_uv.cmd +13 -0
- package/.opencode/mednotes/scripts/hooks/antigravity_hook_status.mjs +212 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/cli.mjs +143 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/retention.mjs +114 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook/vault_guard.mjs +624 -0
- package/.opencode/mednotes/scripts/hooks/mednotes_hook.mjs +5 -0
- package/.opencode/mednotes/scripts/mednotes/_runtime_paths.py +24 -0
- package/.opencode/mednotes/scripts/mednotes/anki_model_validator.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/capture_extension_diff.py +1562 -0
- package/.opencode/mednotes/scripts/mednotes/feedback_report.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_index.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_pipeline.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_report.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/flashcard_sources.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian/README.md +6 -0
- package/.opencode/mednotes/scripts/mednotes/obsidian_note_utils.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/pdf_library/cli.py +16 -0
- package/.opencode/mednotes/scripts/mednotes/project_fsm.py +229 -0
- package/.opencode/mednotes/scripts/mednotes/setup_telemetry_email.py +404 -0
- package/.opencode/mednotes/scripts/mednotes/sync_anki_twenty_rules.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/sync_opencode_user_config.py +36 -0
- package/.opencode/mednotes/scripts/mednotes/wiki/cli.py +20 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_graph.py +18 -0
- package/.opencode/mednotes/scripts/mednotes/wiki_tree.py +134 -0
- package/.opencode/mednotes/scripts/reset_windows_python_uv.ps1 +625 -0
- package/.opencode/mednotes/scripts/run_python.mjs +109 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_commit.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_git.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_git.py +3107 -0
- package/.opencode/mednotes/scripts/vault/vault_git.sh +18 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.ps1 +19 -0
- package/.opencode/mednotes/scripts/vault/vault_precommit.sh +18 -0
- package/.opencode/mednotes/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/.opencode/mednotes/skills/create-medical-flashcards/SKILL.md +113 -0
- package/.opencode/mednotes/skills/create-medical-note/SKILL.md +90 -0
- package/.opencode/mednotes/skills/enrich-medical-note/SKILL.md +120 -0
- package/.opencode/mednotes/skills/fix-medical-wiki/SKILL.md +559 -0
- package/.opencode/mednotes/skills/link-medical-wiki/SKILL.md +224 -0
- package/.opencode/mednotes/skills/obsidian-cli/SKILL.md +118 -0
- package/.opencode/mednotes/skills/obsidian-markdown/SKILL.md +207 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/.opencode/mednotes/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/.opencode/mednotes/skills/obsidian-ops/SKILL.md +136 -0
- package/.opencode/mednotes/skills/pdf-library/SKILL.md +45 -0
- package/.opencode/mednotes/skills/process-medical-chats/SKILL.md +246 -0
- package/.opencode/mednotes/skills/workflow-report/SKILL.md +100 -0
- package/.opencode/mednotes/src/mednotes/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/README.md +26 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/build_demo_apkg.py +177 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/contracts.py +385 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/flashcards_machine.py +522 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/fsm.py +817 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/index.py +630 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/install_models.py +445 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/model.py +359 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_links.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/obsidian_note_utils.py +546 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/pipeline.py +580 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/report.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sources.py +682 -0
- package/.opencode/mednotes/src/mednotes/domains/flashcards/sync_rules.py +184 -0
- package/.opencode/mednotes/src/mednotes/domains/history/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_fsm.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/history/history_machine.py +453 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/__init__.py +7 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_fsm.py +808 -0
- package/.opencode/mednotes/src/mednotes/domains/setup/setup_machine.py +973 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/README.md +64 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/api.py +668 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/batch_state.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/atomicity/atomicity.py +877 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/body_link/body_linker.py +1562 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/effect_adapters.py +949 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/effects/fix_wiki_runtime_adapters.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/coverage.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph.py +396 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/graph/graph_fixes.py +161 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/hygiene/hygiene.py +483 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/anchors.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/__init__.py +0 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/cache.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/config.py +131 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/download.py +224 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/frontmatter.py +59 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/insert.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/core/local_import.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/__init__.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_profiles.py +99 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/web_search.py +203 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/illustrate/sources/wikimedia.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_db_adapter.mjs +434 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_node_runtime.py +274 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/markdown/markdown_query.py +227 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/artifacts.py +605 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/canonical_merge.py +277 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/markdown_zones.py +85 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/meaning_planner.py +307 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_iter.py +67 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_merge.py +278 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_plan.py +409 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_policy.py +22 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/__init__.py +79 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/fixes.py +264 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/frontmatter.py +435 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/models.py +208 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/prompts.py +37 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/tables.py +236 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/note_style/validate.py +404 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/provenance.py +478 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/raw_chats.py +273 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/notes/sources_backfill.py +235 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/__init__.py +10 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/anchors.py +16 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/captions.py +47 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cli.py +179 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/cloud.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/config.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/context_packets.py +76 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/db.py +81 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/doctor.py +102 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/figure_ids.py +42 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ingest.py +326 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/insert.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/mentions.py +57 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/ocr.py +71 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/paths.py +35 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/pdf_engine.py +77 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/schema.py +155 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/search.py +188 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/app.py +89 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/image_backend.py +29 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/pdf/tui/state.py +65 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish.py +1139 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_receipts.py +365 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/publish/publish_recovery.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_behavior_corpus.py +2069 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_report_validation.py +4448 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/agent_run_audit.py +852 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/architect_prompt_eval.py +341 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/body_linker_eval.py +240 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_output_validation.py +175 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/curator_prompt_eval.py +865 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/quality/triager_prompt_eval.py +1295 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes.py +1920 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/related_notes/related_notes_headless.py +1186 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/plan_attestation.py +148 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_receipts.py +360 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_runtime.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/specialist/specialist_task_runner.py +2470 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/style/style.py +1952 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/subagents/agents.py +1767 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/alias_projection.py +331 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/link_terms.py +151 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/llm_disambiguation.py +182 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/__init__.py +116 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/audit.py +201 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/migration.py +314 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/normalize.py +72 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/policy.py +135 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/resolve.py +413 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/schema.py +157 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/taxonomy/status.py +137 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_bootstrap.py +509 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_curator_batch.py +1115 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_ingestion.py +632 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_map.py +930 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/capabilities/vocabulary/vocabulary_recovery.py +1388 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/cli.py +6665 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/common.py +69 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/config.py +210 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/__init__.py +74 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_report.py +242 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agent_run_audit.py +196 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/agents.py +601 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/curator.py +256 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/effect_payloads.py +519 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/happy_path.py +190 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_git.py +110 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/link_runtime_artifact.py +52 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/note_plan.py +75 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/paths.py +114 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/public_report.py +53 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/publish.py +111 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/raw_coverage.py +217 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes.py +136 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_headless.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/related_notes_runtime.py +395 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/schema_registry.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/specialist.py +432 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/status.py +62 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/style_rewrite.py +568 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/vocabulary_ingestion.py +223 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_blockers.py +510 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_guardrails.py +637 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_outcomes.py +121 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/contracts/workflow_receipts.py +100 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/__main__.py +4 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/__init__.py +2 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/candidates.py +193 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/cli.py +189 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/gemini.py +220 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/inputs.py +120 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/models.py +34 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/parsing.py +48 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/prompts.py +216 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/quality.py +54 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/reporting.py +24 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/runner.py +433 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/utils.py +39 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/enrich/workflow/vault_guard_bridge.py +17 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_context_packets.py +454 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_decision_projection.py +133 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_effects.py +1260 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_fsm.py +2768 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_machine.py +1588 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_plan.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_primary_objective.py +316 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_problem.py +153 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_receipt_evidence.py +306 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_states.py +290 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/fix_wiki_user_report.py +342 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/fix_wiki/health.py +6332 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_fsm.py +1119 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_git.py +638 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_machine.py +1106 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_retry_governance.py +374 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_runtime_result.py +485 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/link_triggers.py +183 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/linking.py +2758 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/reference_repair.py +718 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link/related_notes_fsm.py +1855 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/link_related/link_related_machine.py +834 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/__init__.py +1 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_fsm.py +1592 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_machine.py +3097 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_primary_objective.py +28 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/flows/process_chats/process_chats_runtime_result.py +185 -0
- package/.opencode/mednotes/src/mednotes/domains/wiki/performance.py +97 -0
- package/.opencode/mednotes/src/mednotes/kernel/__init__.py +6 -0
- package/.opencode/mednotes/src/mednotes/kernel/agent_directive.py +336 -0
- package/.opencode/mednotes/src/mednotes/kernel/base.py +51 -0
- package/.opencode/mednotes/src/mednotes/kernel/blockers.py +39 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_executor.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/effect_intent.py +69 -0
- package/.opencode/mednotes/src/mednotes/kernel/effects.py +160 -0
- package/.opencode/mednotes/src/mednotes/kernel/errors.py +38 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_event.py +35 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_model.py +55 -0
- package/.opencode/mednotes/src/mednotes/kernel/fsm_transition_result.py +75 -0
- package/.opencode/mednotes/src/mednotes/kernel/guardrails.py +188 -0
- package/.opencode/mednotes/src/mednotes/kernel/progress.py +319 -0
- package/.opencode/mednotes/src/mednotes/kernel/public_report.py +346 -0
- package/.opencode/mednotes/src/mednotes/kernel/state_machine.py +164 -0
- package/.opencode/mednotes/src/mednotes/kernel/workflow.py +619 -0
- package/.opencode/mednotes/src/mednotes/platform/__init__.py +5 -0
- package/.opencode/mednotes/src/mednotes/platform/backup_policy.py +382 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/__init__.py +62 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/cli.py +275 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/contracts.py +83 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/core.py +4168 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/integrity.py +989 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/operational_contract.py +2293 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry.py +875 -0
- package/.opencode/mednotes/src/mednotes/platform/feedback/telemetry_config.py +65 -0
- package/.opencode/mednotes/src/mednotes/platform/opencode_runtime_config.py +182 -0
- package/.opencode/mednotes/src/mednotes/platform/paths/__init__.py +1560 -0
- package/.opencode/mednotes/src/mednotes/platform/secrets.py +89 -0
- package/.opencode/mednotes/src/mednotes/platform/user_config.py +103 -0
- package/.opencode/mednotes/src/mednotes/platform/vault_guard.py +214 -0
- package/.opencode/mednotes/uv.lock +932 -0
- package/.opencode/mednotes.generated.json +395 -0
- package/.opencode/opencode.json +31 -0
- package/.opencode/plugins/mednotes-fsm.mjs +7 -0
- package/.opencode/plugins/mednotes_hook/adapters/antigravity.mjs +169 -0
- package/.opencode/plugins/mednotes_hook/adapters/harness_payload.mjs +103 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_plugin.mjs +341 -0
- package/.opencode/plugins/mednotes_hook/adapters/opencode_user_config_sync.mjs +177 -0
- package/.opencode/plugins/mednotes_hook/anki_preflight.mjs +214 -0
- package/.opencode/plugins/mednotes_hook/cli.mjs +143 -0
- package/.opencode/plugins/mednotes_hook/diagnostics.mjs +11 -0
- package/.opencode/plugins/mednotes_hook/domain/agent_directive_core.mjs +160 -0
- package/.opencode/plugins/mednotes_hook/fsm_directive.mjs +1470 -0
- package/.opencode/plugins/mednotes_hook/hook_errors.mjs +120 -0
- package/.opencode/plugins/mednotes_hook/retention.mjs +114 -0
- package/.opencode/plugins/mednotes_hook/runtime.mjs +174 -0
- package/.opencode/plugins/mednotes_hook/telemetry_capture.mjs +511 -0
- package/.opencode/plugins/mednotes_hook/vault_guard.mjs +624 -0
- package/AGENTS.md +57 -0
- package/README.md +194 -0
- package/adapters/antigravity/agents.json +80 -0
- package/adapters/antigravity/templates/med-chat-triager.md +214 -0
- package/adapters/antigravity/templates/med-flashcard-maker.md +72 -0
- package/adapters/antigravity/templates/med-knowledge-architect.md +241 -0
- package/adapters/antigravity/templates/med-link-graph-curator.md +187 -0
- package/adapters/antigravity/templates/med-publish-guard.md +71 -0
- package/adapters/gemini-cli/gemini-extension.json +14 -0
- package/adapters/gemini-cli/package.json +15 -0
- package/adapters/gemini-cli/pyproject.toml +48 -0
- package/bin/mednotes-opencode.mjs +155 -0
- package/contracts/agents.json +116 -0
- package/core/agents/med-chat-triager.md +197 -0
- package/core/agents/med-flashcard-maker.md +56 -0
- package/core/agents/med-knowledge-architect.md +224 -0
- package/core/agents/med-link-graph-curator.md +171 -0
- package/core/agents/med-publish-guard.md +55 -0
- package/core/commands/flashcards.toml +22 -0
- package/core/commands/mednotes/create.toml +22 -0
- package/core/commands/mednotes/enrich.toml +24 -0
- package/core/commands/mednotes/fix-wiki.toml +24 -0
- package/core/commands/mednotes/history.toml +19 -0
- package/core/commands/mednotes/link-body.toml +22 -0
- package/core/commands/mednotes/link-related.toml +24 -0
- package/core/commands/mednotes/link.toml +24 -0
- package/core/commands/mednotes/pdf-library.toml +24 -0
- package/core/commands/mednotes/process-chats.toml +20 -0
- package/core/commands/mednotes/setup.toml +18 -0
- package/core/commands/mednotes/status.toml +24 -0
- package/core/commands/mednotes/telemetry.toml +24 -0
- package/core/commands/report.toml +23 -0
- package/core/skills/THIRD_PARTY_NOTICES.md +45 -0
- package/core/skills/create-medical-flashcards/SKILL.md +113 -0
- package/core/skills/create-medical-note/SKILL.md +90 -0
- package/core/skills/enrich-medical-note/SKILL.md +120 -0
- package/core/skills/fix-medical-wiki/SKILL.md +559 -0
- package/core/skills/link-medical-wiki/SKILL.md +224 -0
- package/core/skills/obsidian-cli/SKILL.md +118 -0
- package/core/skills/obsidian-markdown/SKILL.md +207 -0
- package/core/skills/obsidian-markdown/references/CALLOUTS.md +58 -0
- package/core/skills/obsidian-markdown/references/EMBEDS.md +63 -0
- package/core/skills/obsidian-markdown/references/PROPERTIES.md +61 -0
- package/core/skills/obsidian-ops/SKILL.md +136 -0
- package/core/skills/pdf-library/SKILL.md +45 -0
- package/core/skills/process-medical-chats/SKILL.md +246 -0
- package/core/skills/workflow-report/SKILL.md +100 -0
- package/package.json +45 -0
|
@@ -0,0 +1,4168 @@
|
|
|
1
|
+
"""Structured local feedback records for public workflow executions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mednotes.kernel.base import JsonObject, JsonObjectAdapter, JsonValue
|
|
18
|
+
from mednotes.kernel.guardrails import (
|
|
19
|
+
CONTRACT_GAP_MISSING_NEXT_ACTION,
|
|
20
|
+
)
|
|
21
|
+
from mednotes.kernel.guardrails import (
|
|
22
|
+
default_contract_next_action as _shared_default_contract_next_action,
|
|
23
|
+
)
|
|
24
|
+
from mednotes.kernel.guardrails import (
|
|
25
|
+
needs_next_action_hardening as _shared_needs_next_action_hardening,
|
|
26
|
+
)
|
|
27
|
+
from mednotes.kernel.public_report import FsmFirstPayloadSummary
|
|
28
|
+
from mednotes.platform.paths import user_state_dir
|
|
29
|
+
|
|
30
|
+
RUN_RECORD_SCHEMA = "medical-notes-workbench.workflow-run-record.v1"
|
|
31
|
+
BACKLOG_SCHEMA = "medical-notes-workbench.workflow-improvement-backlog.v1"
|
|
32
|
+
AGENT_HOOK_EVENT_SCHEMA = "medical-notes-workbench.agent-hook-event.v1"
|
|
33
|
+
AGENT_HOOK_ERROR_SCHEMA = "medical-notes-workbench.agent-hook-error.v1"
|
|
34
|
+
PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA = "medical-notes-workbench.pre-update-extension-snapshot.v1"
|
|
35
|
+
TELEMETRY_EVIDENCE_SCHEMA = "medical-notes-workbench.telemetry-evidence.v1"
|
|
36
|
+
ENVIRONMENT_BLOCKER_CODE = "environment_blocker.windows_path_or_venv"
|
|
37
|
+
DEFAULT_ROOT = "~/.mednotes/feedback"
|
|
38
|
+
FSM_FIRST_RUN_RECORD_SCHEMAS = {
|
|
39
|
+
"medical-notes-workbench.fix-wiki-fsm-result.v1",
|
|
40
|
+
"medical-notes-workbench.flashcards-fsm-result.v1",
|
|
41
|
+
"medical-notes-workbench.link-fsm-result.v1",
|
|
42
|
+
"medical-notes-workbench.link-related-fsm-result.v1",
|
|
43
|
+
"medical-notes-workbench.process-chats-fsm-result.v1",
|
|
44
|
+
"medical-notes-workbench.setup-fsm-result.v1",
|
|
45
|
+
"medical-notes-workbench.history-fsm-result.v1",
|
|
46
|
+
}
|
|
47
|
+
MAX_SNIPPET_CHARS = 420
|
|
48
|
+
MAX_RELEVANT_PATHS = 24
|
|
49
|
+
MAX_PATH_HASH_BYTES = 2 * 1024 * 1024
|
|
50
|
+
MAX_DIAGNOSTIC_ITEMS = 3
|
|
51
|
+
MAX_AGENT_EVENT_SAMPLES = 3
|
|
52
|
+
MAX_AGENT_EVENTS = 20
|
|
53
|
+
MAX_HOOK_EVENTS = 50
|
|
54
|
+
MAX_HOOK_ERRORS = 25
|
|
55
|
+
MAX_GENERATED_SCRIPTS = 12
|
|
56
|
+
MAX_COMMAND_EVENTS = 20
|
|
57
|
+
MAX_SCRIPT_CONTENT_CHARS = 48 * 1024
|
|
58
|
+
MAX_CONSOLE_TAIL_CHARS = 16 * 1024
|
|
59
|
+
MAX_HOOK_ERROR_CHARS = 8 * 1024
|
|
60
|
+
MAX_PRE_UPDATE_PATCH_CHARS = 160 * 1024
|
|
61
|
+
DEFAULT_RUN_RECORD_MAX_FILES = 200
|
|
62
|
+
DEFAULT_RUN_RECORD_RETENTION_DAYS = 14
|
|
63
|
+
DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS = 5
|
|
64
|
+
DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS = 7
|
|
65
|
+
DEFAULT_HOOK_EVENT_RETENTION_DAYS = 1
|
|
66
|
+
AGENT_EMPTY_RECORD_INHERIT_SECONDS = 15 * 60
|
|
67
|
+
INHERITABLE_WORKFLOW_STATUSES = {
|
|
68
|
+
"failed": 3,
|
|
69
|
+
"error": 3,
|
|
70
|
+
"blocked": 2,
|
|
71
|
+
"completed_with_warnings": 1,
|
|
72
|
+
}
|
|
73
|
+
PRE_UPDATE_PATCH_NOISE_PARTS = (
|
|
74
|
+
"__pycache__/",
|
|
75
|
+
".venv/",
|
|
76
|
+
"node_modules/",
|
|
77
|
+
".pytest_cache/",
|
|
78
|
+
".mypy_cache/",
|
|
79
|
+
".egg-info/",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
ARTIFACT_STATE_KEYS = {
|
|
83
|
+
"batch_id",
|
|
84
|
+
"run_id",
|
|
85
|
+
"note_plan_hash",
|
|
86
|
+
"coverage_hash",
|
|
87
|
+
"manifest_hash",
|
|
88
|
+
"manifest_sha256",
|
|
89
|
+
"source_artifact_hash",
|
|
90
|
+
"dry_run_options_hash",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
COUNT_KEYS = {
|
|
94
|
+
"count",
|
|
95
|
+
"file_count",
|
|
96
|
+
"changed_count",
|
|
97
|
+
"written_count",
|
|
98
|
+
"error_count",
|
|
99
|
+
"warning_count",
|
|
100
|
+
"write_error_count",
|
|
101
|
+
"requires_llm_rewrite_count",
|
|
102
|
+
"taxonomy_issue_count",
|
|
103
|
+
"taxonomy_applied_move_count",
|
|
104
|
+
"graph_error_count",
|
|
105
|
+
"blocker_count",
|
|
106
|
+
"links_planned",
|
|
107
|
+
"links_rewritten",
|
|
108
|
+
"files_scanned",
|
|
109
|
+
"files_changed",
|
|
110
|
+
"candidate_count",
|
|
111
|
+
"new_count",
|
|
112
|
+
"duplicate_count",
|
|
113
|
+
"changed_source_count",
|
|
114
|
+
"anki_note_count",
|
|
115
|
+
"processed_note_count",
|
|
116
|
+
"created_card_count",
|
|
117
|
+
"duplicate_card_count",
|
|
118
|
+
"skipped_note_count",
|
|
119
|
+
"model_error_count",
|
|
120
|
+
"anki_error_count",
|
|
121
|
+
"inserted_count",
|
|
122
|
+
"enriched_count",
|
|
123
|
+
"skipped_count",
|
|
124
|
+
"no_insert_count",
|
|
125
|
+
"failure_count",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
PATH_KEY_HINTS = ("path", "file", "dir", "manifest", "receipt", "output", "target")
|
|
129
|
+
TITLE_KEY_HINTS = ("title", "titulo", "título")
|
|
130
|
+
HASH_KEY_HINTS = ("hash", "sha", "sha256", "digest")
|
|
131
|
+
SECRET_KEYS = {"token", "auth_token", "api_key", "apikey", "secret", "password", "authorization", "bearer"}
|
|
132
|
+
LONG_TEXT_KEYS = {"content", "markdown", "html", "raw_chat", "note_text"}
|
|
133
|
+
SCRIPT_SUFFIXES = {".py", ".js", ".mjs", ".cjs", ".sh", ".ps1", ".cmd"}
|
|
134
|
+
AGENT_RELEVANT_DRIFT_KINDS = {"launcher", "prompt", "runbook", "documentation", "script"}
|
|
135
|
+
AGENT_RELEVANT_DRIFT_PREFIXES = (
|
|
136
|
+
"commands/",
|
|
137
|
+
"docs/",
|
|
138
|
+
"scripts/",
|
|
139
|
+
"skills/",
|
|
140
|
+
)
|
|
141
|
+
RETRY_BUDGETS = {
|
|
142
|
+
"rewrite": {
|
|
143
|
+
"max_attempts": 2,
|
|
144
|
+
"rule": "Reescrita clínica/determinística deve parar após duas tentativas e preservar error_context.",
|
|
145
|
+
},
|
|
146
|
+
"publish_rollback": {
|
|
147
|
+
"max_attempts": 0,
|
|
148
|
+
"rule": "Falha após início de publish não deve ser repetida automaticamente; rollback e revisão primeiro.",
|
|
149
|
+
},
|
|
150
|
+
"dry_run": {
|
|
151
|
+
"max_attempts": 1,
|
|
152
|
+
"rule": "Dry-run só deve repetir se manifest, blockers ou opções mudaram.",
|
|
153
|
+
},
|
|
154
|
+
"coverage_stage": {
|
|
155
|
+
"max_attempts": 1,
|
|
156
|
+
"rule": "Coverage/stage só deve repetir se coverage, manifest ou nota staged mudaram.",
|
|
157
|
+
},
|
|
158
|
+
"triage_correction": {
|
|
159
|
+
"max_attempts": 1,
|
|
160
|
+
"rule": "Correção de triagem só deve repetir se note_plan mudou.",
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
ENVIRONMENT_PATTERN_CODES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
164
|
+
(re.compile(r"\bwinerror\s*\d+\b", re.IGNORECASE), "windows_error"),
|
|
165
|
+
(re.compile(r"microsoft\\windowsapps", re.IGNORECASE), "windows_store_python_alias"),
|
|
166
|
+
(
|
|
167
|
+
re.compile(r"executionpolicy|running scripts is disabled|cannot be loaded because running scripts", re.IGNORECASE),
|
|
168
|
+
"powershell_execution_policy",
|
|
169
|
+
),
|
|
170
|
+
(
|
|
171
|
+
re.compile(r"\buv(?:\.exe)?\b.{0,120}(not found|not recognized|could not find|no such file|failed)", re.IGNORECASE),
|
|
172
|
+
"uv_unavailable",
|
|
173
|
+
),
|
|
174
|
+
(
|
|
175
|
+
re.compile(r"(not found|not recognized|could not find|no such file).{0,120}\buv(?:\.exe)?\b", re.IGNORECASE),
|
|
176
|
+
"uv_unavailable",
|
|
177
|
+
),
|
|
178
|
+
(re.compile(r"uv_project_environment|persistent_venv|\.venv[\\/](scripts|bin)", re.IGNORECASE), "persistent_venv"),
|
|
179
|
+
(
|
|
180
|
+
re.compile(
|
|
181
|
+
r"\bpython(?:\.exe)?\b.{0,120}(not found|not recognized|could not find|no such file)|no module named",
|
|
182
|
+
re.IGNORECASE,
|
|
183
|
+
),
|
|
184
|
+
"python_environment",
|
|
185
|
+
),
|
|
186
|
+
(re.compile(r"max_path|long path|filename too long|file name too long", re.IGNORECASE), "windows_long_path"),
|
|
187
|
+
(re.compile(r"[A-Za-z]:\\[^\r\n]*", re.IGNORECASE), "windows_path"),
|
|
188
|
+
(re.compile(r"\r\n"), "crlf"),
|
|
189
|
+
(re.compile(r"\b(powershell|pwsh|set-content|out-file)\b", re.IGNORECASE), "powershell_command"),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
STRONG_ENVIRONMENT_CODES = {
|
|
193
|
+
"windows_error",
|
|
194
|
+
"windows_store_python_alias",
|
|
195
|
+
"powershell_execution_policy",
|
|
196
|
+
"uv_unavailable",
|
|
197
|
+
"persistent_venv",
|
|
198
|
+
"python_environment",
|
|
199
|
+
"windows_long_path",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def now_iso() -> str:
|
|
204
|
+
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def feedback_root(root: str | Path | None = None) -> Path:
|
|
208
|
+
value = root or os.getenv("MEDNOTES_FEEDBACK_DIR")
|
|
209
|
+
if not value:
|
|
210
|
+
value = user_state_dir() / "feedback"
|
|
211
|
+
return Path(os.path.expandvars(str(value))).expanduser()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def command_string(argv: list[str] | None = None) -> str:
|
|
215
|
+
values = list(sys.argv if argv is None else argv)
|
|
216
|
+
return " ".join(_quote_arg(item) for item in values)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _quote_arg(value: str) -> str:
|
|
220
|
+
if not value:
|
|
221
|
+
return "''"
|
|
222
|
+
if re.search(r"\s|['\"]", value):
|
|
223
|
+
return json.dumps(value, ensure_ascii=False)
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def redact_snippet(value: Any, *, max_chars: int = MAX_SNIPPET_CHARS) -> str:
|
|
228
|
+
text = str(value)
|
|
229
|
+
text = re.sub(r"```.*?```", "[code omitted]", text, flags=re.DOTALL)
|
|
230
|
+
text = _redact_sensitive_text(text)
|
|
231
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
232
|
+
if len(text) > max_chars:
|
|
233
|
+
return text[: max_chars - 3].rstrip() + "..."
|
|
234
|
+
return text
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def redact_operational_text(value: Any, *, max_chars: int = MAX_SCRIPT_CONTENT_CHARS) -> str:
|
|
238
|
+
text = _redact_sensitive_text(str(value))
|
|
239
|
+
if len(text) > max_chars:
|
|
240
|
+
return text[: max_chars - 3].rstrip() + "..."
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _redact_operational_identifier(value: Any, *, max_chars: int = 120) -> str:
|
|
245
|
+
text = str(value or "").strip()
|
|
246
|
+
if _looks_like_operational_identifier(text):
|
|
247
|
+
return text[: max_chars - 3].rstrip() + "..." if len(text) > max_chars else text
|
|
248
|
+
return redact_snippet(text, max_chars=max_chars)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _redact_sensitive_text(text: str) -> str:
|
|
252
|
+
text = re.sub(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[email]", text)
|
|
253
|
+
text = re.sub(
|
|
254
|
+
r"(?i)\b(api[_-]?key|token|secret|password|authorization|bearer)(\s*[:=]\s*)([\"']?)[^\s\"']+",
|
|
255
|
+
r"\1\2[redacted]",
|
|
256
|
+
text,
|
|
257
|
+
)
|
|
258
|
+
text = re.sub(
|
|
259
|
+
r"(?i)(--(?:api-key|auth-token|token|secret|password)\s+)([^\s\"']+)",
|
|
260
|
+
r"\1[redacted]",
|
|
261
|
+
text,
|
|
262
|
+
)
|
|
263
|
+
text = re.sub(
|
|
264
|
+
r"https?://[^\s)>\"]+",
|
|
265
|
+
lambda match: _redact_url(match.group(0)),
|
|
266
|
+
text,
|
|
267
|
+
)
|
|
268
|
+
return re.sub(
|
|
269
|
+
r"\b[A-Za-z0-9_=-]{36,}\b",
|
|
270
|
+
lambda match: match.group(0) if _looks_like_public_slug(match.group(0)) else "[redacted-token]",
|
|
271
|
+
text,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _redact_url(url: str) -> str:
|
|
276
|
+
if "?" not in url:
|
|
277
|
+
return url
|
|
278
|
+
head, _query = url.split("?", 1)
|
|
279
|
+
return f"{head}?[redacted]"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _looks_like_public_slug(value: str) -> bool:
|
|
283
|
+
return bool(
|
|
284
|
+
"-" in value
|
|
285
|
+
and value.lower() == value
|
|
286
|
+
and re.fullmatch(r"[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*){2,}", value)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _looks_like_operational_identifier(value: str) -> bool:
|
|
291
|
+
return bool(
|
|
292
|
+
value
|
|
293
|
+
and value.lower() == value
|
|
294
|
+
and ("-" in value or "_" in value)
|
|
295
|
+
and re.fullmatch(r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)+", value)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _json_object_view(value: object) -> JsonObject:
|
|
300
|
+
"""Validate raw JSON-ish evidence before operational fields can be read."""
|
|
301
|
+
|
|
302
|
+
if not isinstance(value, dict):
|
|
303
|
+
return {}
|
|
304
|
+
return JsonObjectAdapter.validate_python(value)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _json_value(source: JsonObject, key: str) -> JsonValue:
|
|
308
|
+
if key not in source:
|
|
309
|
+
return None
|
|
310
|
+
return JsonObjectAdapter.validate_python({"value": source[key]})["value"]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _json_text(source: JsonObject, key: str) -> str:
|
|
314
|
+
value = _json_value(source, key)
|
|
315
|
+
return value.strip() if isinstance(value, str) else ""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _json_object_field(source: JsonObject, key: str) -> JsonObject:
|
|
319
|
+
return _json_object_view(_json_value(source, key))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _json_list_field(source: JsonObject, key: str) -> list[JsonValue]:
|
|
323
|
+
value = _json_value(source, key)
|
|
324
|
+
return value if isinstance(value, list) else []
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _json_bool_field(source: JsonObject, key: str) -> bool:
|
|
328
|
+
value = _json_value(source, key)
|
|
329
|
+
return value if isinstance(value, bool) else False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _json_int_field(source: JsonObject, key: str) -> int | None:
|
|
333
|
+
value = _json_value(source, key)
|
|
334
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def summarize_payload(payload: object) -> JsonObject:
|
|
338
|
+
summary: JsonObject = {
|
|
339
|
+
"counts": {},
|
|
340
|
+
"warnings": [],
|
|
341
|
+
"errors": [],
|
|
342
|
+
"required_inputs": [],
|
|
343
|
+
"relevant_paths": [],
|
|
344
|
+
"path_hashes": {},
|
|
345
|
+
"title_fields": {},
|
|
346
|
+
"artifact_state": {},
|
|
347
|
+
"signals": [],
|
|
348
|
+
}
|
|
349
|
+
if not isinstance(payload, dict):
|
|
350
|
+
return summary
|
|
351
|
+
|
|
352
|
+
payload_view = _json_object_view(payload)
|
|
353
|
+
progress = _json_object_field(payload_view, "progress_view_model")
|
|
354
|
+
decision = _json_object_field(payload_view, "decision")
|
|
355
|
+
error_context = _json_object_field(payload_view, "error_context")
|
|
356
|
+
receipt = _json_object_field(payload_view, "receipt")
|
|
357
|
+
|
|
358
|
+
if _json_text(payload_view, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS:
|
|
359
|
+
fsm_summary = FsmFirstPayloadSummary.from_payload(payload_view).to_payload()
|
|
360
|
+
summary.update(fsm_summary)
|
|
361
|
+
else:
|
|
362
|
+
summary["phase"] = _json_text(payload_view, "phase") or _json_text(progress, "phase") or _json_text(payload_view, "command")
|
|
363
|
+
summary["status"] = (
|
|
364
|
+
_json_text(payload_view, "status")
|
|
365
|
+
or _json_text(progress, "status")
|
|
366
|
+
or _json_text(receipt, "status")
|
|
367
|
+
or _status_from_payload(payload_view)
|
|
368
|
+
)
|
|
369
|
+
summary["blocked_reason"] = (
|
|
370
|
+
_json_text(payload_view, "blocked_reason")
|
|
371
|
+
or _json_text(error_context, "blocked_reason")
|
|
372
|
+
or _json_text(error_context, "root_cause")
|
|
373
|
+
or _json_text(decision, "reason_code")
|
|
374
|
+
or _blocked_reason_from_payload(payload_view)
|
|
375
|
+
)
|
|
376
|
+
summary["next_action"] = (
|
|
377
|
+
_json_text(payload_view, "next_action")
|
|
378
|
+
or _json_text(decision, "next_action")
|
|
379
|
+
or _json_text(error_context, "next_action")
|
|
380
|
+
or _json_text(receipt, "next_action")
|
|
381
|
+
or _json_text(payload_view, "next_command")
|
|
382
|
+
)
|
|
383
|
+
summary["human_decision_required"] = bool(
|
|
384
|
+
_json_bool_field(payload_view, "human_decision_required")
|
|
385
|
+
or bool(_json_object_field(payload_view, "human_decision_packet"))
|
|
386
|
+
or bool(_json_list_field(payload_view, "human_decision_packets"))
|
|
387
|
+
or _json_text(decision, "kind") == "ask_human"
|
|
388
|
+
)
|
|
389
|
+
summary["dry_run"] = _json_bool_field(payload_view, "dry_run") if "dry_run" in payload_view else None
|
|
390
|
+
summary["apply"] = _json_bool_field(payload_view, "apply") if "apply" in payload_view else None
|
|
391
|
+
workflow_exit_code = _json_int_field(payload_view, "workflow_exit_code")
|
|
392
|
+
if workflow_exit_code is not None:
|
|
393
|
+
summary["workflow_exit_code"] = workflow_exit_code
|
|
394
|
+
for key in ("process_chats_terminal_state", "process_chats_backlog_state"):
|
|
395
|
+
value = _json_text(payload_view, key)
|
|
396
|
+
if value:
|
|
397
|
+
summary[key] = _redact_operational_identifier(value, max_chars=120)
|
|
398
|
+
|
|
399
|
+
required = _json_list_field(payload_view, "required_inputs")
|
|
400
|
+
if not required:
|
|
401
|
+
required = _json_list_field(decision, "required_inputs")
|
|
402
|
+
if not required:
|
|
403
|
+
required = _json_list_field(error_context, "required_inputs")
|
|
404
|
+
if not required:
|
|
405
|
+
required = _json_list_field(receipt, "required_inputs")
|
|
406
|
+
if required:
|
|
407
|
+
summary["required_inputs"] = [str(item) for item in required]
|
|
408
|
+
|
|
409
|
+
counts: dict[str, int | float] = {}
|
|
410
|
+
_collect_counts(payload, counts)
|
|
411
|
+
summary["counts"] = counts
|
|
412
|
+
|
|
413
|
+
warnings: list[str] = []
|
|
414
|
+
errors: list[str] = []
|
|
415
|
+
_collect_messages(payload, warnings=warnings, errors=errors)
|
|
416
|
+
summary["warnings"] = warnings[:20]
|
|
417
|
+
summary["errors"] = errors[:20]
|
|
418
|
+
|
|
419
|
+
paths = _collect_paths(payload)
|
|
420
|
+
summary["relevant_paths"] = paths
|
|
421
|
+
summary["path_hashes"] = _hash_paths(paths)
|
|
422
|
+
summary["title_fields"] = _collect_title_fields(payload)
|
|
423
|
+
summary["artifact_state"] = _collect_artifact_state(payload)
|
|
424
|
+
summary["signals"] = _signals_from_payload(payload, summary)
|
|
425
|
+
return summary
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _is_empty_agent_feedback_payload(payload: dict[str, Any]) -> bool:
|
|
429
|
+
meaningful_keys = {
|
|
430
|
+
"status",
|
|
431
|
+
"phase",
|
|
432
|
+
"blocked_reason",
|
|
433
|
+
"next_action",
|
|
434
|
+
"required_inputs",
|
|
435
|
+
"human_decision_required",
|
|
436
|
+
"dry_run",
|
|
437
|
+
"apply",
|
|
438
|
+
"agent_events",
|
|
439
|
+
"error_context",
|
|
440
|
+
"diagnostic_context",
|
|
441
|
+
"warnings",
|
|
442
|
+
"errors",
|
|
443
|
+
}
|
|
444
|
+
for key in meaningful_keys:
|
|
445
|
+
value = payload.get(key)
|
|
446
|
+
if value not in (None, "", [], {}, False):
|
|
447
|
+
return False
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _recorded_at_datetime(record: dict[str, Any]) -> datetime | None:
|
|
452
|
+
raw = str(record.get("recorded_at") or "")
|
|
453
|
+
if not raw:
|
|
454
|
+
return None
|
|
455
|
+
try:
|
|
456
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
457
|
+
except ValueError:
|
|
458
|
+
return None
|
|
459
|
+
if parsed.tzinfo is None:
|
|
460
|
+
return parsed.replace(tzinfo=UTC)
|
|
461
|
+
return parsed.astimezone(UTC)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
|
465
|
+
try:
|
|
466
|
+
parsed = int(str(os.environ.get(name, "")).strip())
|
|
467
|
+
except ValueError:
|
|
468
|
+
return default
|
|
469
|
+
return max(minimum, min(maximum, parsed))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _env_float(name: str, default: float, *, minimum: float, maximum: float) -> float:
|
|
473
|
+
try:
|
|
474
|
+
parsed = float(str(os.environ.get(name, "")).strip())
|
|
475
|
+
except ValueError:
|
|
476
|
+
return default
|
|
477
|
+
return max(minimum, min(maximum, parsed))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def prune_local_feedback(*, root: str | Path | None = None) -> dict[str, object]:
|
|
481
|
+
feedback = feedback_root(root)
|
|
482
|
+
return {
|
|
483
|
+
"schema": "medical-notes-workbench.local-feedback-retention.v1",
|
|
484
|
+
"runs": _prune_json_files(
|
|
485
|
+
feedback / "runs",
|
|
486
|
+
max_files=_env_int("MEDNOTES_FEEDBACK_RUN_MAX_FILES", DEFAULT_RUN_RECORD_MAX_FILES, minimum=0, maximum=5000),
|
|
487
|
+
retention_days=_env_float(
|
|
488
|
+
"MEDNOTES_FEEDBACK_RUN_RETENTION_DAYS",
|
|
489
|
+
DEFAULT_RUN_RECORD_RETENTION_DAYS,
|
|
490
|
+
minimum=0.04,
|
|
491
|
+
maximum=365,
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
"pre_update_snapshots": _prune_directories(
|
|
495
|
+
feedback / "pre-update-snapshots",
|
|
496
|
+
max_dirs=_env_int("MEDNOTES_PRE_UPDATE_SNAPSHOT_MAX_DIRS", DEFAULT_PRE_UPDATE_SNAPSHOT_MAX_DIRS, minimum=0, maximum=200),
|
|
497
|
+
retention_days=_env_float(
|
|
498
|
+
"MEDNOTES_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS",
|
|
499
|
+
DEFAULT_PRE_UPDATE_SNAPSHOT_RETENTION_DAYS,
|
|
500
|
+
minimum=0.04,
|
|
501
|
+
maximum=365,
|
|
502
|
+
),
|
|
503
|
+
),
|
|
504
|
+
"hook_events": _prune_json_files(
|
|
505
|
+
feedback / "hook-events",
|
|
506
|
+
max_files=_env_int("MEDNOTES_HOOK_EVENT_MAX_FILES", MAX_HOOK_EVENTS, minimum=0, maximum=1000),
|
|
507
|
+
retention_days=_hook_retention_days("MEDNOTES_HOOK_EVENT_RETENTION_HOURS"),
|
|
508
|
+
),
|
|
509
|
+
"hook_errors": _prune_json_files(
|
|
510
|
+
feedback / "hook-errors",
|
|
511
|
+
max_files=_env_int("MEDNOTES_HOOK_ERROR_MAX_FILES", MAX_HOOK_ERRORS, minimum=0, maximum=1000),
|
|
512
|
+
retention_days=_hook_retention_days("MEDNOTES_HOOK_ERROR_RETENTION_HOURS"),
|
|
513
|
+
),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _hook_retention_days(specific_env: str) -> float:
|
|
518
|
+
hours = _env_float(
|
|
519
|
+
specific_env,
|
|
520
|
+
_env_float("MEDNOTES_HOOK_RETENTION_HOURS", DEFAULT_HOOK_EVENT_RETENTION_DAYS * 24, minimum=1, maximum=24 * 30),
|
|
521
|
+
minimum=1,
|
|
522
|
+
maximum=24 * 30,
|
|
523
|
+
)
|
|
524
|
+
return hours / 24
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _prune_json_files(directory: Path, *, max_files: int, retention_days: float) -> dict[str, object]:
|
|
528
|
+
if not directory.exists():
|
|
529
|
+
return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 0}
|
|
530
|
+
try:
|
|
531
|
+
files = [path for path in directory.glob("*.json") if path.is_file()]
|
|
532
|
+
except OSError:
|
|
533
|
+
return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 1}
|
|
534
|
+
removed, errors = _remove_retention_victims(files, max_items=max_files, retention_days=retention_days, remove=lambda path: path.unlink())
|
|
535
|
+
remaining = len([path for path in directory.glob("*.json") if path.is_file()])
|
|
536
|
+
return {"path": str(directory), "removed_count": removed, "remaining_count": remaining, "error_count": errors}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _prune_directories(directory: Path, *, max_dirs: int, retention_days: float) -> dict[str, object]:
|
|
540
|
+
if not directory.exists():
|
|
541
|
+
return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 0}
|
|
542
|
+
try:
|
|
543
|
+
dirs = [path for path in directory.iterdir() if path.is_dir()]
|
|
544
|
+
except OSError:
|
|
545
|
+
return {"path": str(directory), "removed_count": 0, "remaining_count": 0, "error_count": 1}
|
|
546
|
+
removed, errors = _remove_retention_victims(dirs, max_items=max_dirs, retention_days=retention_days, remove=shutil.rmtree)
|
|
547
|
+
remaining = len([path for path in directory.iterdir() if path.is_dir()])
|
|
548
|
+
return {"path": str(directory), "removed_count": removed, "remaining_count": remaining, "error_count": errors}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _remove_retention_victims(
|
|
552
|
+
paths: list[Path],
|
|
553
|
+
*,
|
|
554
|
+
max_items: int,
|
|
555
|
+
retention_days: float,
|
|
556
|
+
remove: Callable[[Path], None],
|
|
557
|
+
) -> tuple[int, int]:
|
|
558
|
+
cutoff = time.time() - retention_days * 86400
|
|
559
|
+
ordered = sorted(paths, key=lambda item: (_mtime(item), item.name), reverse=True)
|
|
560
|
+
victims: list[Path] = []
|
|
561
|
+
survivors: list[Path] = []
|
|
562
|
+
for item in ordered:
|
|
563
|
+
if _mtime(item) < cutoff:
|
|
564
|
+
victims.append(item)
|
|
565
|
+
else:
|
|
566
|
+
survivors.append(item)
|
|
567
|
+
victims.extend(survivors[max(0, max_items):])
|
|
568
|
+
removed = 0
|
|
569
|
+
errors = 0
|
|
570
|
+
for item in victims:
|
|
571
|
+
try:
|
|
572
|
+
remove(item)
|
|
573
|
+
removed += 1
|
|
574
|
+
except OSError:
|
|
575
|
+
errors += 1
|
|
576
|
+
return removed, errors
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _mtime(path: Path) -> float:
|
|
580
|
+
try:
|
|
581
|
+
return path.stat().st_mtime
|
|
582
|
+
except OSError:
|
|
583
|
+
return 0.0
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _recent_inheritable_workflow_record(
|
|
587
|
+
*,
|
|
588
|
+
workflow: str,
|
|
589
|
+
root: str | Path | None,
|
|
590
|
+
started: float,
|
|
591
|
+
) -> JsonObject | None:
|
|
592
|
+
runs_dir = feedback_root(root) / "runs"
|
|
593
|
+
if not runs_dir.exists():
|
|
594
|
+
return None
|
|
595
|
+
cutoff = datetime.fromtimestamp(max(0, started - AGENT_EMPTY_RECORD_INHERIT_SECONDS), UTC)
|
|
596
|
+
best: tuple[int, datetime, int, JsonObject] | None = None
|
|
597
|
+
for path in sorted(runs_dir.glob("*.json"))[-80:]:
|
|
598
|
+
try:
|
|
599
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
600
|
+
except (OSError, json.JSONDecodeError):
|
|
601
|
+
continue
|
|
602
|
+
if not isinstance(record, dict):
|
|
603
|
+
continue
|
|
604
|
+
record_view = _json_object_view(record)
|
|
605
|
+
if _json_text(record_view, "workflow") != workflow:
|
|
606
|
+
continue
|
|
607
|
+
status = _record_observed_text(record_view, "status")
|
|
608
|
+
rank = INHERITABLE_WORKFLOW_STATUSES.get(status, 0)
|
|
609
|
+
if rank <= 0:
|
|
610
|
+
continue
|
|
611
|
+
recorded_at = _recorded_at_datetime(record)
|
|
612
|
+
if recorded_at is None or recorded_at < cutoff:
|
|
613
|
+
continue
|
|
614
|
+
try:
|
|
615
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
616
|
+
except OSError:
|
|
617
|
+
mtime_ns = 0
|
|
618
|
+
if best is None or rank > best[0] or (rank == best[0] and (recorded_at, mtime_ns) > (best[1], best[2])):
|
|
619
|
+
best = (rank, recorded_at, mtime_ns, record_view)
|
|
620
|
+
return best[3] if best else None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _feedback_summary_command(command: str) -> bool:
|
|
624
|
+
slug = _code_slug(command)
|
|
625
|
+
return "feedback_report_py_record" in slug and "agent" in slug
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _record_observed_text(record: JsonObject, key: str) -> str:
|
|
629
|
+
"""Read run-record observations without treating legacy root fields as truth."""
|
|
630
|
+
|
|
631
|
+
observed = _json_object_field(record, "observed")
|
|
632
|
+
summary = _json_object_field(record, "payload_summary")
|
|
633
|
+
return _json_text(observed, key) or _json_text(summary, key) or _json_text(record, key)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _agent_feedback_payload_needs_inheritance(payload: dict[str, Any], *, command: str) -> bool:
|
|
637
|
+
if _is_empty_agent_feedback_payload(payload):
|
|
638
|
+
return True
|
|
639
|
+
if not _feedback_summary_command(command):
|
|
640
|
+
return False
|
|
641
|
+
evidence_keys = (
|
|
642
|
+
"error_context",
|
|
643
|
+
"agent_events",
|
|
644
|
+
"command_events",
|
|
645
|
+
"diagnosis_path",
|
|
646
|
+
"diagnosis",
|
|
647
|
+
"manifest_path",
|
|
648
|
+
"receipt_path",
|
|
649
|
+
"operational_evidence",
|
|
650
|
+
)
|
|
651
|
+
return not any(payload.get(key) not in (None, "", [], {}) for key in evidence_keys)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _inherited_workflow_error_context(previous: JsonObject) -> JsonObject:
|
|
655
|
+
error_context = _json_object_field(previous, "error_context")
|
|
656
|
+
if error_context:
|
|
657
|
+
return error_context
|
|
658
|
+
status = _record_observed_text(previous, "status")
|
|
659
|
+
blocked_reason = _record_observed_text(previous, "blocked_reason")
|
|
660
|
+
next_action = _record_observed_text(previous, "next_action")
|
|
661
|
+
if status not in {"blocked", "failed", "error"} or not blocked_reason or not next_action:
|
|
662
|
+
return {}
|
|
663
|
+
required_inputs = [str(item) for item in _json_list_field(previous, "required_inputs") if str(item).strip()]
|
|
664
|
+
affected = ", ".join(required_inputs[:5]) or _json_text(previous, "workflow") or "workflow"
|
|
665
|
+
return _normalized_error_context(
|
|
666
|
+
{
|
|
667
|
+
"phase": _record_observed_text(previous, "phase") or "workflow",
|
|
668
|
+
"blocked_reason": blocked_reason,
|
|
669
|
+
"root_cause": f"Workflow reportou bloqueio acionavel: {blocked_reason}.",
|
|
670
|
+
"affected_artifact": affected,
|
|
671
|
+
"error_summary": f"Workflow terminou como {status} em {blocked_reason}.",
|
|
672
|
+
"suggested_fix": next_action,
|
|
673
|
+
"next_action": next_action,
|
|
674
|
+
"retry_scope": "resolve_required_inputs_then_retry",
|
|
675
|
+
"missing_inputs": required_inputs,
|
|
676
|
+
"human_decision_required": _json_bool_field(previous, "human_decision_required"),
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _inherit_agent_feedback_payload(
|
|
682
|
+
payload: dict[str, Any],
|
|
683
|
+
*,
|
|
684
|
+
workflow: str,
|
|
685
|
+
root: str | Path | None,
|
|
686
|
+
source: str,
|
|
687
|
+
started: float,
|
|
688
|
+
command: str,
|
|
689
|
+
) -> dict[str, Any]:
|
|
690
|
+
if source != "agent" or not _agent_feedback_payload_needs_inheritance(payload, command=command):
|
|
691
|
+
return payload
|
|
692
|
+
previous = _recent_inheritable_workflow_record(workflow=workflow, root=root, started=started)
|
|
693
|
+
if not previous:
|
|
694
|
+
return payload
|
|
695
|
+
inherited = dict(payload)
|
|
696
|
+
diagnostic = dict(inherited.get("diagnostic_context") or {}) if isinstance(inherited.get("diagnostic_context"), dict) else {}
|
|
697
|
+
previous_error_context = _inherited_workflow_error_context(previous)
|
|
698
|
+
observed: JsonObject = {
|
|
699
|
+
"status": _record_observed_text(previous, "status"),
|
|
700
|
+
"phase": _record_observed_text(previous, "phase"),
|
|
701
|
+
"blocked_reason": _record_observed_text(previous, "blocked_reason"),
|
|
702
|
+
"next_action": _record_observed_text(previous, "next_action"),
|
|
703
|
+
}
|
|
704
|
+
inherited_feedback_context: JsonObject = {
|
|
705
|
+
"run_id": str(previous.get("run_id") or ""),
|
|
706
|
+
"source": str(previous.get("source") or ""),
|
|
707
|
+
"command": str(previous.get("command") or ""),
|
|
708
|
+
"observed": {key: value for key, value in observed.items() if value},
|
|
709
|
+
}
|
|
710
|
+
if previous_error_context:
|
|
711
|
+
inherited_feedback_context["error_context"] = previous_error_context
|
|
712
|
+
diagnostic["inherited_feedback_context"] = inherited_feedback_context
|
|
713
|
+
inherited["diagnostic_context"] = diagnostic
|
|
714
|
+
return inherited
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _default_contract_next_action(*, workflow: str, command: str) -> str:
|
|
718
|
+
return _shared_default_contract_next_action(workflow=workflow, command=command)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _needs_next_action_hardening(payload: JsonObject) -> bool:
|
|
722
|
+
return _shared_needs_next_action_hardening(payload)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _harden_payload_missing_next_action(
|
|
726
|
+
payload: JsonObject,
|
|
727
|
+
*,
|
|
728
|
+
workflow: str,
|
|
729
|
+
command: str,
|
|
730
|
+
) -> JsonObject:
|
|
731
|
+
if not _needs_next_action_hardening(payload):
|
|
732
|
+
return dict(payload)
|
|
733
|
+
hardened = dict(payload)
|
|
734
|
+
summary = summarize_payload(hardened)
|
|
735
|
+
phase = _json_text(hardened, "phase") or str(summary.get("phase") or command or "unknown")
|
|
736
|
+
original_blocked_reason = _json_text(hardened, "blocked_reason") or _json_text(summary, "blocked_reason")
|
|
737
|
+
next_action = _default_contract_next_action(workflow=workflow, command=command)
|
|
738
|
+
hardened = {
|
|
739
|
+
**hardened,
|
|
740
|
+
"status": "blocked",
|
|
741
|
+
"blocked_reason": CONTRACT_GAP_MISSING_NEXT_ACTION,
|
|
742
|
+
"next_action": next_action,
|
|
743
|
+
}
|
|
744
|
+
if "required_inputs" not in hardened or not isinstance(_json_value(hardened, "required_inputs"), list):
|
|
745
|
+
hardened["required_inputs"] = []
|
|
746
|
+
hardened.setdefault("human_decision_required", False)
|
|
747
|
+
|
|
748
|
+
diagnostic: JsonObject = dict(_json_object_field(hardened, "diagnostic_context"))
|
|
749
|
+
diagnostic["root_cause_code"] = CONTRACT_GAP_MISSING_NEXT_ACTION
|
|
750
|
+
diagnostic["contract_gap"] = {
|
|
751
|
+
"missing_fields": ["next_action"],
|
|
752
|
+
"original_blocked_reason": original_blocked_reason,
|
|
753
|
+
"workflow": workflow,
|
|
754
|
+
"command": command,
|
|
755
|
+
}
|
|
756
|
+
hardened["diagnostic_context"] = diagnostic
|
|
757
|
+
|
|
758
|
+
if not _json_object_field(hardened, "error_context"):
|
|
759
|
+
hardened["error_context"] = {
|
|
760
|
+
"phase": phase,
|
|
761
|
+
"blocked_reason": CONTRACT_GAP_MISSING_NEXT_ACTION,
|
|
762
|
+
"root_cause": CONTRACT_GAP_MISSING_NEXT_ACTION,
|
|
763
|
+
"affected_artifact": phase,
|
|
764
|
+
"error_summary": _json_text(hardened, "error")
|
|
765
|
+
or _json_text(hardened, "message")
|
|
766
|
+
or CONTRACT_GAP_MISSING_NEXT_ACTION,
|
|
767
|
+
"suggested_fix": next_action,
|
|
768
|
+
"next_action": next_action,
|
|
769
|
+
"retry_scope": "restore_official_workflow_route",
|
|
770
|
+
"missing_inputs": ["next_action"],
|
|
771
|
+
"human_decision_required": _json_bool_field(hardened, "human_decision_required"),
|
|
772
|
+
}
|
|
773
|
+
return hardened
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _status_from_payload(payload: JsonObject) -> str:
|
|
777
|
+
if _json_bool_field(payload, "blocked"):
|
|
778
|
+
return "blocked"
|
|
779
|
+
if _safe_int(_json_value(payload, "error_count")) > 0:
|
|
780
|
+
return "failed"
|
|
781
|
+
ok_value = _json_value(payload, "ok")
|
|
782
|
+
if ok_value is False or _json_value(payload, "error") or _json_value(payload, "parse_error"):
|
|
783
|
+
return "failed"
|
|
784
|
+
if _json_value(payload, "warnings") or _json_value(payload, "warning_count"):
|
|
785
|
+
return "completed_with_warnings"
|
|
786
|
+
return "completed"
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _blocked_reason_from_payload(payload: JsonObject) -> str:
|
|
790
|
+
if _json_value(payload, "blocker_count"):
|
|
791
|
+
return "graph_blockers"
|
|
792
|
+
if _safe_int(_json_value(payload, "error_count")) > 0:
|
|
793
|
+
return "validation_errors"
|
|
794
|
+
if _json_bool_field(payload, "blocked"):
|
|
795
|
+
return "blocked"
|
|
796
|
+
if _json_value(payload, "parse_error") or _json_value(payload, "error"):
|
|
797
|
+
return "runtime_error"
|
|
798
|
+
model_validation = _json_object_field(payload, "model_validation")
|
|
799
|
+
if _json_value(model_validation, "ok") is False:
|
|
800
|
+
return "anki_model_validation_failed"
|
|
801
|
+
return ""
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _collect_counts(value: object, counts: dict[str, int | float], *, prefix: str = "") -> None:
|
|
805
|
+
if isinstance(value, dict):
|
|
806
|
+
for key, item in value.items():
|
|
807
|
+
name = f"{prefix}.{key}" if prefix else str(key)
|
|
808
|
+
leaf = str(key)
|
|
809
|
+
if isinstance(item, (int, float)) and not isinstance(item, bool):
|
|
810
|
+
if leaf in COUNT_KEYS or leaf.endswith(("_count", "_planned")):
|
|
811
|
+
counts[name] = item
|
|
812
|
+
elif isinstance(item, dict):
|
|
813
|
+
_collect_counts(item, counts, prefix=name)
|
|
814
|
+
elif isinstance(value, list):
|
|
815
|
+
for item in value[:20]:
|
|
816
|
+
if isinstance(item, dict):
|
|
817
|
+
_collect_counts(item, counts, prefix=prefix)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _collect_messages(value: Any, *, warnings: list[str], errors: list[str]) -> None:
|
|
821
|
+
if isinstance(value, dict):
|
|
822
|
+
for key, item in value.items():
|
|
823
|
+
lower = str(key).lower()
|
|
824
|
+
if lower in {"warning", "warnings"}:
|
|
825
|
+
_append_messages(item, warnings)
|
|
826
|
+
elif lower in {"error", "errors", "write_errors", "anki_errors"}:
|
|
827
|
+
_append_messages(item, errors)
|
|
828
|
+
elif isinstance(item, (dict, list)):
|
|
829
|
+
_collect_messages(item, warnings=warnings, errors=errors)
|
|
830
|
+
elif isinstance(value, list):
|
|
831
|
+
for item in value[:30]:
|
|
832
|
+
_collect_messages(item, warnings=warnings, errors=errors)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _append_messages(value: Any, target: list[str]) -> None:
|
|
836
|
+
if isinstance(value, str):
|
|
837
|
+
target.append(redact_snippet(value))
|
|
838
|
+
elif isinstance(value, list):
|
|
839
|
+
for item in value[:10]:
|
|
840
|
+
_append_messages(item, target)
|
|
841
|
+
elif isinstance(value, dict):
|
|
842
|
+
message = value.get("message") or value.get("error") or value.get("reason") or value.get("code")
|
|
843
|
+
if message:
|
|
844
|
+
target.append(redact_snippet(message))
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _collect_paths(payload: dict[str, Any]) -> list[str]:
|
|
848
|
+
paths: list[str] = []
|
|
849
|
+
|
|
850
|
+
def visit(value: Any, key: str = "") -> None:
|
|
851
|
+
if len(paths) >= MAX_RELEVANT_PATHS:
|
|
852
|
+
return
|
|
853
|
+
if isinstance(value, dict):
|
|
854
|
+
for child_key, child_value in value.items():
|
|
855
|
+
visit(child_value, str(child_key))
|
|
856
|
+
elif isinstance(value, list):
|
|
857
|
+
for item in value[:20]:
|
|
858
|
+
visit(item, key)
|
|
859
|
+
elif isinstance(value, str) and _looks_like_path_key(key) and _looks_like_path_value(value):
|
|
860
|
+
paths.append(value)
|
|
861
|
+
|
|
862
|
+
visit(payload)
|
|
863
|
+
deduped = []
|
|
864
|
+
seen = set()
|
|
865
|
+
for path in paths:
|
|
866
|
+
if path not in seen:
|
|
867
|
+
seen.add(path)
|
|
868
|
+
deduped.append(path)
|
|
869
|
+
return deduped[:MAX_RELEVANT_PATHS]
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _looks_like_path_key(key: str) -> bool:
|
|
873
|
+
lower = key.lower()
|
|
874
|
+
return any(hint in lower for hint in PATH_KEY_HINTS)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _looks_like_path_value(value: str) -> bool:
|
|
878
|
+
if value.startswith(("obsidian://", "http://", "https://")):
|
|
879
|
+
return False
|
|
880
|
+
return "/" in value or "\\" in value or value.endswith((".md", ".json", ".toml", ".html"))
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _hash_paths(paths: list[str]) -> dict[str, str]:
|
|
884
|
+
hashes: dict[str, str] = {}
|
|
885
|
+
for raw in paths[:MAX_RELEVANT_PATHS]:
|
|
886
|
+
path = Path(os.path.expandvars(raw)).expanduser()
|
|
887
|
+
try:
|
|
888
|
+
if not path.is_file() or path.stat().st_size > MAX_PATH_HASH_BYTES:
|
|
889
|
+
continue
|
|
890
|
+
hashes[raw] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
891
|
+
except OSError:
|
|
892
|
+
continue
|
|
893
|
+
return hashes
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _collect_title_fields(payload: dict[str, Any]) -> dict[str, str]:
|
|
897
|
+
titles: dict[str, str] = {}
|
|
898
|
+
|
|
899
|
+
def visit(value: Any, prefix: str = "") -> None:
|
|
900
|
+
if len(titles) >= 80:
|
|
901
|
+
return
|
|
902
|
+
if isinstance(value, dict):
|
|
903
|
+
for child_key, child_value in value.items():
|
|
904
|
+
key = str(child_key)
|
|
905
|
+
name = f"{prefix}.{key}" if prefix else key
|
|
906
|
+
if isinstance(child_value, str) and _looks_like_title_key(key):
|
|
907
|
+
clean = redact_snippet(child_value, max_chars=240)
|
|
908
|
+
if clean:
|
|
909
|
+
titles[name] = clean
|
|
910
|
+
if isinstance(child_value, (dict, list)):
|
|
911
|
+
visit(child_value, name)
|
|
912
|
+
elif isinstance(value, list):
|
|
913
|
+
for index, item in enumerate(value[:20]):
|
|
914
|
+
if isinstance(item, (dict, list)):
|
|
915
|
+
visit(item, f"{prefix}.{index}" if prefix else str(index))
|
|
916
|
+
|
|
917
|
+
visit(payload)
|
|
918
|
+
return titles
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _looks_like_title_key(key: str) -> bool:
|
|
922
|
+
lower = key.lower()
|
|
923
|
+
return any(hint in lower for hint in TITLE_KEY_HINTS)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _collect_artifact_state(payload: dict[str, Any]) -> dict[str, str]:
|
|
927
|
+
state: dict[str, str] = {}
|
|
928
|
+
|
|
929
|
+
def visit(value: Any, prefix: str = "") -> None:
|
|
930
|
+
if len(state) >= 80:
|
|
931
|
+
return
|
|
932
|
+
if isinstance(value, dict):
|
|
933
|
+
for child_key, child_value in value.items():
|
|
934
|
+
key = str(child_key)
|
|
935
|
+
name = f"{prefix}.{key}" if prefix else key
|
|
936
|
+
is_hash_key = _looks_like_hash_key(key)
|
|
937
|
+
if key in ARTIFACT_STATE_KEYS or is_hash_key:
|
|
938
|
+
clean = str(child_value or "").strip()
|
|
939
|
+
if clean and len(clean) <= 160:
|
|
940
|
+
state[name] = clean if is_hash_key else redact_snippet(clean, max_chars=160)
|
|
941
|
+
if isinstance(child_value, (dict, list)):
|
|
942
|
+
visit(child_value, name)
|
|
943
|
+
elif isinstance(value, list):
|
|
944
|
+
for index, item in enumerate(value[:20]):
|
|
945
|
+
if isinstance(item, (dict, list)):
|
|
946
|
+
visit(item, f"{prefix}.{index}" if prefix else str(index))
|
|
947
|
+
|
|
948
|
+
visit(payload)
|
|
949
|
+
return state
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _looks_like_hash_key(key: str) -> bool:
|
|
953
|
+
lower = key.lower()
|
|
954
|
+
return any(hint in lower for hint in HASH_KEY_HINTS)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _signals_from_payload(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
|
|
958
|
+
signals: set[str] = set()
|
|
959
|
+
blocked_reason = str(summary.get("blocked_reason") or "")
|
|
960
|
+
status = str(summary.get("status") or "")
|
|
961
|
+
search_text = " ".join(
|
|
962
|
+
[
|
|
963
|
+
blocked_reason,
|
|
964
|
+
str(summary.get("next_action") or ""),
|
|
965
|
+
" ".join(str(item) for item in summary.get("errors", [])),
|
|
966
|
+
" ".join(str(item) for item in summary.get("warnings", [])),
|
|
967
|
+
]
|
|
968
|
+
).lower()
|
|
969
|
+
if blocked_reason:
|
|
970
|
+
signals.add(f"blocked:{blocked_reason}")
|
|
971
|
+
raw_blocked_items = payload.get("blocked_items")
|
|
972
|
+
if isinstance(raw_blocked_items, list):
|
|
973
|
+
for item in raw_blocked_items[:20]:
|
|
974
|
+
if isinstance(item, dict):
|
|
975
|
+
code = _code_slug(item.get("blocked_reason") or "")
|
|
976
|
+
if code:
|
|
977
|
+
signals.add(f"blocked:{code}")
|
|
978
|
+
if "coverage_path" in search_text and status in {"blocked", "failed"}:
|
|
979
|
+
signals.add("blocked:coverage_path_missing")
|
|
980
|
+
signals.add("required_input:coverage_path")
|
|
981
|
+
if summary.get("human_decision_required"):
|
|
982
|
+
signals.add("human_decision_required")
|
|
983
|
+
if status in {"blocked", "failed"}:
|
|
984
|
+
for item in summary.get("required_inputs", []):
|
|
985
|
+
signals.add(f"required_input:{item}")
|
|
986
|
+
if status in {"blocked", "failed", "completed_with_warnings"} and not summary.get("next_action"):
|
|
987
|
+
signals.add("missing_next_action")
|
|
988
|
+
if summary.get("warnings"):
|
|
989
|
+
signals.add("warnings")
|
|
990
|
+
if summary.get("errors"):
|
|
991
|
+
signals.add("errors")
|
|
992
|
+
model_validation = payload.get("model_validation")
|
|
993
|
+
if isinstance(model_validation, dict) and model_validation.get("ok") is False:
|
|
994
|
+
signals.add("anki_model_validation_failed")
|
|
995
|
+
if payload.get("requires_reprocess_confirmation"):
|
|
996
|
+
signals.add("flashcards_reprocess_confirmation")
|
|
997
|
+
if payload.get("dry_run") is True:
|
|
998
|
+
signals.add("dry_run")
|
|
999
|
+
if int(summary.get("counts", {}).get("blocker_count", 0) or 0):
|
|
1000
|
+
signals.add("blocked:graph_blockers")
|
|
1001
|
+
for event in _normalized_agent_events(payload):
|
|
1002
|
+
event_type = event.get("type")
|
|
1003
|
+
if event_type:
|
|
1004
|
+
signals.add(f"agent.{event_type}")
|
|
1005
|
+
if _environment_blocker_context(payload, summary):
|
|
1006
|
+
signals.add(ENVIRONMENT_BLOCKER_CODE)
|
|
1007
|
+
return sorted(signals)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def build_diagnostic_context(payload: Any, summary: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
1011
|
+
"""Build a compact, redacted explanation of what likely needs attention."""
|
|
1012
|
+
if not isinstance(payload, dict):
|
|
1013
|
+
payload = {}
|
|
1014
|
+
summary = summary or summarize_payload(payload)
|
|
1015
|
+
decision_context = _decision_context(payload, summary)
|
|
1016
|
+
blocker_context = _blocker_context(payload, summary)
|
|
1017
|
+
agent_behavior_context = _agent_behavior_context(payload)
|
|
1018
|
+
environment_blocker_context = _environment_blocker_context(payload, summary)
|
|
1019
|
+
error_ctx = _normalized_error_context(payload.get("error_context"))
|
|
1020
|
+
if environment_blocker_context and not error_ctx:
|
|
1021
|
+
error_ctx = _environment_error_context(payload, summary, environment_blocker_context)
|
|
1022
|
+
retry_governance = _retry_governance_context(payload, summary, error_ctx)
|
|
1023
|
+
missing_inputs = _missing_inputs(payload, summary)
|
|
1024
|
+
contract_gaps = _contract_gaps(summary, decision_context)
|
|
1025
|
+
root_cause_code, root_cause_label = _root_cause(
|
|
1026
|
+
payload,
|
|
1027
|
+
summary,
|
|
1028
|
+
decision_context=decision_context,
|
|
1029
|
+
blocker_context=blocker_context,
|
|
1030
|
+
environment_blocker_context=environment_blocker_context,
|
|
1031
|
+
missing_inputs=missing_inputs,
|
|
1032
|
+
contract_gaps=contract_gaps,
|
|
1033
|
+
)
|
|
1034
|
+
recovery_command = _recovery_command(payload, summary, root_cause_code, decision_context, blocker_context)
|
|
1035
|
+
context = {
|
|
1036
|
+
"root_cause_code": root_cause_code,
|
|
1037
|
+
"root_cause_label": root_cause_label,
|
|
1038
|
+
"recovery_command": recovery_command,
|
|
1039
|
+
"missing_inputs": missing_inputs,
|
|
1040
|
+
"decision_context": decision_context,
|
|
1041
|
+
"blocker_context": blocker_context,
|
|
1042
|
+
"environment_blocker_context": environment_blocker_context,
|
|
1043
|
+
"agent_behavior_context": agent_behavior_context,
|
|
1044
|
+
"retry_governance": retry_governance,
|
|
1045
|
+
"contract_gaps": contract_gaps,
|
|
1046
|
+
}
|
|
1047
|
+
if error_ctx:
|
|
1048
|
+
context["error_context"] = error_ctx
|
|
1049
|
+
return context
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _normalized_public_report(value: Any, *, depth: int = 0) -> Any:
|
|
1053
|
+
if depth > 4:
|
|
1054
|
+
return redact_snippet(value)
|
|
1055
|
+
if isinstance(value, str):
|
|
1056
|
+
return redact_snippet(value, max_chars=700)
|
|
1057
|
+
if isinstance(value, bool) or value is None:
|
|
1058
|
+
return value
|
|
1059
|
+
if isinstance(value, (int, float)):
|
|
1060
|
+
return value
|
|
1061
|
+
if isinstance(value, list):
|
|
1062
|
+
return [_normalized_public_report(item, depth=depth + 1) for item in value[:30]]
|
|
1063
|
+
if isinstance(value, dict):
|
|
1064
|
+
clean: dict[str, Any] = {}
|
|
1065
|
+
for key, item in value.items():
|
|
1066
|
+
clean[str(key)] = _normalized_public_report(item, depth=depth + 1)
|
|
1067
|
+
return clean
|
|
1068
|
+
return redact_snippet(value)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _materialized_agent_directive(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1072
|
+
directive = payload.get("agent_directive")
|
|
1073
|
+
if not isinstance(directive, dict):
|
|
1074
|
+
return {}
|
|
1075
|
+
normalized = _normalized_public_report(directive)
|
|
1076
|
+
return normalized if isinstance(normalized, dict) else {}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _normalized_error_context(value: Any) -> dict[str, Any]:
|
|
1080
|
+
if not isinstance(value, dict):
|
|
1081
|
+
return {}
|
|
1082
|
+
required = (
|
|
1083
|
+
"phase",
|
|
1084
|
+
"blocked_reason",
|
|
1085
|
+
"root_cause",
|
|
1086
|
+
"affected_artifact",
|
|
1087
|
+
"error_summary",
|
|
1088
|
+
"suggested_fix",
|
|
1089
|
+
"next_action",
|
|
1090
|
+
"retry_scope",
|
|
1091
|
+
)
|
|
1092
|
+
context: dict[str, Any] = {}
|
|
1093
|
+
for key in required:
|
|
1094
|
+
text = str(value.get(key) or "").strip()
|
|
1095
|
+
if text:
|
|
1096
|
+
context[key] = (
|
|
1097
|
+
_redact_operational_identifier(text, max_chars=120)
|
|
1098
|
+
if key in {"phase", "retry_scope"}
|
|
1099
|
+
else redact_snippet(text, max_chars=500)
|
|
1100
|
+
)
|
|
1101
|
+
for key in ("affected_items", "missing_inputs"):
|
|
1102
|
+
items = value.get(key)
|
|
1103
|
+
if isinstance(items, list):
|
|
1104
|
+
clean = [redact_snippet(item, max_chars=160) for item in items if str(item).strip()]
|
|
1105
|
+
if clean:
|
|
1106
|
+
context[key] = clean[:20]
|
|
1107
|
+
for key in ("max_attempts", "attempt_index"):
|
|
1108
|
+
if key in value:
|
|
1109
|
+
try:
|
|
1110
|
+
context[key] = int(value[key])
|
|
1111
|
+
except (TypeError, ValueError):
|
|
1112
|
+
pass
|
|
1113
|
+
if "human_decision_required" in value:
|
|
1114
|
+
context["human_decision_required"] = bool(value.get("human_decision_required"))
|
|
1115
|
+
if not all(key in context for key in required):
|
|
1116
|
+
return {}
|
|
1117
|
+
return context
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _prompt_hardening_context(
|
|
1121
|
+
payload: dict[str, Any],
|
|
1122
|
+
summary: dict[str, Any],
|
|
1123
|
+
*,
|
|
1124
|
+
workflow: str,
|
|
1125
|
+
command: str,
|
|
1126
|
+
) -> dict[str, Any]:
|
|
1127
|
+
error_context = _normalized_error_context(payload.get("error_context"))
|
|
1128
|
+
evidence_field_candidates = (
|
|
1129
|
+
"diagnosis_path",
|
|
1130
|
+
"diagnosis",
|
|
1131
|
+
"db_path",
|
|
1132
|
+
"manifest_path",
|
|
1133
|
+
"manifest",
|
|
1134
|
+
"plan_path",
|
|
1135
|
+
"receipt_path",
|
|
1136
|
+
"receipt",
|
|
1137
|
+
"dry_run_receipt_path",
|
|
1138
|
+
"dry_run",
|
|
1139
|
+
"output_path",
|
|
1140
|
+
)
|
|
1141
|
+
evidence_fields = [
|
|
1142
|
+
field
|
|
1143
|
+
for field in evidence_field_candidates
|
|
1144
|
+
if payload.get(field) not in (None, "", [], {})
|
|
1145
|
+
]
|
|
1146
|
+
field_values = {
|
|
1147
|
+
"app_version": payload.get("app_version") or payload.get("version"),
|
|
1148
|
+
"workflow": payload.get("workflow") or workflow,
|
|
1149
|
+
"phase": summary.get("phase") or payload.get("phase"),
|
|
1150
|
+
"command": payload.get("command") or command,
|
|
1151
|
+
"blocked_reason": summary.get("blocked_reason") or payload.get("blocked_reason"),
|
|
1152
|
+
"next_action": summary.get("next_action") or payload.get("next_action"),
|
|
1153
|
+
"error_context": error_context,
|
|
1154
|
+
"operational_evidence": evidence_fields,
|
|
1155
|
+
}
|
|
1156
|
+
required_fields = [
|
|
1157
|
+
"app_version",
|
|
1158
|
+
"workflow",
|
|
1159
|
+
"phase",
|
|
1160
|
+
"command",
|
|
1161
|
+
"blocked_reason",
|
|
1162
|
+
"next_action",
|
|
1163
|
+
"error_context",
|
|
1164
|
+
"operational_evidence",
|
|
1165
|
+
]
|
|
1166
|
+
present_fields = [field for field in required_fields if bool(field_values.get(field))]
|
|
1167
|
+
missing_fields = [field for field in required_fields if field not in present_fields]
|
|
1168
|
+
quality_flags = []
|
|
1169
|
+
if missing_fields:
|
|
1170
|
+
quality_flags.append("prompt_context_incomplete")
|
|
1171
|
+
if "error_context" in missing_fields and str(summary.get("status") or "") in {"blocked", "failed", "error"}:
|
|
1172
|
+
quality_flags.append("missing_error_context")
|
|
1173
|
+
if "operational_evidence" in missing_fields:
|
|
1174
|
+
quality_flags.append("missing_operational_evidence")
|
|
1175
|
+
return {
|
|
1176
|
+
"status": "complete" if not missing_fields else "incomplete",
|
|
1177
|
+
"required_fields": required_fields,
|
|
1178
|
+
"present_fields": present_fields,
|
|
1179
|
+
"missing_fields": missing_fields,
|
|
1180
|
+
"evidence_fields": evidence_fields,
|
|
1181
|
+
"quality_flags": quality_flags,
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _inherited_feedback_summary_context(payload: dict[str, Any], *, workflow: str, command: str) -> dict[str, Any]:
|
|
1186
|
+
diagnostic = payload.get("diagnostic_context") if isinstance(payload.get("diagnostic_context"), dict) else {}
|
|
1187
|
+
inherited = diagnostic.get("inherited_feedback_context") if isinstance(diagnostic, dict) else {}
|
|
1188
|
+
fields = [
|
|
1189
|
+
"workflow",
|
|
1190
|
+
"phase",
|
|
1191
|
+
"command",
|
|
1192
|
+
"blocked_reason",
|
|
1193
|
+
"next_action",
|
|
1194
|
+
"error_context",
|
|
1195
|
+
"operational_evidence",
|
|
1196
|
+
]
|
|
1197
|
+
present = [
|
|
1198
|
+
field
|
|
1199
|
+
for field, value in {
|
|
1200
|
+
"workflow": workflow,
|
|
1201
|
+
"phase": payload.get("phase"),
|
|
1202
|
+
"command": command,
|
|
1203
|
+
"blocked_reason": payload.get("blocked_reason"),
|
|
1204
|
+
"next_action": payload.get("next_action"),
|
|
1205
|
+
"error_context": payload.get("error_context"),
|
|
1206
|
+
"operational_evidence": inherited,
|
|
1207
|
+
}.items()
|
|
1208
|
+
if value not in (None, "", [], {})
|
|
1209
|
+
]
|
|
1210
|
+
return {
|
|
1211
|
+
"status": "inherited_summary",
|
|
1212
|
+
"required_fields": fields,
|
|
1213
|
+
"present_fields": present,
|
|
1214
|
+
"missing_fields": [],
|
|
1215
|
+
"evidence_fields": ["inherited_feedback_context"],
|
|
1216
|
+
"quality_flags": [],
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _retry_governance_context(
|
|
1221
|
+
payload: dict[str, Any],
|
|
1222
|
+
summary: dict[str, Any],
|
|
1223
|
+
error_context: dict[str, Any],
|
|
1224
|
+
) -> dict[str, Any]:
|
|
1225
|
+
category = _retry_category(payload, summary, error_context)
|
|
1226
|
+
budget = RETRY_BUDGETS.get(category, {})
|
|
1227
|
+
return {
|
|
1228
|
+
"category": category,
|
|
1229
|
+
"max_attempts": int(budget.get("max_attempts", 1)),
|
|
1230
|
+
"rule": str(budget.get("rule") or "Retry deve seguir next_action e mudar input relevante antes de repetir."),
|
|
1231
|
+
"requires_input_change": category in {"dry_run", "coverage_stage", "triage_correction"},
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _retry_category(payload: dict[str, Any], summary: dict[str, Any], error_context: dict[str, Any]) -> str:
|
|
1236
|
+
phase = _code_slug(summary.get("phase") or payload.get("phase") or "")
|
|
1237
|
+
retry_scope = _code_slug(error_context.get("retry_scope") or payload.get("retry_scope") or "")
|
|
1238
|
+
blocked_reason = _code_slug(summary.get("blocked_reason") or "")
|
|
1239
|
+
if "rollback" in phase or "rollback" in retry_scope:
|
|
1240
|
+
return "publish_rollback"
|
|
1241
|
+
if payload.get("dry_run") is True or "dry_run" in phase or "dry_run" in retry_scope:
|
|
1242
|
+
return "dry_run"
|
|
1243
|
+
if "rewrite" in phase or "rewrite" in retry_scope or "fix_note" in phase:
|
|
1244
|
+
return "rewrite"
|
|
1245
|
+
if "triage" in phase or "note_plan" in retry_scope or blocked_reason == "note_plan_invalid":
|
|
1246
|
+
return "triage_correction"
|
|
1247
|
+
if "stage" in phase or "coverage" in phase or "coverage" in retry_scope or blocked_reason in {
|
|
1248
|
+
"coverage_invalid",
|
|
1249
|
+
"coverage_path_missing",
|
|
1250
|
+
"provenance_gap",
|
|
1251
|
+
}:
|
|
1252
|
+
return "coverage_stage"
|
|
1253
|
+
return "generic"
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _environment_blocker_context(payload: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
|
|
1257
|
+
codes: list[str] = []
|
|
1258
|
+
samples: list[str] = []
|
|
1259
|
+
status = str(summary.get("status") or payload.get("status") or "").lower()
|
|
1260
|
+
blocked_reason = _code_slug(summary.get("blocked_reason") or payload.get("blocked_reason") or "")
|
|
1261
|
+
problem_status = status in {"blocked", "failed", "error"}
|
|
1262
|
+
explicit_environment = False
|
|
1263
|
+
|
|
1264
|
+
preflight = payload.get("environment_preflight")
|
|
1265
|
+
if isinstance(preflight, dict):
|
|
1266
|
+
preflight_blockers = [
|
|
1267
|
+
_code_slug(item)
|
|
1268
|
+
for item in preflight.get("blockers", [])
|
|
1269
|
+
if str(item).strip()
|
|
1270
|
+
]
|
|
1271
|
+
if preflight_blockers or _code_slug(preflight.get("blocked_reason") or "") == _code_slug(ENVIRONMENT_BLOCKER_CODE):
|
|
1272
|
+
codes.extend(preflight_blockers or [ENVIRONMENT_BLOCKER_CODE])
|
|
1273
|
+
samples.extend(_environment_preflight_samples(preflight))
|
|
1274
|
+
problem_status = True
|
|
1275
|
+
explicit_environment = True
|
|
1276
|
+
|
|
1277
|
+
if blocked_reason in {
|
|
1278
|
+
_code_slug(ENVIRONMENT_BLOCKER_CODE),
|
|
1279
|
+
"windows_path_or_venv",
|
|
1280
|
+
"uv_unavailable",
|
|
1281
|
+
"python_environment",
|
|
1282
|
+
}:
|
|
1283
|
+
codes.append(blocked_reason)
|
|
1284
|
+
problem_status = True
|
|
1285
|
+
explicit_environment = True
|
|
1286
|
+
|
|
1287
|
+
command_failed = _payload_has_failed_command(payload)
|
|
1288
|
+
if explicit_environment or command_failed:
|
|
1289
|
+
for text in _environment_text_candidates(payload, summary):
|
|
1290
|
+
matched = False
|
|
1291
|
+
for pattern, code in ENVIRONMENT_PATTERN_CODES:
|
|
1292
|
+
if pattern.search(text):
|
|
1293
|
+
codes.append(code)
|
|
1294
|
+
matched = True
|
|
1295
|
+
if matched and len(samples) < MAX_DIAGNOSTIC_ITEMS:
|
|
1296
|
+
samples.append(redact_snippet(text, max_chars=260))
|
|
1297
|
+
|
|
1298
|
+
codes = _dedupe(_code_slug(code) for code in codes if code)
|
|
1299
|
+
if not explicit_environment and not any(code in STRONG_ENVIRONMENT_CODES for code in codes):
|
|
1300
|
+
return {}
|
|
1301
|
+
if not codes:
|
|
1302
|
+
return {}
|
|
1303
|
+
|
|
1304
|
+
next_action = _environment_recovery_action(codes, payload, summary)
|
|
1305
|
+
severity = "high" if problem_status or command_failed else "medium"
|
|
1306
|
+
return {
|
|
1307
|
+
"code": ENVIRONMENT_BLOCKER_CODE,
|
|
1308
|
+
"kind": "windows_path_or_venv",
|
|
1309
|
+
"severity": severity,
|
|
1310
|
+
"codes": codes[:8],
|
|
1311
|
+
"samples": _dedupe(samples)[:MAX_DIAGNOSTIC_ITEMS],
|
|
1312
|
+
"setup_command": "/mednotes:setup",
|
|
1313
|
+
"reset_command": (
|
|
1314
|
+
"scripts\\bootstrap_windows_python_uv.ps1; fallback scripts\\reset_windows_python_uv.ps1 -FullReset"
|
|
1315
|
+
),
|
|
1316
|
+
"next_action": next_action,
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _environment_preflight_samples(preflight: dict[str, Any]) -> list[str]:
|
|
1321
|
+
samples: list[str] = []
|
|
1322
|
+
for key in ("next_action", "setup_command", "reset_command"):
|
|
1323
|
+
value = str(preflight.get(key) or "").strip()
|
|
1324
|
+
if value:
|
|
1325
|
+
samples.append(redact_snippet(value, max_chars=260))
|
|
1326
|
+
checks = preflight.get("checks")
|
|
1327
|
+
if isinstance(checks, list):
|
|
1328
|
+
for check in checks[:20]:
|
|
1329
|
+
if not isinstance(check, dict) or check.get("ok") is not False:
|
|
1330
|
+
continue
|
|
1331
|
+
name = str(check.get("name") or "")
|
|
1332
|
+
detail = str(check.get("detail") or "")
|
|
1333
|
+
samples.append(redact_snippet(f"{name}: {detail}", max_chars=260))
|
|
1334
|
+
if len(samples) >= MAX_DIAGNOSTIC_ITEMS:
|
|
1335
|
+
break
|
|
1336
|
+
return samples
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _payload_has_failed_command(payload: dict[str, Any]) -> bool:
|
|
1340
|
+
events = payload.get("command_events")
|
|
1341
|
+
if not isinstance(events, list):
|
|
1342
|
+
return False
|
|
1343
|
+
for event in events[:MAX_COMMAND_EVENTS]:
|
|
1344
|
+
if not isinstance(event, dict):
|
|
1345
|
+
continue
|
|
1346
|
+
status = _code_slug(event.get("status") or "")
|
|
1347
|
+
exit_code = event.get("exit_code")
|
|
1348
|
+
if status in {"failed", "error"} or (isinstance(exit_code, int) and exit_code != 0) or event.get("error"):
|
|
1349
|
+
return True
|
|
1350
|
+
return False
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def _environment_text_candidates(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
|
|
1354
|
+
values: list[str] = []
|
|
1355
|
+
values.extend(
|
|
1356
|
+
str(item or "")
|
|
1357
|
+
for item in (
|
|
1358
|
+
summary.get("blocked_reason"),
|
|
1359
|
+
summary.get("next_action"),
|
|
1360
|
+
" ".join(str(item) for item in summary.get("errors", [])),
|
|
1361
|
+
" ".join(str(item) for item in summary.get("warnings", [])),
|
|
1362
|
+
)
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
interesting_keys = {
|
|
1366
|
+
"blocked_reason",
|
|
1367
|
+
"next_action",
|
|
1368
|
+
"error",
|
|
1369
|
+
"errors",
|
|
1370
|
+
"warning",
|
|
1371
|
+
"warnings",
|
|
1372
|
+
"message",
|
|
1373
|
+
"detail",
|
|
1374
|
+
"command",
|
|
1375
|
+
"stdout",
|
|
1376
|
+
"stderr",
|
|
1377
|
+
"output",
|
|
1378
|
+
"stdout_tail",
|
|
1379
|
+
"stderr_tail",
|
|
1380
|
+
"output_tail",
|
|
1381
|
+
"path",
|
|
1382
|
+
"python",
|
|
1383
|
+
"uv_path",
|
|
1384
|
+
"persistent_venv",
|
|
1385
|
+
"platform",
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
def visit(value: Any, key: str = "", depth: int = 0) -> None:
|
|
1389
|
+
if depth > 4 or len(values) >= 80:
|
|
1390
|
+
return
|
|
1391
|
+
if isinstance(value, dict):
|
|
1392
|
+
for child_key, child_value in list(value.items())[:40]:
|
|
1393
|
+
visit(child_value, str(child_key), depth + 1)
|
|
1394
|
+
elif isinstance(value, list):
|
|
1395
|
+
for item in value[:20]:
|
|
1396
|
+
visit(item, key, depth + 1)
|
|
1397
|
+
elif isinstance(value, str):
|
|
1398
|
+
lower = key.lower()
|
|
1399
|
+
if lower in LONG_TEXT_KEYS:
|
|
1400
|
+
return
|
|
1401
|
+
if lower in interesting_keys or any(token in lower for token in ("command", "error", "path", "venv")):
|
|
1402
|
+
values.append(value)
|
|
1403
|
+
|
|
1404
|
+
visit(payload)
|
|
1405
|
+
return _dedupe(text for text in values if str(text).strip())[:80]
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _environment_recovery_action(
|
|
1409
|
+
codes: list[str],
|
|
1410
|
+
payload: dict[str, Any],
|
|
1411
|
+
summary: dict[str, Any],
|
|
1412
|
+
) -> str:
|
|
1413
|
+
preflight = payload.get("environment_preflight")
|
|
1414
|
+
if isinstance(preflight, dict) and preflight.get("next_action"):
|
|
1415
|
+
return redact_snippet(preflight["next_action"], max_chars=300)
|
|
1416
|
+
value = str(summary.get("next_action") or payload.get("next_action") or "").strip()
|
|
1417
|
+
if value and any(token in _code_slug(value) for token in ("setup", "bootstrap", "reset_windows", "uv_project_environment")):
|
|
1418
|
+
return redact_snippet(value, max_chars=300)
|
|
1419
|
+
windowsish = any(code.startswith(("windows", "powershell", "crlf")) for code in codes)
|
|
1420
|
+
if windowsish:
|
|
1421
|
+
return (
|
|
1422
|
+
"Rodar /mednotes:setup. Se persistir no Windows, executar "
|
|
1423
|
+
"scripts\\bootstrap_windows_python_uv.ps1; como fallback, "
|
|
1424
|
+
"scripts\\reset_windows_python_uv.ps1 -FullReset. Nao editar scripts/runbooks como workaround."
|
|
1425
|
+
)
|
|
1426
|
+
return (
|
|
1427
|
+
"Rodar /mednotes:setup, configurar UV_PROJECT_ENVIRONMENT para a venv persistente, "
|
|
1428
|
+
"executar uv sync e repetir o workflow sem editar scripts/runbooks como workaround."
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _environment_error_context(
|
|
1433
|
+
payload: dict[str, Any],
|
|
1434
|
+
summary: dict[str, Any],
|
|
1435
|
+
environment_blocker_context: dict[str, Any],
|
|
1436
|
+
) -> dict[str, Any]:
|
|
1437
|
+
codes = environment_blocker_context.get("codes") if isinstance(environment_blocker_context.get("codes"), list) else []
|
|
1438
|
+
samples = environment_blocker_context.get("samples") if isinstance(environment_blocker_context.get("samples"), list) else []
|
|
1439
|
+
error_summary = "Ambiente/path/venv bloqueou a execucao."
|
|
1440
|
+
if codes:
|
|
1441
|
+
error_summary = f"Ambiente/path/venv bloqueou a execucao: {', '.join(str(code) for code in codes[:5])}."
|
|
1442
|
+
if samples:
|
|
1443
|
+
error_summary += f" Amostra: {samples[0]}"
|
|
1444
|
+
context = {
|
|
1445
|
+
"phase": summary.get("phase") or payload.get("phase") or "environment",
|
|
1446
|
+
"blocked_reason": ENVIRONMENT_BLOCKER_CODE,
|
|
1447
|
+
"root_cause": "Preflight ou console indicou problema de Python, uv, venv persistente, PowerShell ou path Windows.",
|
|
1448
|
+
"affected_artifact": ", ".join(str(code) for code in codes[:5]) or "python/uv/persistent_venv/path",
|
|
1449
|
+
"error_summary": error_summary,
|
|
1450
|
+
"suggested_fix": "Corrigir setup/venv/path pelo comando oficial; nao reescrever scripts, prompts ou runbooks para contornar ambiente.",
|
|
1451
|
+
"next_action": environment_blocker_context.get("next_action") or _environment_recovery_action(codes, payload, summary),
|
|
1452
|
+
"retry_scope": "setup_reset_then_retry",
|
|
1453
|
+
"missing_inputs": ["python", "uv", "persistent_venv", "wiki_dir"],
|
|
1454
|
+
"human_decision_required": False,
|
|
1455
|
+
}
|
|
1456
|
+
return _normalized_error_context(context)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def _agent_behavior_context(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1460
|
+
events = _normalized_agent_events(payload)
|
|
1461
|
+
type_counts = Counter(str(event.get("type") or "unknown") for event in events)
|
|
1462
|
+
severity_counts = Counter(str(event.get("severity") or "low") for event in events)
|
|
1463
|
+
codes = _dedupe(str(event.get("code") or "") for event in events if event.get("code"))
|
|
1464
|
+
return {
|
|
1465
|
+
"event_count": len(events),
|
|
1466
|
+
"types": dict(type_counts),
|
|
1467
|
+
"severities": dict(severity_counts),
|
|
1468
|
+
"highest_severity": _highest_agent_event_severity(events),
|
|
1469
|
+
"codes": codes[:8],
|
|
1470
|
+
"samples": events[:MAX_AGENT_EVENT_SAMPLES],
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _normalized_agent_events(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
1475
|
+
raw_events = payload.get("agent_events")
|
|
1476
|
+
if not isinstance(raw_events, list):
|
|
1477
|
+
return []
|
|
1478
|
+
events: list[dict[str, Any]] = []
|
|
1479
|
+
for item in raw_events[:MAX_AGENT_EVENTS]:
|
|
1480
|
+
if not isinstance(item, dict):
|
|
1481
|
+
continue
|
|
1482
|
+
event_type = _code_slug(item.get("type") or "unknown") or "unknown"
|
|
1483
|
+
severity = _normalize_severity(item.get("severity"))
|
|
1484
|
+
event = {
|
|
1485
|
+
"type": event_type,
|
|
1486
|
+
"code": f"agent.{event_type}",
|
|
1487
|
+
"phase": _redact_operational_identifier(item.get("phase") or payload.get("phase") or "", max_chars=80),
|
|
1488
|
+
"severity": severity,
|
|
1489
|
+
"summary": redact_snippet(item.get("summary") or "", max_chars=220),
|
|
1490
|
+
"action": redact_snippet(item.get("action") or "", max_chars=220),
|
|
1491
|
+
"target_kind": _code_slug(item.get("target_kind") or ""),
|
|
1492
|
+
"result": _code_slug(item.get("result") or ""),
|
|
1493
|
+
}
|
|
1494
|
+
optional_text = {
|
|
1495
|
+
"expected_phase": _redact_operational_identifier(item.get("expected_phase") or "", max_chars=80),
|
|
1496
|
+
"actual_phase": _redact_operational_identifier(
|
|
1497
|
+
item.get("actual_phase") or item.get("executed_phase") or "",
|
|
1498
|
+
max_chars=80,
|
|
1499
|
+
),
|
|
1500
|
+
"executed_action": redact_snippet(item.get("executed_action") or item.get("actual_action") or "", max_chars=220),
|
|
1501
|
+
"command_family": _code_slug(item.get("command_family") or ""),
|
|
1502
|
+
"blocked_reason": _code_slug(item.get("blocked_reason") or ""),
|
|
1503
|
+
"next_action_expected": redact_snippet(
|
|
1504
|
+
item.get("next_action_expected") or item.get("expected_next_action") or "",
|
|
1505
|
+
max_chars=220,
|
|
1506
|
+
),
|
|
1507
|
+
"snippet": redact_snippet(item.get("snippet") or "", max_chars=220),
|
|
1508
|
+
}
|
|
1509
|
+
for key, value in optional_text.items():
|
|
1510
|
+
if value:
|
|
1511
|
+
event[key] = value
|
|
1512
|
+
if item.get("path"):
|
|
1513
|
+
event["path"] = _compact_path_label(str(item.get("path")))
|
|
1514
|
+
events.append(event)
|
|
1515
|
+
return events
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _normalize_severity(value: Any) -> str:
|
|
1519
|
+
severity = _code_slug(value or "low")
|
|
1520
|
+
if severity in {"high", "medium", "low"}:
|
|
1521
|
+
return severity
|
|
1522
|
+
return "low"
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _highest_agent_event_severity(events: list[dict[str, Any]]) -> str:
|
|
1526
|
+
highest = "low"
|
|
1527
|
+
for event in events:
|
|
1528
|
+
severity = str(event.get("severity") or "low")
|
|
1529
|
+
if _severity_rank(severity) > _severity_rank(highest):
|
|
1530
|
+
highest = severity
|
|
1531
|
+
return highest if events else ""
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _with_derived_agent_events(
|
|
1535
|
+
payload: dict[str, Any],
|
|
1536
|
+
summary: dict[str, Any],
|
|
1537
|
+
error_context: dict[str, Any],
|
|
1538
|
+
*,
|
|
1539
|
+
source: str,
|
|
1540
|
+
) -> dict[str, Any]:
|
|
1541
|
+
if source != "agent":
|
|
1542
|
+
return dict(payload)
|
|
1543
|
+
derived = _derived_agent_events(payload, summary, error_context)
|
|
1544
|
+
if not derived:
|
|
1545
|
+
return dict(payload)
|
|
1546
|
+
enriched = dict(payload)
|
|
1547
|
+
existing = payload.get("agent_events")
|
|
1548
|
+
events = list(existing) if isinstance(existing, list) else []
|
|
1549
|
+
existing_types = {
|
|
1550
|
+
_code_slug(item.get("type") or "")
|
|
1551
|
+
for item in events
|
|
1552
|
+
if isinstance(item, dict)
|
|
1553
|
+
}
|
|
1554
|
+
for event in derived:
|
|
1555
|
+
if _code_slug(event.get("type") or "") not in existing_types:
|
|
1556
|
+
events.append(event)
|
|
1557
|
+
existing_types.add(_code_slug(event.get("type") or ""))
|
|
1558
|
+
enriched["agent_events"] = events
|
|
1559
|
+
return enriched
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _agent_timeout_or_max_turns_detected(payload: dict[str, Any], *, blocked_reason: str) -> bool:
|
|
1563
|
+
candidates = [
|
|
1564
|
+
blocked_reason,
|
|
1565
|
+
payload.get("blocked_reason"),
|
|
1566
|
+
payload.get("error"),
|
|
1567
|
+
payload.get("error_summary"),
|
|
1568
|
+
payload.get("root_cause"),
|
|
1569
|
+
payload.get("stop_condition"),
|
|
1570
|
+
payload.get("failure_reason"),
|
|
1571
|
+
]
|
|
1572
|
+
for value in candidates:
|
|
1573
|
+
slug = _code_slug(value or "")
|
|
1574
|
+
if "timeout" in slug or "max_turns" in slug or "turn_budget" in slug:
|
|
1575
|
+
return True
|
|
1576
|
+
metrics = payload.get("agent_metrics")
|
|
1577
|
+
if isinstance(metrics, dict):
|
|
1578
|
+
turns_used = _safe_int(metrics.get("turns_used"))
|
|
1579
|
+
max_turns = _safe_int(metrics.get("max_turns"))
|
|
1580
|
+
if max_turns and turns_used >= max_turns:
|
|
1581
|
+
return True
|
|
1582
|
+
return False
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _derived_agent_events(
|
|
1586
|
+
payload: dict[str, Any],
|
|
1587
|
+
summary: dict[str, Any],
|
|
1588
|
+
error_context: dict[str, Any],
|
|
1589
|
+
) -> list[dict[str, Any]]:
|
|
1590
|
+
events: list[dict[str, Any]] = []
|
|
1591
|
+
status = str(summary.get("status") or "")
|
|
1592
|
+
phase = str(summary.get("phase") or payload.get("phase") or "")
|
|
1593
|
+
blocked_reason = str(summary.get("blocked_reason") or "")
|
|
1594
|
+
next_action = str(summary.get("next_action") or "")
|
|
1595
|
+
executed_action = str(
|
|
1596
|
+
payload.get("executed_action")
|
|
1597
|
+
or payload.get("actual_action")
|
|
1598
|
+
or payload.get("agent_action")
|
|
1599
|
+
or payload.get("command")
|
|
1600
|
+
or ""
|
|
1601
|
+
)
|
|
1602
|
+
expected_next_action = str(payload.get("expected_next_action") or payload.get("next_action_expected") or next_action)
|
|
1603
|
+
expected_phase = str(payload.get("expected_phase") or payload.get("allowed_phase") or payload.get("expected_next_phase") or "")
|
|
1604
|
+
actual_phase = str(payload.get("actual_phase") or payload.get("executed_phase") or phase)
|
|
1605
|
+
timeout_or_max_turns = status in {"blocked", "failed", "error"} and _agent_timeout_or_max_turns_detected(
|
|
1606
|
+
payload,
|
|
1607
|
+
blocked_reason=blocked_reason,
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
if expected_phase and actual_phase and _code_slug(expected_phase) != _code_slug(actual_phase):
|
|
1611
|
+
events.append(
|
|
1612
|
+
{
|
|
1613
|
+
"type": "wrong_phase",
|
|
1614
|
+
"phase": actual_phase,
|
|
1615
|
+
"expected_phase": expected_phase,
|
|
1616
|
+
"actual_phase": actual_phase,
|
|
1617
|
+
"severity": "high",
|
|
1618
|
+
"summary": f"Agente executou fase {actual_phase} quando a fase esperada era {expected_phase}.",
|
|
1619
|
+
"action": "Voltar para a fase esperada antes de mutar qualquer artefato.",
|
|
1620
|
+
"target_kind": "workflow",
|
|
1621
|
+
"result": "blocked",
|
|
1622
|
+
"expected_next_action": expected_next_action,
|
|
1623
|
+
"executed_action": executed_action,
|
|
1624
|
+
}
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
if expected_next_action and executed_action and not _actions_are_compatible(expected_next_action, executed_action):
|
|
1628
|
+
events.append(
|
|
1629
|
+
{
|
|
1630
|
+
"type": "ignored_next_action",
|
|
1631
|
+
"phase": phase,
|
|
1632
|
+
"severity": "high" if status in {"blocked", "failed", "error"} else "medium",
|
|
1633
|
+
"summary": "Agente executou ação fora da rota indicada por next_action.",
|
|
1634
|
+
"action": "Interromper retry e seguir a next_action esperada.",
|
|
1635
|
+
"target_kind": "workflow",
|
|
1636
|
+
"result": status or "detected",
|
|
1637
|
+
"expected_next_action": expected_next_action,
|
|
1638
|
+
"executed_action": executed_action,
|
|
1639
|
+
"blocked_reason": blocked_reason,
|
|
1640
|
+
}
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
if timeout_or_max_turns:
|
|
1644
|
+
events.append(
|
|
1645
|
+
{
|
|
1646
|
+
"type": "timeout_or_max_turns",
|
|
1647
|
+
"phase": phase,
|
|
1648
|
+
"severity": "high",
|
|
1649
|
+
"summary": "Subagente excedeu timeout ou max_turns antes de entregar output aplicavel.",
|
|
1650
|
+
"action": next_action
|
|
1651
|
+
or "Parar retry cego, registrar blocked packet com error_context e reduzir o escopo do work item.",
|
|
1652
|
+
"target_kind": "subagent",
|
|
1653
|
+
"result": status,
|
|
1654
|
+
"blocked_reason": blocked_reason or "timeout_or_max_turns",
|
|
1655
|
+
"expected_next_action": next_action,
|
|
1656
|
+
}
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
if status in {"blocked", "failed", "error"} and blocked_reason:
|
|
1660
|
+
events.append(
|
|
1661
|
+
{
|
|
1662
|
+
"type": "workflow_blocked",
|
|
1663
|
+
"phase": phase,
|
|
1664
|
+
"severity": "medium" if next_action else "high",
|
|
1665
|
+
"summary": f"Workflow parou em {blocked_reason}.",
|
|
1666
|
+
"action": next_action or "Adicionar next_action/error_context antes de repetir.",
|
|
1667
|
+
"target_kind": "workflow",
|
|
1668
|
+
"result": status,
|
|
1669
|
+
"blocked_reason": blocked_reason,
|
|
1670
|
+
"expected_next_action": next_action,
|
|
1671
|
+
}
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
if timeout_or_max_turns and not isinstance(payload.get("agent_metrics"), dict):
|
|
1675
|
+
events.append(
|
|
1676
|
+
{
|
|
1677
|
+
"type": "missing_agent_metrics",
|
|
1678
|
+
"phase": phase,
|
|
1679
|
+
"severity": "high",
|
|
1680
|
+
"summary": "Subagente bloqueado por timeout/max_turns sem agent_metrics estruturado.",
|
|
1681
|
+
"action": "Exigir agent_metrics no blocked packet antes de reexecutar ou comparar baseline de prompt.",
|
|
1682
|
+
"target_kind": "subagent",
|
|
1683
|
+
"result": status,
|
|
1684
|
+
"blocked_reason": blocked_reason or "timeout_or_max_turns",
|
|
1685
|
+
"expected_next_action": next_action,
|
|
1686
|
+
}
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
if status in {"blocked", "failed", "error"} and not error_context:
|
|
1690
|
+
events.append(
|
|
1691
|
+
{
|
|
1692
|
+
"type": "missing_error_context",
|
|
1693
|
+
"phase": phase,
|
|
1694
|
+
"severity": "high",
|
|
1695
|
+
"summary": "Run agentico bloqueou/falhou sem error_context estruturado.",
|
|
1696
|
+
"action": "Registrar error_context com causa, artefato afetado, retry_scope e next_action antes de tentar novamente.",
|
|
1697
|
+
"target_kind": "workflow",
|
|
1698
|
+
"result": status,
|
|
1699
|
+
"blocked_reason": blocked_reason,
|
|
1700
|
+
"expected_next_action": next_action,
|
|
1701
|
+
}
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
if payload.get("manual_intervention") or payload.get("manual_intervention_required"):
|
|
1705
|
+
events.append(
|
|
1706
|
+
{
|
|
1707
|
+
"type": "manual_intervention",
|
|
1708
|
+
"phase": phase,
|
|
1709
|
+
"severity": "medium",
|
|
1710
|
+
"summary": "Run exigiu intervenção manual.",
|
|
1711
|
+
"action": str(payload.get("manual_intervention_action") or next_action or "Registrar decisão humana estruturada."),
|
|
1712
|
+
"target_kind": "workflow",
|
|
1713
|
+
"result": status or "pending",
|
|
1714
|
+
}
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
return events
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _actions_are_compatible(expected: str, executed: str) -> bool:
|
|
1721
|
+
expected_slug = _code_slug(expected)
|
|
1722
|
+
executed_slug = _code_slug(executed)
|
|
1723
|
+
if not expected_slug or not executed_slug:
|
|
1724
|
+
return True
|
|
1725
|
+
expected_command = _command_hint(expected_slug)
|
|
1726
|
+
executed_command = _command_hint(executed_slug)
|
|
1727
|
+
if expected_command and executed_command:
|
|
1728
|
+
return expected_command == executed_command
|
|
1729
|
+
expected_tokens = {token for token in expected_slug.split("_") if len(token) >= 4}
|
|
1730
|
+
executed_tokens = {token for token in executed_slug.split("_") if len(token) >= 4}
|
|
1731
|
+
if not expected_tokens or not executed_tokens:
|
|
1732
|
+
return True
|
|
1733
|
+
return bool(expected_tokens & executed_tokens)
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _command_hint(slug: str) -> str:
|
|
1737
|
+
commands = (
|
|
1738
|
+
"stage_note",
|
|
1739
|
+
"publish_batch",
|
|
1740
|
+
"triage",
|
|
1741
|
+
"plan_subagents",
|
|
1742
|
+
"validate_note",
|
|
1743
|
+
"fix_note",
|
|
1744
|
+
"run_linker",
|
|
1745
|
+
"taxonomy_resolve",
|
|
1746
|
+
"fix_wiki",
|
|
1747
|
+
"apply_style_rewrite",
|
|
1748
|
+
"apply_note_merge",
|
|
1749
|
+
)
|
|
1750
|
+
for command in commands:
|
|
1751
|
+
if command in slug:
|
|
1752
|
+
return command
|
|
1753
|
+
return ""
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _compact_path_label(value: str) -> str:
|
|
1757
|
+
text = redact_snippet(value, max_chars=220).replace(str(Path.home()), "~")
|
|
1758
|
+
parts = [part for part in re.split(r"[\\/]+", text) if part]
|
|
1759
|
+
if text.startswith("~"):
|
|
1760
|
+
prefix = "~"
|
|
1761
|
+
else:
|
|
1762
|
+
prefix = ""
|
|
1763
|
+
label = "/".join(parts[-3:]) if len(parts) > 3 else "/".join(parts) or text
|
|
1764
|
+
return f"{prefix}/{label}" if prefix and not label.startswith("~") else label
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _decision_context(payload: JsonObject, summary: JsonObject) -> JsonObject:
|
|
1768
|
+
decisions: list[JsonObject] = []
|
|
1769
|
+
raw_packets: list[JsonObject] = []
|
|
1770
|
+
human_decision_packet = _json_object_field(payload, "human_decision_packet")
|
|
1771
|
+
if human_decision_packet:
|
|
1772
|
+
raw_packets.append(human_decision_packet)
|
|
1773
|
+
for packet in _json_list_field(payload, "human_decision_packets"):
|
|
1774
|
+
packet_view = _json_object_view(packet)
|
|
1775
|
+
if packet_view:
|
|
1776
|
+
raw_packets.append(packet_view)
|
|
1777
|
+
decision_summary = _json_object_field(payload, "decision_summary")
|
|
1778
|
+
if decision_summary and not bool(_json_value(summary, "human_decision_required")):
|
|
1779
|
+
reason_code = _json_text(decision_summary, "reason_code")
|
|
1780
|
+
next_action = _json_text(payload, "next_action")
|
|
1781
|
+
decisions.append(
|
|
1782
|
+
{
|
|
1783
|
+
"kind": _code_slug(_json_text(decision_summary, "kind")),
|
|
1784
|
+
"type": _code_slug(_json_text(decision_summary, "kind")),
|
|
1785
|
+
"question": redact_snippet(_json_text(decision_summary, "public_summary"), max_chars=240),
|
|
1786
|
+
"options": [],
|
|
1787
|
+
"next_action": redact_snippet(next_action, max_chars=300),
|
|
1788
|
+
"continue_after_choice": redact_snippet(next_action, max_chars=300),
|
|
1789
|
+
"resume_action": redact_snippet(next_action, max_chars=300),
|
|
1790
|
+
"reason_code": reason_code,
|
|
1791
|
+
}
|
|
1792
|
+
)
|
|
1793
|
+
for item in _json_list_field(payload, "blocked_items")[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1794
|
+
item_view = _json_object_view(item)
|
|
1795
|
+
if not item_view:
|
|
1796
|
+
continue
|
|
1797
|
+
blocked_packet = _json_object_field(item_view, "human_decision_packet")
|
|
1798
|
+
if blocked_packet:
|
|
1799
|
+
raw_packets.append(blocked_packet)
|
|
1800
|
+
for packet in _json_list_field(item_view, "human_decision_packets"):
|
|
1801
|
+
packet_view = _json_object_view(packet)
|
|
1802
|
+
if packet_view:
|
|
1803
|
+
raw_packets.append(packet_view)
|
|
1804
|
+
for packet in raw_packets[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1805
|
+
options = []
|
|
1806
|
+
for option in _json_list_field(packet, "options")[:5]:
|
|
1807
|
+
option_view = _json_object_view(option)
|
|
1808
|
+
if option_view:
|
|
1809
|
+
options.append(
|
|
1810
|
+
{
|
|
1811
|
+
"id": redact_snippet(_json_text(option_view, "id"), max_chars=80),
|
|
1812
|
+
"label": redact_snippet(_json_text(option_view, "label"), max_chars=160),
|
|
1813
|
+
}
|
|
1814
|
+
)
|
|
1815
|
+
decisions.append(
|
|
1816
|
+
{
|
|
1817
|
+
"kind": _code_slug(_json_text(packet, "kind") or _json_text(packet, "type") or "manual_review"),
|
|
1818
|
+
"question": redact_snippet(_json_text(packet, "question"), max_chars=240),
|
|
1819
|
+
"options": options,
|
|
1820
|
+
"next_action": redact_snippet(_json_text(packet, "next_action"), max_chars=300),
|
|
1821
|
+
"continue_after_choice": redact_snippet(_json_text(packet, "resume_action"), max_chars=300),
|
|
1822
|
+
}
|
|
1823
|
+
)
|
|
1824
|
+
kinds = [str(item.get("kind") or "manual_review") for item in decisions]
|
|
1825
|
+
if not kinds and bool(_json_value(summary, "human_decision_required")):
|
|
1826
|
+
blocked_reason = _json_text(summary, "blocked_reason") or "manual_review"
|
|
1827
|
+
kinds.append(_code_slug(blocked_reason))
|
|
1828
|
+
return {
|
|
1829
|
+
"types": _dedupe(kinds)[:MAX_DIAGNOSTIC_ITEMS],
|
|
1830
|
+
"decisions": decisions,
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _blocker_context(payload: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
|
|
1835
|
+
codes: list[str] = []
|
|
1836
|
+
summaries: list[dict[str, Any]] = []
|
|
1837
|
+
raw_summary = payload.get("blocker_summary")
|
|
1838
|
+
decision_summary = payload.get("decision_summary")
|
|
1839
|
+
if isinstance(decision_summary, dict) and decision_summary.get("reason_code"):
|
|
1840
|
+
codes.append(_code_slug(decision_summary.get("reason_code")))
|
|
1841
|
+
if isinstance(raw_summary, list):
|
|
1842
|
+
for item in raw_summary[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1843
|
+
if not isinstance(item, dict):
|
|
1844
|
+
continue
|
|
1845
|
+
code = _code_slug(item.get("code") or item.get("kind") or "unknown")
|
|
1846
|
+
codes.append(code)
|
|
1847
|
+
summaries.append(
|
|
1848
|
+
{
|
|
1849
|
+
"code": code,
|
|
1850
|
+
"count": _safe_int(item.get("count")),
|
|
1851
|
+
"message": redact_snippet(item.get("message") or item.get("reason") or "", max_chars=220),
|
|
1852
|
+
}
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
samples: list[Any] = []
|
|
1856
|
+
raw_samples = payload.get("blockers_sample")
|
|
1857
|
+
if isinstance(raw_samples, list):
|
|
1858
|
+
for item in raw_samples[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1859
|
+
if isinstance(item, dict) and item.get("code"):
|
|
1860
|
+
codes.append(_code_slug(item.get("code")))
|
|
1861
|
+
samples.append(_compact_diagnostic_value(item))
|
|
1862
|
+
|
|
1863
|
+
routes: list[dict[str, Any]] = []
|
|
1864
|
+
raw_blocked_items = payload.get("blocked_items")
|
|
1865
|
+
if isinstance(raw_blocked_items, list):
|
|
1866
|
+
for item in raw_blocked_items[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1867
|
+
if not isinstance(item, dict):
|
|
1868
|
+
continue
|
|
1869
|
+
code = _code_slug(item.get("blocked_reason") or "")
|
|
1870
|
+
if code:
|
|
1871
|
+
codes.append(code)
|
|
1872
|
+
summaries.append(
|
|
1873
|
+
{
|
|
1874
|
+
"code": code,
|
|
1875
|
+
"count": 1,
|
|
1876
|
+
"message": redact_snippet(item.get("reason") or item.get("next_action") or "", max_chars=220),
|
|
1877
|
+
}
|
|
1878
|
+
)
|
|
1879
|
+
if item.get("next_action"):
|
|
1880
|
+
routes.append(
|
|
1881
|
+
{
|
|
1882
|
+
"route": code,
|
|
1883
|
+
"count": 1,
|
|
1884
|
+
"automatic": False,
|
|
1885
|
+
"reason": redact_snippet(item.get("reason") or "", max_chars=240),
|
|
1886
|
+
"next_action": redact_snippet(item.get("next_action") or "", max_chars=300),
|
|
1887
|
+
}
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
blocker_resolution = payload.get("blocker_resolution")
|
|
1891
|
+
if isinstance(blocker_resolution, dict):
|
|
1892
|
+
raw_groups = blocker_resolution.get("groups")
|
|
1893
|
+
if isinstance(raw_groups, list):
|
|
1894
|
+
for group in raw_groups[:MAX_DIAGNOSTIC_ITEMS]:
|
|
1895
|
+
if not isinstance(group, dict):
|
|
1896
|
+
continue
|
|
1897
|
+
route = _code_slug(group.get("route") or "unknown")
|
|
1898
|
+
codes.append(route)
|
|
1899
|
+
for code in group.get("codes") or []:
|
|
1900
|
+
codes.append(_code_slug(code))
|
|
1901
|
+
routes.append(
|
|
1902
|
+
{
|
|
1903
|
+
"route": route,
|
|
1904
|
+
"count": _safe_int(group.get("count")),
|
|
1905
|
+
"automatic": bool(group.get("automatic", False)),
|
|
1906
|
+
"reason": redact_snippet(group.get("reason") or "", max_chars=240),
|
|
1907
|
+
"next_action": redact_snippet(group.get("next_action") or "", max_chars=300),
|
|
1908
|
+
}
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
counts: dict[str, int | float] = {}
|
|
1912
|
+
summary_counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
|
|
1913
|
+
for key, value in summary_counts.items():
|
|
1914
|
+
leaf = str(key).split(".")[-1]
|
|
1915
|
+
if leaf in {"blocker_count", "graph_error_count", "error_count", "warning_count"}:
|
|
1916
|
+
counts[str(key)] = value
|
|
1917
|
+
|
|
1918
|
+
return {
|
|
1919
|
+
"codes": _dedupe([code for code in codes if code and code != "unknown"])[:8],
|
|
1920
|
+
"counts": counts,
|
|
1921
|
+
"summaries": summaries,
|
|
1922
|
+
"samples": samples,
|
|
1923
|
+
"routes": routes,
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def _missing_inputs(payload: dict[str, Any], summary: dict[str, Any]) -> list[str]:
|
|
1928
|
+
missing: list[str] = []
|
|
1929
|
+
for key in ("missing_inputs", "required_inputs_missing"):
|
|
1930
|
+
value = payload.get(key)
|
|
1931
|
+
if isinstance(value, list):
|
|
1932
|
+
missing.extend(str(item) for item in value if str(item).strip())
|
|
1933
|
+
text = " ".join(
|
|
1934
|
+
[
|
|
1935
|
+
str(summary.get("blocked_reason") or ""),
|
|
1936
|
+
str(summary.get("next_action") or ""),
|
|
1937
|
+
" ".join(str(item) for item in summary.get("errors", [])),
|
|
1938
|
+
" ".join(str(item) for item in summary.get("warnings", [])),
|
|
1939
|
+
]
|
|
1940
|
+
).lower()
|
|
1941
|
+
if "coverage_path" in text:
|
|
1942
|
+
missing.append("coverage_path")
|
|
1943
|
+
required_inputs = summary.get("required_inputs") if isinstance(summary.get("required_inputs"), list) else []
|
|
1944
|
+
if "coverage_path" in required_inputs and str(summary.get("status") or "") in {"blocked", "failed"} and "coverage" in text:
|
|
1945
|
+
missing.append("coverage_path")
|
|
1946
|
+
return _dedupe(_code_slug(item) for item in missing)
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def _contract_gaps(summary: JsonObject, decision_context: JsonObject) -> list[str]:
|
|
1950
|
+
gaps: list[str] = []
|
|
1951
|
+
status = str(summary.get("status") or "")
|
|
1952
|
+
statuses_requiring_next_action = {
|
|
1953
|
+
"blocked",
|
|
1954
|
+
"failed",
|
|
1955
|
+
"completed_with_warnings",
|
|
1956
|
+
"preview_ready",
|
|
1957
|
+
"ready_to_publish",
|
|
1958
|
+
"published",
|
|
1959
|
+
"completed_with_link_blockers",
|
|
1960
|
+
}
|
|
1961
|
+
if status in statuses_requiring_next_action and not summary.get("next_action"):
|
|
1962
|
+
gaps.append("missing_next_action")
|
|
1963
|
+
if status in {"blocked", "failed"} and not summary.get("blocked_reason"):
|
|
1964
|
+
gaps.append("empty_blocked_reason")
|
|
1965
|
+
if summary.get("human_decision_required") and not decision_context.get("decisions"):
|
|
1966
|
+
gaps.append("missing_human_decision_packet")
|
|
1967
|
+
return gaps
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _root_cause(
|
|
1971
|
+
payload: JsonObject,
|
|
1972
|
+
summary: JsonObject,
|
|
1973
|
+
*,
|
|
1974
|
+
decision_context: JsonObject,
|
|
1975
|
+
blocker_context: JsonObject,
|
|
1976
|
+
environment_blocker_context: JsonObject,
|
|
1977
|
+
missing_inputs: list[str],
|
|
1978
|
+
contract_gaps: list[str],
|
|
1979
|
+
) -> tuple[str, str]:
|
|
1980
|
+
blocked_reason = _code_slug(summary.get("blocked_reason") or "")
|
|
1981
|
+
status = str(summary.get("status") or "")
|
|
1982
|
+
if blocked_reason == "contract_gap_missing_next_action":
|
|
1983
|
+
return CONTRACT_GAP_MISSING_NEXT_ACTION, "Workflow bloqueado sem próximo passo"
|
|
1984
|
+
if environment_blocker_context:
|
|
1985
|
+
return ENVIRONMENT_BLOCKER_CODE, "Bloqueio de ambiente Windows/path/venv"
|
|
1986
|
+
if blocked_reason == "batch_state_mismatch":
|
|
1987
|
+
return "batch_state_mismatch", "Artefatos incompatíveis entre fases do processamento de chats"
|
|
1988
|
+
if blocked_reason == "coverage_invalid":
|
|
1989
|
+
return "coverage_invalid", "Coverage inválida no processamento de chats"
|
|
1990
|
+
if blocked_reason == "provenance_gap":
|
|
1991
|
+
return "provenance_gap", "Proveniência multi-fonte incompleta no processamento de chats"
|
|
1992
|
+
if "coverage_path" in missing_inputs or blocked_reason == "coverage_path_missing":
|
|
1993
|
+
return "coverage_path_missing", "Coverage ausente no processamento de chats"
|
|
1994
|
+
if blocked_reason == "note_plan_invalid":
|
|
1995
|
+
return "note_plan_invalid", "Plano de triagem inválido no processamento de chats"
|
|
1996
|
+
if blocked_reason == "manifest_invalid":
|
|
1997
|
+
return "manifest_invalid", "Manifest inválido no processamento de chats"
|
|
1998
|
+
if blocked_reason == "dry_run_receipt_invalid":
|
|
1999
|
+
return "dry_run_receipt_invalid", "Recibo de dry-run ausente ou incompatível"
|
|
2000
|
+
if blocked_reason == "taxonomy_resolution_required":
|
|
2001
|
+
return "taxonomy_resolution_required", "Taxonomia precisa de resolução antes de avançar"
|
|
2002
|
+
blocker_codes = set(blocker_context.get("codes") or [])
|
|
2003
|
+
if "canonical_merge_required" in blocker_codes:
|
|
2004
|
+
return "canonical_merge_required", "Merge canônico necessário antes de publicar"
|
|
2005
|
+
if "human_decision_required_ambiguous_canonical_target" in blocker_codes:
|
|
2006
|
+
return (
|
|
2007
|
+
"human_decision_required.ambiguous_canonical_target",
|
|
2008
|
+
"Decisão humana: escolher alvo canônico",
|
|
2009
|
+
)
|
|
2010
|
+
if blocked_reason == "human_decision_required" or decision_context.get("decisions"):
|
|
2011
|
+
kind = _first_or_default(decision_context.get("types"), "manual_review")
|
|
2012
|
+
code = f"human_decision_required.{_code_slug(kind)}"
|
|
2013
|
+
return code, _human_decision_label(kind)
|
|
2014
|
+
model_validation = _json_object_field(payload, "model_validation")
|
|
2015
|
+
if _json_value(model_validation, "ok") is False:
|
|
2016
|
+
return "anki_model_validation_failed", "Modelo Anki bloqueou criação de cards"
|
|
2017
|
+
counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
|
|
2018
|
+
if blocked_reason == "graph_blockers" or int(counts.get("blocker_count", 0) or 0) or blocker_context.get("codes"):
|
|
2019
|
+
code = _first_or_default(blocker_context.get("codes"), "unknown")
|
|
2020
|
+
if code and code != "unknown":
|
|
2021
|
+
return f"graph_blockers.{_code_slug(code)}", _graph_blocker_label(code)
|
|
2022
|
+
return "graph_blockers", "Blockers de grafo recorrentes"
|
|
2023
|
+
if "missing_next_action" in contract_gaps:
|
|
2024
|
+
return "contract_gap.missing_next_action", "Workflow bloqueado sem próximo passo"
|
|
2025
|
+
if blocked_reason:
|
|
2026
|
+
return f"blocked.{blocked_reason}", f"Bloqueio recorrente: {blocked_reason}"
|
|
2027
|
+
if status in {"blocked", "failed", "error"}:
|
|
2028
|
+
return f"status.{_code_slug(status)}", f"Run terminou como {status}"
|
|
2029
|
+
return "no_issue_detected", "Nenhum padrão de falha detectado"
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _recovery_command(
|
|
2033
|
+
payload: JsonObject,
|
|
2034
|
+
summary: JsonObject,
|
|
2035
|
+
root_cause_code: str,
|
|
2036
|
+
decision_context: JsonObject,
|
|
2037
|
+
blocker_context: JsonObject,
|
|
2038
|
+
) -> str:
|
|
2039
|
+
for decision in _json_list_field(decision_context, "decisions"):
|
|
2040
|
+
decision_view = _json_object_view(decision)
|
|
2041
|
+
value = _json_text(decision_view, "continue_after_choice") or _json_text(decision_view, "next_action")
|
|
2042
|
+
if value:
|
|
2043
|
+
return redact_snippet(value, max_chars=300)
|
|
2044
|
+
for route in _json_list_field(blocker_context, "routes"):
|
|
2045
|
+
route_view = _json_object_view(route)
|
|
2046
|
+
value = _json_text(route_view, "next_action")
|
|
2047
|
+
if value:
|
|
2048
|
+
return redact_snippet(value, max_chars=300)
|
|
2049
|
+
if root_cause_code == ENVIRONMENT_BLOCKER_CODE:
|
|
2050
|
+
context = _environment_blocker_context(payload, summary)
|
|
2051
|
+
if context.get("next_action"):
|
|
2052
|
+
return redact_snippet(context["next_action"], max_chars=300)
|
|
2053
|
+
value = summary.get("next_action") or payload.get("next_command") or ""
|
|
2054
|
+
if value:
|
|
2055
|
+
return redact_snippet(value, max_chars=300)
|
|
2056
|
+
if root_cause_code == "coverage_path_missing":
|
|
2057
|
+
return "Gerar coverage_path a partir do note_plan, repetir stage-note --coverage <coverage.json> e depois publish-batch --dry-run."
|
|
2058
|
+
if root_cause_code == "note_plan_invalid":
|
|
2059
|
+
return "Corrigir o note_plan conforme triage-note-plan.v2 e repetir somente triage --note-plan antes de architect/stage/publish."
|
|
2060
|
+
if root_cause_code == "coverage_invalid":
|
|
2061
|
+
return "Corrigir ou regenerar coverage a partir do note_plan e repetir stage-note --coverage antes do publish-batch --dry-run."
|
|
2062
|
+
if root_cause_code == "provenance_gap":
|
|
2063
|
+
return "Completar coverage.sources e as Fontes Consolidadas da nota canônica antes de repetir stage-note/publish-batch --dry-run."
|
|
2064
|
+
if root_cause_code == "batch_state_mismatch":
|
|
2065
|
+
return "Regenerar coverage, manifest e dry-run a partir do note_plan atual antes de avançar."
|
|
2066
|
+
if root_cause_code == "manifest_invalid":
|
|
2067
|
+
return "Regenerar manifest via stage-note --coverage e repetir publish-batch --dry-run."
|
|
2068
|
+
if root_cause_code == "dry_run_receipt_invalid":
|
|
2069
|
+
return "Rodar publish-batch --dry-run com o mesmo manifest/opções antes do publish real."
|
|
2070
|
+
if root_cause_code == "taxonomy_resolution_required":
|
|
2071
|
+
return "Resolver taxonomia com categoria existente ou decisão explícita antes de repetir a fase."
|
|
2072
|
+
if root_cause_code == "canonical_merge_required":
|
|
2073
|
+
return "Consolidar informação nova no alvo canônico, preservar referências múltiplas e validar antes de aplicar."
|
|
2074
|
+
if root_cause_code == "human_decision_required.ambiguous_canonical_target":
|
|
2075
|
+
return "Escolher explicitamente o alvo canônico, ajustar o note_plan e reexecutar plan-subagents --phase architect."
|
|
2076
|
+
if root_cause_code.startswith("graph_blockers"):
|
|
2077
|
+
return "Rodar /mednotes:fix-wiki --dry-run para obter a rota segura antes de aplicar o linker."
|
|
2078
|
+
return ""
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
def _human_decision_label(kind: str) -> str:
|
|
2082
|
+
labels = {
|
|
2083
|
+
"note_merge_required": "Decisão humana: fundir ou separar notas com identidade semântica confirmada",
|
|
2084
|
+
"taxonomy_review_required": "Decisão humana: revisar taxonomia",
|
|
2085
|
+
"io_retry": "Decisão humana: liberar arquivo e tentar novamente",
|
|
2086
|
+
"manual_review": "Decisão humana pendente",
|
|
2087
|
+
}
|
|
2088
|
+
return labels.get(_code_slug(kind), f"Decisão humana: {_code_slug(kind)}")
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def _graph_blocker_label(code: str) -> str:
|
|
2092
|
+
labels = {
|
|
2093
|
+
"duplicate_stem": "Blocker de grafo: notas duplicadas",
|
|
2094
|
+
"dangling_link": "Blocker de grafo: link sem alvo",
|
|
2095
|
+
"self_link": "Blocker de grafo: auto-link",
|
|
2096
|
+
"ambiguous_link": "Blocker de grafo: link ambíguo",
|
|
2097
|
+
"catalog_repair": "Blocker de grafo: catálogo precisa de reparo",
|
|
2098
|
+
"unknown_graph_blocker": "Blocker de grafo sem reparo conhecido",
|
|
2099
|
+
}
|
|
2100
|
+
return labels.get(_code_slug(code), f"Blocker de grafo: {_code_slug(code)}")
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def _compact_diagnostic_value(value: Any, *, key: str = "", depth: int = 0) -> Any:
|
|
2104
|
+
if depth > 4:
|
|
2105
|
+
return "[max-depth]"
|
|
2106
|
+
if isinstance(value, dict):
|
|
2107
|
+
out: dict[str, Any] = {}
|
|
2108
|
+
for child_key, child_value in list(value.items())[:16]:
|
|
2109
|
+
lower = str(child_key).lower()
|
|
2110
|
+
if lower in SECRET_KEYS:
|
|
2111
|
+
out[str(child_key)] = "[redacted]"
|
|
2112
|
+
else:
|
|
2113
|
+
out[str(child_key)] = _compact_diagnostic_value(child_value, key=str(child_key), depth=depth + 1)
|
|
2114
|
+
return out
|
|
2115
|
+
if isinstance(value, list):
|
|
2116
|
+
return [_compact_diagnostic_value(item, key=key, depth=depth + 1) for item in value[:MAX_DIAGNOSTIC_ITEMS]]
|
|
2117
|
+
if isinstance(value, str):
|
|
2118
|
+
if key.lower() in LONG_TEXT_KEYS:
|
|
2119
|
+
return redact_snippet(value, max_chars=160)
|
|
2120
|
+
if key.lower() in {"phase", "expected_phase", "actual_phase", "retry_scope"}:
|
|
2121
|
+
return _redact_operational_identifier(value, max_chars=120)
|
|
2122
|
+
if _looks_like_path_key(key) and _looks_like_path_value(value):
|
|
2123
|
+
return _path_label(value)
|
|
2124
|
+
return redact_snippet(value, max_chars=240)
|
|
2125
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
2126
|
+
return value
|
|
2127
|
+
return redact_snippet(value, max_chars=120)
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def _path_label(path: str) -> str:
|
|
2131
|
+
expanded_home = str(Path.home())
|
|
2132
|
+
if path.startswith(expanded_home):
|
|
2133
|
+
path = "~" + path[len(expanded_home):]
|
|
2134
|
+
p = Path(path)
|
|
2135
|
+
parts = p.parts
|
|
2136
|
+
if len(parts) >= 3:
|
|
2137
|
+
return "/".join(parts[-3:])
|
|
2138
|
+
return p.name or path
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def _code_slug(value: Any) -> str:
|
|
2142
|
+
text = str(value or "").strip().lower()
|
|
2143
|
+
text = re.sub(r"[^a-z0-9]+", "_", text)
|
|
2144
|
+
return text.strip("_")
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def _dedupe(items: Any) -> list[str]:
|
|
2148
|
+
out: list[str] = []
|
|
2149
|
+
seen: set[str] = set()
|
|
2150
|
+
for item in items:
|
|
2151
|
+
value = str(item or "").strip()
|
|
2152
|
+
if not value or value in seen:
|
|
2153
|
+
continue
|
|
2154
|
+
seen.add(value)
|
|
2155
|
+
out.append(value)
|
|
2156
|
+
return out
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
def _safe_int(value: Any) -> int:
|
|
2160
|
+
try:
|
|
2161
|
+
return int(value or 0)
|
|
2162
|
+
except (TypeError, ValueError):
|
|
2163
|
+
return 0
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
def _first_or_default(value: Any, default: str) -> str:
|
|
2167
|
+
if isinstance(value, list) and value:
|
|
2168
|
+
return str(value[0] or default)
|
|
2169
|
+
return default
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
def record_workflow_run(
|
|
2173
|
+
*,
|
|
2174
|
+
workflow: str,
|
|
2175
|
+
command: str | None = None,
|
|
2176
|
+
payload: object = None,
|
|
2177
|
+
exit_code: int = 0,
|
|
2178
|
+
started_at: float | None = None,
|
|
2179
|
+
duration_ms: int | None = None,
|
|
2180
|
+
snippets: list[object] | None = None,
|
|
2181
|
+
root: str | Path | None = None,
|
|
2182
|
+
source: str = "cli",
|
|
2183
|
+
extra: JsonObject | None = None,
|
|
2184
|
+
) -> JsonObject:
|
|
2185
|
+
started = started_at if started_at is not None else time.time()
|
|
2186
|
+
if duration_ms is None:
|
|
2187
|
+
duration_ms = max(0, int((time.time() - started) * 1000))
|
|
2188
|
+
effective_command = command or command_string()
|
|
2189
|
+
payload_dict = _json_object_view(payload)
|
|
2190
|
+
payload_dict = _inherit_agent_feedback_payload(
|
|
2191
|
+
payload_dict,
|
|
2192
|
+
workflow=workflow,
|
|
2193
|
+
root=root,
|
|
2194
|
+
source=source,
|
|
2195
|
+
started=started,
|
|
2196
|
+
command=effective_command,
|
|
2197
|
+
)
|
|
2198
|
+
if isinstance(payload_dict, dict):
|
|
2199
|
+
initial_summary = summarize_payload(payload_dict)
|
|
2200
|
+
initial_error_context = _normalized_error_context(payload_dict.get("error_context"))
|
|
2201
|
+
initial_environment_context = _environment_blocker_context(payload_dict, initial_summary)
|
|
2202
|
+
if _needs_next_action_hardening(payload_dict) and initial_environment_context.get("next_action"):
|
|
2203
|
+
payload_dict = {
|
|
2204
|
+
**payload_dict,
|
|
2205
|
+
"status": "blocked",
|
|
2206
|
+
"blocked_reason": ENVIRONMENT_BLOCKER_CODE,
|
|
2207
|
+
"next_action": _json_text(initial_environment_context, "next_action"),
|
|
2208
|
+
}
|
|
2209
|
+
if not initial_error_context:
|
|
2210
|
+
payload_dict["error_context"] = _environment_error_context(
|
|
2211
|
+
payload_dict,
|
|
2212
|
+
summarize_payload(payload_dict),
|
|
2213
|
+
initial_environment_context,
|
|
2214
|
+
)
|
|
2215
|
+
else:
|
|
2216
|
+
payload_dict = _harden_payload_missing_next_action(
|
|
2217
|
+
payload_dict,
|
|
2218
|
+
workflow=workflow,
|
|
2219
|
+
command=effective_command,
|
|
2220
|
+
)
|
|
2221
|
+
provisional_summary = summarize_payload(payload_dict)
|
|
2222
|
+
provisional_error_context = _normalized_error_context(payload_dict.get("error_context"))
|
|
2223
|
+
provisional_environment_context = _environment_blocker_context(payload_dict, provisional_summary)
|
|
2224
|
+
if provisional_environment_context and not provisional_error_context:
|
|
2225
|
+
provisional_error_context = _environment_error_context(
|
|
2226
|
+
payload_dict,
|
|
2227
|
+
provisional_summary,
|
|
2228
|
+
provisional_environment_context,
|
|
2229
|
+
)
|
|
2230
|
+
payload_dict = _with_derived_agent_events(
|
|
2231
|
+
payload_dict,
|
|
2232
|
+
provisional_summary,
|
|
2233
|
+
provisional_error_context,
|
|
2234
|
+
source=source,
|
|
2235
|
+
)
|
|
2236
|
+
payload_for_context = payload_dict if payload_dict else payload
|
|
2237
|
+
payload_summary = summarize_payload(payload_for_context)
|
|
2238
|
+
agent_events = _normalized_agent_events(payload_dict)
|
|
2239
|
+
error_context = _normalized_error_context(payload_dict.get("error_context"))
|
|
2240
|
+
diagnostic_context = build_diagnostic_context(payload_for_context, payload_summary)
|
|
2241
|
+
payload_diagnostic = payload_dict.get("diagnostic_context") if isinstance(payload_dict, dict) else {}
|
|
2242
|
+
if isinstance(payload_diagnostic, dict) and isinstance(payload_diagnostic.get("contract_gap"), dict):
|
|
2243
|
+
diagnostic_context["contract_gap"] = payload_diagnostic["contract_gap"]
|
|
2244
|
+
if isinstance(payload_diagnostic, dict) and isinstance(payload_diagnostic.get("inherited_feedback_context"), dict):
|
|
2245
|
+
diagnostic_context["inherited_feedback_context"] = payload_diagnostic["inherited_feedback_context"]
|
|
2246
|
+
public_report = _json_object_field(_json_object_view(payload_diagnostic), "public_report")
|
|
2247
|
+
if public_report:
|
|
2248
|
+
diagnostic_context = {
|
|
2249
|
+
**diagnostic_context,
|
|
2250
|
+
"public_report": _normalized_public_report(public_report),
|
|
2251
|
+
}
|
|
2252
|
+
if source == "agent":
|
|
2253
|
+
if _feedback_summary_command(effective_command) and isinstance(
|
|
2254
|
+
diagnostic_context.get("inherited_feedback_context"),
|
|
2255
|
+
dict,
|
|
2256
|
+
):
|
|
2257
|
+
diagnostic_context["prompt_hardening_context"] = _inherited_feedback_summary_context(
|
|
2258
|
+
payload_dict,
|
|
2259
|
+
workflow=workflow,
|
|
2260
|
+
command=effective_command,
|
|
2261
|
+
)
|
|
2262
|
+
else:
|
|
2263
|
+
diagnostic_context["prompt_hardening_context"] = _prompt_hardening_context(
|
|
2264
|
+
payload_dict,
|
|
2265
|
+
payload_summary,
|
|
2266
|
+
workflow=workflow,
|
|
2267
|
+
command=effective_command,
|
|
2268
|
+
)
|
|
2269
|
+
if not error_context and isinstance(diagnostic_context.get("error_context"), dict):
|
|
2270
|
+
error_context = diagnostic_context["error_context"]
|
|
2271
|
+
environment_context = _environment_context(root=root)
|
|
2272
|
+
integrity = environment_context.get("extension_integrity") if isinstance(environment_context, dict) else None
|
|
2273
|
+
if isinstance(integrity, dict) and integrity.get("drift_detected"):
|
|
2274
|
+
diagnostic_context.setdefault("environment_warnings", []).append("extension_integrity_drift")
|
|
2275
|
+
summary = integrity.get("summary") if isinstance(integrity.get("summary"), dict) else {}
|
|
2276
|
+
if int(summary.get("encoding_corruption_count", 0) or 0):
|
|
2277
|
+
diagnostic_context.setdefault("environment_warnings", []).append("extension.prompt_encoding_corruption")
|
|
2278
|
+
elif isinstance(integrity, dict) and integrity.get("skipped_reason") == "integrity_check_skipped_timeout":
|
|
2279
|
+
diagnostic_context.setdefault("environment_warnings", []).append("integrity_check_skipped_timeout")
|
|
2280
|
+
if source == "agent" and isinstance(integrity, dict):
|
|
2281
|
+
drift_event = _append_agent_integrity_drift_event(diagnostic_context, payload_summary, integrity)
|
|
2282
|
+
drift_code = _json_text(drift_event, "code") if isinstance(drift_event, dict) else ""
|
|
2283
|
+
if drift_event and not any(_json_text(_json_object_view(event), "code") == drift_code for event in agent_events):
|
|
2284
|
+
agent_events.append(drift_event)
|
|
2285
|
+
hook_since = datetime.fromtimestamp(max(0, started - 300), UTC).isoformat()
|
|
2286
|
+
hook_debug = _debug_from_hook_events(
|
|
2287
|
+
load_hook_events(since=hook_since, root=root),
|
|
2288
|
+
errors=load_hook_errors(since=hook_since, root=root),
|
|
2289
|
+
)
|
|
2290
|
+
generated_scripts = _merge_generated_scripts(
|
|
2291
|
+
_normalized_generated_scripts(payload_dict.get("generated_scripts", []), source="payload"),
|
|
2292
|
+
hook_debug["generated_scripts"],
|
|
2293
|
+
)
|
|
2294
|
+
command_events = _merge_command_events(
|
|
2295
|
+
_normalized_command_events(payload_dict.get("command_events", []), source="payload"),
|
|
2296
|
+
hook_debug["command_events"],
|
|
2297
|
+
)
|
|
2298
|
+
hook_errors = _merge_hook_errors(
|
|
2299
|
+
_normalized_hook_errors(payload_dict.get("hook_errors", []), source="payload"),
|
|
2300
|
+
hook_debug["hook_errors"],
|
|
2301
|
+
)
|
|
2302
|
+
hook_failure_event = _telemetry_hook_failed_agent_event(
|
|
2303
|
+
hook_errors,
|
|
2304
|
+
workflow=workflow,
|
|
2305
|
+
phase=str(payload_summary.get("phase") or payload_dict.get("phase") or "telemetry_capture"),
|
|
2306
|
+
)
|
|
2307
|
+
hook_failure_code = _json_text(hook_failure_event, "code") if hook_failure_event else ""
|
|
2308
|
+
if hook_failure_event and not any(_json_text(_json_object_view(event), "code") == hook_failure_code for event in agent_events):
|
|
2309
|
+
agent_events.append(hook_failure_event)
|
|
2310
|
+
status = str(payload_summary.get("status") or ("completed" if exit_code == 0 else "failed"))
|
|
2311
|
+
if exit_code != 0 and status == "completed":
|
|
2312
|
+
status = "failed"
|
|
2313
|
+
workflow_exit_code = (
|
|
2314
|
+
payload_dict.get("workflow_exit_code")
|
|
2315
|
+
if isinstance(payload_dict.get("workflow_exit_code"), int)
|
|
2316
|
+
else int(exit_code)
|
|
2317
|
+
if _json_text(payload_dict, "schema").endswith("-fsm-result.v1") and int(exit_code) != 0
|
|
2318
|
+
else None
|
|
2319
|
+
)
|
|
2320
|
+
record = {
|
|
2321
|
+
"schema": RUN_RECORD_SCHEMA,
|
|
2322
|
+
"run_id": _run_id(workflow),
|
|
2323
|
+
"recorded_at": now_iso(),
|
|
2324
|
+
"workflow": workflow,
|
|
2325
|
+
"source": source,
|
|
2326
|
+
"command": redact_snippet(effective_command, max_chars=700),
|
|
2327
|
+
"exit_code": int(exit_code),
|
|
2328
|
+
"workflow_exit_code": workflow_exit_code,
|
|
2329
|
+
"duration_ms": int(duration_ms),
|
|
2330
|
+
"status": status,
|
|
2331
|
+
"phase": payload_summary.get("phase") or "",
|
|
2332
|
+
"blocked_reason": payload_summary.get("blocked_reason") or "",
|
|
2333
|
+
"next_action": payload_summary.get("next_action") or "",
|
|
2334
|
+
"next_command": payload_dict.get("next_command") if isinstance(payload_dict, dict) else None,
|
|
2335
|
+
"resume_command": payload_dict.get("resume_command") if isinstance(payload_dict, dict) else None,
|
|
2336
|
+
"rollback_command": payload_dict.get("rollback_command") if isinstance(payload_dict, dict) else None,
|
|
2337
|
+
"execution_gate": payload_dict.get("execution_gate") if isinstance(payload_dict, dict) else None,
|
|
2338
|
+
"required_inputs": payload_summary.get("required_inputs") or [],
|
|
2339
|
+
"human_decision_required": bool(payload_summary.get("human_decision_required")),
|
|
2340
|
+
"process_chats_terminal_state": payload_summary.get("process_chats_terminal_state") or "",
|
|
2341
|
+
"process_chats_backlog_state": payload_summary.get("process_chats_backlog_state") or "",
|
|
2342
|
+
"dry_run": payload_summary.get("dry_run"),
|
|
2343
|
+
"apply": payload_summary.get("apply"),
|
|
2344
|
+
"payload_summary": payload_summary,
|
|
2345
|
+
"diagnostic_context": diagnostic_context,
|
|
2346
|
+
"environment_context": environment_context,
|
|
2347
|
+
"diagnostic_snippets": [
|
|
2348
|
+
redact_snippet(item) for item in (snippets or []) if str(item).strip()
|
|
2349
|
+
][:10],
|
|
2350
|
+
"extra": extra or {},
|
|
2351
|
+
}
|
|
2352
|
+
if _run_record_root_truth_is_observational(
|
|
2353
|
+
payload_dict,
|
|
2354
|
+
command=effective_command,
|
|
2355
|
+
):
|
|
2356
|
+
_move_legacy_root_truth_to_observed(record, source_payload=payload_dict)
|
|
2357
|
+
if agent_events:
|
|
2358
|
+
record["agent_events"] = agent_events[:MAX_AGENT_EVENTS]
|
|
2359
|
+
if error_context:
|
|
2360
|
+
record["error_context"] = error_context
|
|
2361
|
+
if isinstance(payload_dict.get("human_decision_packet"), dict):
|
|
2362
|
+
record["human_decision_packet"] = payload_dict["human_decision_packet"]
|
|
2363
|
+
if isinstance(payload_dict.get("human_decision_packets"), list):
|
|
2364
|
+
record["human_decision_packets"] = [
|
|
2365
|
+
packet for packet in payload_dict["human_decision_packets"][:MAX_DIAGNOSTIC_ITEMS]
|
|
2366
|
+
if isinstance(packet, dict)
|
|
2367
|
+
]
|
|
2368
|
+
materialized_directive = _materialized_agent_directive(payload_dict)
|
|
2369
|
+
if materialized_directive:
|
|
2370
|
+
record = {**record, "agent_directive": materialized_directive}
|
|
2371
|
+
if generated_scripts:
|
|
2372
|
+
record["generated_scripts"] = generated_scripts[:MAX_GENERATED_SCRIPTS]
|
|
2373
|
+
if command_events:
|
|
2374
|
+
record["command_events"] = command_events[:MAX_COMMAND_EVENTS]
|
|
2375
|
+
if hook_debug["hook_event_ids"]:
|
|
2376
|
+
record["hook_event_ids"] = hook_debug["hook_event_ids"][:MAX_HOOK_EVENTS]
|
|
2377
|
+
if hook_errors:
|
|
2378
|
+
record["hook_errors"] = hook_errors[:MAX_HOOK_ERRORS]
|
|
2379
|
+
if hook_debug["hook_error_ids"]:
|
|
2380
|
+
record["hook_error_ids"] = hook_debug["hook_error_ids"][:MAX_HOOK_ERRORS]
|
|
2381
|
+
_apply_generated_script_risk_signals(record)
|
|
2382
|
+
_apply_process_chats_retry_loop_guard(record, root=root)
|
|
2383
|
+
attach_telemetry_evidence(record)
|
|
2384
|
+
runs_dir = feedback_root(root) / "runs"
|
|
2385
|
+
runs_dir.mkdir(parents=True, exist_ok=True)
|
|
2386
|
+
path = runs_dir / f"{record['run_id']}.json"
|
|
2387
|
+
record["record_path"] = str(path)
|
|
2388
|
+
_atomic_write_json(path, record)
|
|
2389
|
+
try:
|
|
2390
|
+
from mednotes.platform.feedback.telemetry import safe_auto_send_record
|
|
2391
|
+
|
|
2392
|
+
safe_auto_send_record(record, raw_payload=payload, root=root)
|
|
2393
|
+
except Exception:
|
|
2394
|
+
pass
|
|
2395
|
+
try:
|
|
2396
|
+
prune_local_feedback(root=root)
|
|
2397
|
+
except Exception:
|
|
2398
|
+
pass
|
|
2399
|
+
return record
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def _run_record_root_truth_is_observational(payload: JsonObject, *, command: str) -> bool:
|
|
2403
|
+
"""Return true when legacy workflow fields must be demoted from record root.
|
|
2404
|
+
|
|
2405
|
+
FSM-first payloads and agent summary records are observations about a run.
|
|
2406
|
+
Keeping `status`/`phase`/`next_action` at root makes them look executable to
|
|
2407
|
+
hooks and reports, so those values move under `observed`/`payload_summary`.
|
|
2408
|
+
"""
|
|
2409
|
+
|
|
2410
|
+
return _json_text(payload, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS or _feedback_summary_command(command)
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
def _move_legacy_root_truth_to_observed(record: dict[str, Any], *, source_payload: JsonObject | None = None) -> None:
|
|
2414
|
+
"""Demote record roots and preserve stale payload roots as legacy evidence."""
|
|
2415
|
+
|
|
2416
|
+
observed: dict[str, Any] = {}
|
|
2417
|
+
for key in ("status", "phase", "blocked_reason", "next_action", "workflow_exit_code"):
|
|
2418
|
+
value = record.pop(key, None)
|
|
2419
|
+
if value not in (None, "", [], {}):
|
|
2420
|
+
observed[key] = value
|
|
2421
|
+
legacy_root_fields: dict[str, Any] = {}
|
|
2422
|
+
source = source_payload or {}
|
|
2423
|
+
if _json_text(source, "schema") in FSM_FIRST_RUN_RECORD_SCHEMAS:
|
|
2424
|
+
for key in ("status", "phase", "blocked_reason", "next_action", "workflow_exit_code"):
|
|
2425
|
+
value = source[key] if key in source else None
|
|
2426
|
+
if value not in (None, "", [], {}):
|
|
2427
|
+
legacy_root_fields[key] = value
|
|
2428
|
+
if legacy_root_fields:
|
|
2429
|
+
observed["legacy_root_fields"] = legacy_root_fields
|
|
2430
|
+
if observed:
|
|
2431
|
+
record["observed"] = observed
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _append_agent_integrity_drift_event(
|
|
2435
|
+
diagnostic_context: dict[str, Any],
|
|
2436
|
+
payload_summary: dict[str, Any],
|
|
2437
|
+
integrity: dict[str, Any],
|
|
2438
|
+
) -> dict[str, Any] | None:
|
|
2439
|
+
drift_paths = _agent_relevant_integrity_paths(integrity)
|
|
2440
|
+
if not drift_paths:
|
|
2441
|
+
return None
|
|
2442
|
+
event = {
|
|
2443
|
+
"type": "script_or_prompt_drift",
|
|
2444
|
+
"code": "agent.script_or_prompt_drift",
|
|
2445
|
+
"phase": str(payload_summary.get("phase") or ""),
|
|
2446
|
+
"severity": "high",
|
|
2447
|
+
"summary": f"Instalacao com drift em {len(drift_paths)} arquivo(s) de comando, prompt, runbook ou script.",
|
|
2448
|
+
"action": "Rodar /mednotes:status para revisar integrity drift; reinstalar/publicar update se a mudanca nao foi intencional.",
|
|
2449
|
+
"target_kind": str(drift_paths[0].get("kind") or ""),
|
|
2450
|
+
"result": "detected",
|
|
2451
|
+
"path": str(drift_paths[0].get("path") or ""),
|
|
2452
|
+
}
|
|
2453
|
+
context = diagnostic_context.setdefault(
|
|
2454
|
+
"agent_behavior_context",
|
|
2455
|
+
{"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
|
|
2456
|
+
)
|
|
2457
|
+
context["event_count"] = int(context.get("event_count") or 0) + 1
|
|
2458
|
+
_increment_dict(context.setdefault("types", {}), event["type"])
|
|
2459
|
+
_increment_dict(context.setdefault("severities", {}), event["severity"])
|
|
2460
|
+
context["highest_severity"] = _higher_severity(str(context.get("highest_severity") or ""), event["severity"])
|
|
2461
|
+
codes = context.setdefault("codes", [])
|
|
2462
|
+
if event["code"] not in codes:
|
|
2463
|
+
codes.append(event["code"])
|
|
2464
|
+
samples = context.setdefault("samples", [])
|
|
2465
|
+
if len(samples) < MAX_AGENT_EVENT_SAMPLES:
|
|
2466
|
+
samples.append(event)
|
|
2467
|
+
signals = payload_summary.setdefault("signals", [])
|
|
2468
|
+
if "agent.script_or_prompt_drift" not in signals:
|
|
2469
|
+
signals.append("agent.script_or_prompt_drift")
|
|
2470
|
+
return event
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
def _agent_relevant_integrity_paths(integrity: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2474
|
+
if not integrity.get("drift_detected"):
|
|
2475
|
+
return []
|
|
2476
|
+
items: list[dict[str, Any]] = []
|
|
2477
|
+
for key in ("modified_files", "missing_files", "unexpected_files"):
|
|
2478
|
+
for item in integrity.get(key) or []:
|
|
2479
|
+
if not isinstance(item, dict):
|
|
2480
|
+
continue
|
|
2481
|
+
path = str(item.get("path") or "")
|
|
2482
|
+
kind = str(item.get("kind") or "")
|
|
2483
|
+
if _is_agent_relevant_drift(path, kind):
|
|
2484
|
+
items.append({"path": path, "kind": kind or "unknown", "change": key.removesuffix("_files")})
|
|
2485
|
+
return items
|
|
2486
|
+
|
|
2487
|
+
|
|
2488
|
+
def _is_agent_relevant_drift(path: str, kind: str) -> bool:
|
|
2489
|
+
if path == "GEMINI.md":
|
|
2490
|
+
return True
|
|
2491
|
+
if kind in AGENT_RELEVANT_DRIFT_KINDS:
|
|
2492
|
+
return True
|
|
2493
|
+
return path.startswith(AGENT_RELEVANT_DRIFT_PREFIXES)
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _increment_dict(counts: JsonObject, key: str) -> None:
|
|
2497
|
+
counts[key] = int(counts.get(key) or 0) + 1
|
|
2498
|
+
|
|
2499
|
+
|
|
2500
|
+
def _higher_severity(left: str, right: str) -> str:
|
|
2501
|
+
if not left:
|
|
2502
|
+
return right
|
|
2503
|
+
return right if _severity_rank(right) > _severity_rank(left) else left
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
def safe_record_workflow_run(
|
|
2507
|
+
*,
|
|
2508
|
+
workflow: str,
|
|
2509
|
+
command: str | None = None,
|
|
2510
|
+
payload: object = None,
|
|
2511
|
+
exit_code: int = 0,
|
|
2512
|
+
started_at: float | None = None,
|
|
2513
|
+
duration_ms: int | None = None,
|
|
2514
|
+
snippets: list[object] | None = None,
|
|
2515
|
+
root: str | Path | None = None,
|
|
2516
|
+
source: str = "cli",
|
|
2517
|
+
extra: JsonObject | None = None,
|
|
2518
|
+
) -> JsonObject | None:
|
|
2519
|
+
"""Fail-open wrapper around the typed feedback recorder.
|
|
2520
|
+
|
|
2521
|
+
Feedback persistence must never alter public workflow exit behavior, but the
|
|
2522
|
+
boundary still keeps the same typed contract as `record_workflow_run`; a
|
|
2523
|
+
catch-all `**kwargs` here would reintroduce an untyped operational API.
|
|
2524
|
+
"""
|
|
2525
|
+
|
|
2526
|
+
try:
|
|
2527
|
+
return record_workflow_run(
|
|
2528
|
+
workflow=workflow,
|
|
2529
|
+
command=command,
|
|
2530
|
+
payload=payload,
|
|
2531
|
+
exit_code=exit_code,
|
|
2532
|
+
started_at=started_at,
|
|
2533
|
+
duration_ms=duration_ms,
|
|
2534
|
+
snippets=snippets,
|
|
2535
|
+
root=root,
|
|
2536
|
+
source=source,
|
|
2537
|
+
extra=extra,
|
|
2538
|
+
)
|
|
2539
|
+
except Exception:
|
|
2540
|
+
return None
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def _apply_process_chats_retry_loop_guard(record: dict[str, Any], *, root: str | Path | None = None) -> None:
|
|
2544
|
+
if str(record.get("workflow") or "") != "/mednotes:process-chats":
|
|
2545
|
+
return
|
|
2546
|
+
if str(record.get("status") or "") not in {"blocked", "failed", "error"}:
|
|
2547
|
+
return
|
|
2548
|
+
grouping = _record_grouping_dimensions(record, "agent.retry_loop")
|
|
2549
|
+
if not grouping.get("phase") or not grouping.get("root_cause"):
|
|
2550
|
+
return
|
|
2551
|
+
if not grouping.get("input_hash"):
|
|
2552
|
+
return
|
|
2553
|
+
retry_governance = record.get("diagnostic_context", {}).get("retry_governance", {})
|
|
2554
|
+
if not isinstance(retry_governance, dict):
|
|
2555
|
+
retry_governance = {}
|
|
2556
|
+
max_attempts = max(1, _safe_int(retry_governance.get("max_attempts") or 1))
|
|
2557
|
+
previous = [
|
|
2558
|
+
item
|
|
2559
|
+
for item in load_records(since="24h", root=root)
|
|
2560
|
+
if _same_retry_loop_signature(grouping, _record_grouping_dimensions(item, "agent.retry_loop"))
|
|
2561
|
+
]
|
|
2562
|
+
if len(previous) < max_attempts:
|
|
2563
|
+
return
|
|
2564
|
+
|
|
2565
|
+
previous_next_action = str(record.get("next_action") or "")
|
|
2566
|
+
phase = str(grouping.get("phase") or "unknown")
|
|
2567
|
+
root_cause = str(grouping.get("root_cause") or "unknown")
|
|
2568
|
+
attempt_count = len(previous) + 1
|
|
2569
|
+
next_action = (
|
|
2570
|
+
f"Parar retries automáticos: o mesmo bloqueio em {phase} ({root_cause}) já ocorreu "
|
|
2571
|
+
f"{attempt_count} vez(es) sem mudança relevante. Preserve os artefatos atuais, revise o "
|
|
2572
|
+
"error_context e só repita depois de alterar o input indicado ou pedir decisão humana."
|
|
2573
|
+
)
|
|
2574
|
+
if previous_next_action:
|
|
2575
|
+
next_action += f" Última rota esperada: {previous_next_action}"
|
|
2576
|
+
|
|
2577
|
+
record["next_action"] = next_action
|
|
2578
|
+
summary: dict[str, Any] = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
|
|
2579
|
+
summary["next_action"] = next_action
|
|
2580
|
+
signals = summary.setdefault("signals", [])
|
|
2581
|
+
if "agent.retry_loop" not in signals:
|
|
2582
|
+
signals.append("agent.retry_loop")
|
|
2583
|
+
|
|
2584
|
+
error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
|
|
2585
|
+
target_kind = str(error_context.get("affected_artifact") or "workflow")
|
|
2586
|
+
if error_context:
|
|
2587
|
+
error_context["next_action"] = next_action
|
|
2588
|
+
|
|
2589
|
+
diagnostic: dict[str, Any] = (
|
|
2590
|
+
record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
2591
|
+
)
|
|
2592
|
+
diagnostic["recovery_command"] = next_action
|
|
2593
|
+
agent_context = diagnostic.setdefault(
|
|
2594
|
+
"agent_behavior_context",
|
|
2595
|
+
{"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
|
|
2596
|
+
)
|
|
2597
|
+
event = {
|
|
2598
|
+
"type": "retry_loop",
|
|
2599
|
+
"code": "agent.retry_loop",
|
|
2600
|
+
"phase": phase,
|
|
2601
|
+
"severity": "high",
|
|
2602
|
+
"summary": f"Mesmo bloqueio repetido {attempt_count} vez(es) sem mudança relevante.",
|
|
2603
|
+
"action": next_action,
|
|
2604
|
+
"target_kind": _code_slug(target_kind),
|
|
2605
|
+
"result": "blocked",
|
|
2606
|
+
"blocked_reason": _code_slug(record.get("blocked_reason") or root_cause),
|
|
2607
|
+
"next_action_expected": previous_next_action,
|
|
2608
|
+
}
|
|
2609
|
+
_append_agent_context_event(agent_context, event)
|
|
2610
|
+
events = record.setdefault("agent_events", [])
|
|
2611
|
+
if isinstance(events, list) and not any(isinstance(item, dict) and item.get("code") == "agent.retry_loop" for item in events):
|
|
2612
|
+
events.append(event)
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def _same_retry_loop_signature(current: dict[str, str], previous: dict[str, str]) -> bool:
|
|
2616
|
+
for key in ("phase", "root_cause"):
|
|
2617
|
+
if str(current.get(key) or "") != str(previous.get(key) or ""):
|
|
2618
|
+
return False
|
|
2619
|
+
compared = False
|
|
2620
|
+
for key in ("target_canonical", "input_hash", "error_hash"):
|
|
2621
|
+
current_value = str(current.get(key) or "")
|
|
2622
|
+
previous_value = str(previous.get(key) or "")
|
|
2623
|
+
if not current_value:
|
|
2624
|
+
continue
|
|
2625
|
+
compared = True
|
|
2626
|
+
if current_value != previous_value:
|
|
2627
|
+
return False
|
|
2628
|
+
return compared
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
def _append_agent_context_event(context: dict[str, Any], event: dict[str, Any]) -> None:
|
|
2632
|
+
context["event_count"] = int(context.get("event_count") or 0) + 1
|
|
2633
|
+
_increment_dict(context.setdefault("types", {}), str(event.get("type") or "unknown"))
|
|
2634
|
+
severity = str(event.get("severity") or "low")
|
|
2635
|
+
_increment_dict(context.setdefault("severities", {}), severity)
|
|
2636
|
+
context["highest_severity"] = _higher_severity(str(context.get("highest_severity") or ""), severity)
|
|
2637
|
+
codes = context.setdefault("codes", [])
|
|
2638
|
+
code = str(event.get("code") or "")
|
|
2639
|
+
if code and code not in codes:
|
|
2640
|
+
codes.append(code)
|
|
2641
|
+
samples = context.setdefault("samples", [])
|
|
2642
|
+
if len(samples) < MAX_AGENT_EVENT_SAMPLES:
|
|
2643
|
+
samples.append(event)
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
def attach_telemetry_evidence(record: dict[str, Any], *, send_path: str = "workflow_record") -> dict[str, Any]:
|
|
2647
|
+
record["telemetry_evidence"] = build_telemetry_evidence(record, send_path=send_path)
|
|
2648
|
+
return record
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
def build_telemetry_evidence(record: dict[str, Any], *, send_path: str = "workflow_record") -> dict[str, Any]:
|
|
2652
|
+
extension_diffs = _record_extension_diffs(record)
|
|
2653
|
+
generated_scripts = record.get("generated_scripts") if isinstance(record.get("generated_scripts"), list) else []
|
|
2654
|
+
command_events = record.get("command_events") if isinstance(record.get("command_events"), list) else []
|
|
2655
|
+
hook_errors = record.get("hook_errors") if isinstance(record.get("hook_errors"), list) else []
|
|
2656
|
+
hook_event_ids = record.get("hook_event_ids") if isinstance(record.get("hook_event_ids"), list) else []
|
|
2657
|
+
hook_error_ids = record.get("hook_error_ids") if isinstance(record.get("hook_error_ids"), list) else []
|
|
2658
|
+
counts = {
|
|
2659
|
+
"extension_diff_count": len(extension_diffs),
|
|
2660
|
+
"generated_script_count": len(generated_scripts),
|
|
2661
|
+
"command_event_count": len(command_events),
|
|
2662
|
+
"hook_error_count": len(hook_errors),
|
|
2663
|
+
"hook_event_id_count": len(hook_event_ids),
|
|
2664
|
+
"hook_error_id_count": len(hook_error_ids),
|
|
2665
|
+
}
|
|
2666
|
+
sources = _evidence_sources(record, extension_diffs, generated_scripts, command_events, hook_errors)
|
|
2667
|
+
quality_flags = _evidence_quality_flags(record, extension_diffs, generated_scripts, command_events, hook_errors)
|
|
2668
|
+
timeline = _evidence_timeline(record, extension_diffs, generated_scripts, command_events, hook_errors)
|
|
2669
|
+
seed = {
|
|
2670
|
+
"run_id": record.get("run_id"),
|
|
2671
|
+
"recorded_at": record.get("recorded_at"),
|
|
2672
|
+
"sources": sources,
|
|
2673
|
+
"counts": counts,
|
|
2674
|
+
"timeline": timeline[:8],
|
|
2675
|
+
}
|
|
2676
|
+
return {
|
|
2677
|
+
"schema": TELEMETRY_EVIDENCE_SCHEMA,
|
|
2678
|
+
"bundle_id": f"telem-{hashlib.sha256(json.dumps(seed, sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest()[:16]}",
|
|
2679
|
+
"sources": sources,
|
|
2680
|
+
"artifact_counts": counts,
|
|
2681
|
+
"timeline": timeline[:12],
|
|
2682
|
+
"quality_flags": quality_flags,
|
|
2683
|
+
"redaction_summary": {
|
|
2684
|
+
"applied": True,
|
|
2685
|
+
"blocked_fields": ["content", "markdown", "html", "raw_chat", "note_text", ".env", "tokens", "keys"],
|
|
2686
|
+
"operational_debug_fields": ["extension_diffs", "generated_scripts", "command_events", "hook_errors"],
|
|
2687
|
+
},
|
|
2688
|
+
"truncation_summary": _evidence_truncation_summary(extension_diffs, generated_scripts, command_events, hook_errors),
|
|
2689
|
+
"send_path": send_path,
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
|
|
2693
|
+
def _record_extension_diffs(record: dict[str, Any]) -> list[dict[str, Any]]:
|
|
2694
|
+
direct = record.get("extension_diffs") if isinstance(record.get("extension_diffs"), list) else []
|
|
2695
|
+
if direct:
|
|
2696
|
+
return [item for item in direct if isinstance(item, dict)]
|
|
2697
|
+
environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
|
|
2698
|
+
integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
|
|
2699
|
+
diffs = integrity.get("extension_diffs") if isinstance(integrity.get("extension_diffs"), list) else []
|
|
2700
|
+
return [item for item in diffs if isinstance(item, dict)]
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
def _evidence_sources(
|
|
2704
|
+
record: dict[str, Any],
|
|
2705
|
+
extension_diffs: list[dict[str, Any]],
|
|
2706
|
+
generated_scripts: list[Any],
|
|
2707
|
+
command_events: list[Any],
|
|
2708
|
+
hook_errors: list[Any],
|
|
2709
|
+
) -> list[str]:
|
|
2710
|
+
sources: list[str] = []
|
|
2711
|
+
if extension_diffs:
|
|
2712
|
+
environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
|
|
2713
|
+
integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
|
|
2714
|
+
if integrity.get("snapshot_id"):
|
|
2715
|
+
sources.append("pre_update_snapshot:extension_diffs")
|
|
2716
|
+
else:
|
|
2717
|
+
sources.append("workflow_record:extension_diffs")
|
|
2718
|
+
if generated_scripts:
|
|
2719
|
+
sources.append(f"{_dominant_item_source(generated_scripts, 'payload')}:generated_scripts")
|
|
2720
|
+
if command_events:
|
|
2721
|
+
sources.append(f"{_dominant_item_source(command_events, 'payload')}:command_events")
|
|
2722
|
+
if hook_errors:
|
|
2723
|
+
sources.append(f"{_dominant_item_source(hook_errors, 'hook')}:hook_errors")
|
|
2724
|
+
if record.get("hook_event_ids"):
|
|
2725
|
+
sources.append("hook:hook_event_ids")
|
|
2726
|
+
if record.get("hook_error_ids"):
|
|
2727
|
+
sources.append("hook:hook_error_ids")
|
|
2728
|
+
return _dedupe(sources)
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
def _dominant_item_source(items: list[Any], default: str) -> str:
|
|
2732
|
+
for item in items:
|
|
2733
|
+
if isinstance(item, dict) and item.get("source"):
|
|
2734
|
+
return str(item.get("source"))
|
|
2735
|
+
return default
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def _evidence_quality_flags(
|
|
2739
|
+
record: dict[str, Any],
|
|
2740
|
+
extension_diffs: list[dict[str, Any]],
|
|
2741
|
+
generated_scripts: list[Any],
|
|
2742
|
+
command_events: list[Any],
|
|
2743
|
+
hook_errors: list[Any],
|
|
2744
|
+
) -> list[str]:
|
|
2745
|
+
flags: list[str] = []
|
|
2746
|
+
if generated_scripts and not command_events:
|
|
2747
|
+
flags.append("telemetry.command_events_missing")
|
|
2748
|
+
if hook_errors:
|
|
2749
|
+
flags.append("telemetry.hook_capture_failed")
|
|
2750
|
+
environment = record.get("environment_context") if isinstance(record.get("environment_context"), dict) else {}
|
|
2751
|
+
integrity = environment.get("extension_integrity") if isinstance(environment.get("extension_integrity"), dict) else {}
|
|
2752
|
+
summary = integrity.get("summary") if isinstance(integrity.get("summary"), dict) else {}
|
|
2753
|
+
if summary.get("snapshot_changed_path_count_mismatch") or (extension_diffs and not _safe_int(summary.get("changed_count")) and integrity.get("drift_detected") is False):
|
|
2754
|
+
flags.append("telemetry.snapshot_counts_mismatch")
|
|
2755
|
+
if any(_safe_int(item.get("noise_filtered_count")) for item in extension_diffs if isinstance(item, dict)):
|
|
2756
|
+
flags.append("telemetry.noisy_diff_filtered")
|
|
2757
|
+
return _dedupe(flags)
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def _telemetry_hook_failed_agent_event(
|
|
2761
|
+
hook_errors: list[Any],
|
|
2762
|
+
*,
|
|
2763
|
+
workflow: str,
|
|
2764
|
+
phase: str,
|
|
2765
|
+
) -> dict[str, Any] | None:
|
|
2766
|
+
normalized = [item for item in hook_errors if isinstance(item, dict)]
|
|
2767
|
+
if not normalized:
|
|
2768
|
+
return None
|
|
2769
|
+
sample = normalized[0]
|
|
2770
|
+
return {
|
|
2771
|
+
"schema": "medical-notes-workbench.agent-event.v1",
|
|
2772
|
+
"type": "telemetry_hook_failed",
|
|
2773
|
+
"code": "agent.telemetry_hook_failed",
|
|
2774
|
+
"severity": "medium",
|
|
2775
|
+
"root_cause_code": "telemetry_capture_failed",
|
|
2776
|
+
"workflow": workflow,
|
|
2777
|
+
"phase": phase or "telemetry_capture",
|
|
2778
|
+
"summary": "Falha de hook de telemetria reduziu a evidencia capturada durante o workflow.",
|
|
2779
|
+
"action": "Rodar /report ou capture_extension_diff antes de continuar mutacao arriscada.",
|
|
2780
|
+
"target_kind": "telemetry",
|
|
2781
|
+
"result": "evidence_degraded",
|
|
2782
|
+
"recovery_command": "Run /report or capture_extension_diff before continuing risky mutation.",
|
|
2783
|
+
"artifact_path": str(sample.get("error_path") or ""),
|
|
2784
|
+
"redacted_sample": {
|
|
2785
|
+
"hook_error_count": len(normalized),
|
|
2786
|
+
"hook_event_name": str(sample.get("hook_event_name") or ""),
|
|
2787
|
+
"type": str(sample.get("type") or ""),
|
|
2788
|
+
"mode": str(sample.get("mode") or ""),
|
|
2789
|
+
},
|
|
2790
|
+
"next_action": "Run /report or capture_extension_diff before continuing risky mutation.",
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
def _evidence_timeline(
|
|
2795
|
+
record: dict[str, Any],
|
|
2796
|
+
extension_diffs: list[dict[str, Any]],
|
|
2797
|
+
generated_scripts: list[Any],
|
|
2798
|
+
command_events: list[Any],
|
|
2799
|
+
hook_errors: list[Any],
|
|
2800
|
+
) -> list[dict[str, Any]]:
|
|
2801
|
+
at = str(record.get("recorded_at") or now_iso())
|
|
2802
|
+
timeline: list[dict[str, Any]] = [
|
|
2803
|
+
{"at": at, "kind": "run_record", "label": str(record.get("workflow") or "unknown"), "phase": str(record.get("phase") or "")}
|
|
2804
|
+
]
|
|
2805
|
+
for diff in extension_diffs[:4]:
|
|
2806
|
+
timeline.append({"at": at, "kind": "extension_diff", "label": str(diff.get("path") or ""), "change": str(diff.get("change") or "")})
|
|
2807
|
+
for script in generated_scripts[:4]:
|
|
2808
|
+
if isinstance(script, dict):
|
|
2809
|
+
timeline.append({"at": at, "kind": "generated_script", "label": str(script.get("path") or ""), "source": str(script.get("source") or "")})
|
|
2810
|
+
for event in command_events[:4]:
|
|
2811
|
+
if isinstance(event, dict):
|
|
2812
|
+
timeline.append({"at": at, "kind": "command_event", "label": str(event.get("command_family") or "shell"), "status": str(event.get("status") or "")})
|
|
2813
|
+
for error in hook_errors[:4]:
|
|
2814
|
+
if isinstance(error, dict):
|
|
2815
|
+
timeline.append({"at": str(error.get("recorded_at") or at), "kind": "hook_error", "label": str(error.get("type") or "hook_error")})
|
|
2816
|
+
return timeline
|
|
2817
|
+
|
|
2818
|
+
|
|
2819
|
+
def _evidence_truncation_summary(*groups: list[Any]) -> dict[str, Any]:
|
|
2820
|
+
truncated = 0
|
|
2821
|
+
omitted = 0
|
|
2822
|
+
for group in groups:
|
|
2823
|
+
for item in group:
|
|
2824
|
+
if not isinstance(item, dict):
|
|
2825
|
+
continue
|
|
2826
|
+
if item.get("truncated"):
|
|
2827
|
+
truncated += 1
|
|
2828
|
+
if item.get("content_omitted_reason") or item.get("full_diff_unavailable_reason"):
|
|
2829
|
+
omitted += 1
|
|
2830
|
+
return {"truncated_artifacts": truncated, "omitted_artifacts": omitted}
|
|
2831
|
+
|
|
2832
|
+
|
|
2833
|
+
def _apply_generated_script_risk_signals(record: dict[str, Any]) -> None:
|
|
2834
|
+
scripts = record.get("generated_scripts") if isinstance(record.get("generated_scripts"), list) else []
|
|
2835
|
+
if not scripts:
|
|
2836
|
+
return
|
|
2837
|
+
all_codes = {
|
|
2838
|
+
str(code)
|
|
2839
|
+
for script in scripts
|
|
2840
|
+
if isinstance(script, dict)
|
|
2841
|
+
for code in (script.get("risk_codes") or [])
|
|
2842
|
+
if str(code).strip()
|
|
2843
|
+
}
|
|
2844
|
+
events: list[dict[str, Any]] = []
|
|
2845
|
+
phase = str(record.get("phase") or "")
|
|
2846
|
+
first_path = next((str(script.get("path") or "") for script in scripts if isinstance(script, dict) and script.get("path")), "")
|
|
2847
|
+
events.append(
|
|
2848
|
+
{
|
|
2849
|
+
"type": "generated_script_workaround",
|
|
2850
|
+
"code": "agent.generated_script_workaround",
|
|
2851
|
+
"phase": phase,
|
|
2852
|
+
"severity": "medium",
|
|
2853
|
+
"summary": f"Agente criou ou editou {len(scripts)} script(s) operacional(is) durante o workflow.",
|
|
2854
|
+
"action": "Revisar o script gerado e transformar qualquer logica util em implementacao testada da extensao.",
|
|
2855
|
+
"target_kind": "script",
|
|
2856
|
+
"result": "detected",
|
|
2857
|
+
"path": first_path,
|
|
2858
|
+
}
|
|
2859
|
+
)
|
|
2860
|
+
if {"reads_obsidian_plugin_data", "writes_related_notes_section"} & all_codes:
|
|
2861
|
+
events.append(
|
|
2862
|
+
{
|
|
2863
|
+
"type": "related_notes_wrong_strategy",
|
|
2864
|
+
"code": "agent.related_notes_wrong_strategy",
|
|
2865
|
+
"phase": phase,
|
|
2866
|
+
"severity": "high",
|
|
2867
|
+
"summary": "Agente tentou reconstruir Notas Relacionadas por script improvisado em vez de usar o produto validado do plugin.",
|
|
2868
|
+
"action": "Reimplementar a integracao Related Notes dentro da extensao, com contrato de entrada/saida e dry-run.",
|
|
2869
|
+
"target_kind": "related_notes",
|
|
2870
|
+
"result": "detected",
|
|
2871
|
+
"path": first_path,
|
|
2872
|
+
}
|
|
2873
|
+
)
|
|
2874
|
+
if "mass_markdown_mutation" in all_codes and "no_dry_run" in all_codes:
|
|
2875
|
+
events.append(
|
|
2876
|
+
{
|
|
2877
|
+
"type": "mass_mutation_without_dry_run",
|
|
2878
|
+
"code": "agent.mass_mutation_without_dry_run",
|
|
2879
|
+
"phase": phase,
|
|
2880
|
+
"severity": "high",
|
|
2881
|
+
"summary": "Script gerado parece mutar muitas notas Markdown sem dry-run detectavel.",
|
|
2882
|
+
"action": "Bloquear aplicacao direta; exigir preview, backup e limite de escopo antes de mutar o vault.",
|
|
2883
|
+
"target_kind": "vault",
|
|
2884
|
+
"result": "detected",
|
|
2885
|
+
"path": first_path,
|
|
2886
|
+
}
|
|
2887
|
+
)
|
|
2888
|
+
if {"direct_sql_mutation", "queue_truth_bypass", "unsafe_mass_wikilink_rewrite"} & all_codes:
|
|
2889
|
+
root_cause = (
|
|
2890
|
+
"queue_truth_bypass"
|
|
2891
|
+
if "queue_truth_bypass" in all_codes
|
|
2892
|
+
else "direct_sql_mutation"
|
|
2893
|
+
if "direct_sql_mutation" in all_codes
|
|
2894
|
+
else "unsafe_mass_wikilink_rewrite"
|
|
2895
|
+
)
|
|
2896
|
+
events.append(
|
|
2897
|
+
{
|
|
2898
|
+
"type": "generated_script_risk",
|
|
2899
|
+
"code": "agent.unsafe_generated_script_recovery_bypass",
|
|
2900
|
+
"phase": phase,
|
|
2901
|
+
"severity": "high",
|
|
2902
|
+
"summary": "Script gerado tenta contornar o workflow oficial de recovery com mutação direta.",
|
|
2903
|
+
"action": "Descartar o workaround e usar os comandos oficiais com dry-run, plano e recibo.",
|
|
2904
|
+
"target_kind": "workflow_state",
|
|
2905
|
+
"result": "detected",
|
|
2906
|
+
"path": first_path,
|
|
2907
|
+
"root_cause_code": root_cause,
|
|
2908
|
+
"recovery_command": _generated_script_recovery_command(all_codes),
|
|
2909
|
+
}
|
|
2910
|
+
)
|
|
2911
|
+
if "extension_prompt_or_script_drift" in all_codes:
|
|
2912
|
+
events.append(
|
|
2913
|
+
{
|
|
2914
|
+
"type": "script_or_prompt_drift",
|
|
2915
|
+
"code": "agent.script_or_prompt_drift",
|
|
2916
|
+
"phase": phase,
|
|
2917
|
+
"severity": "high",
|
|
2918
|
+
"summary": "Script gerado toca area allowlisted da extensao.",
|
|
2919
|
+
"action": "Comparar o diff e decidir se vira update publicado ou rollback.",
|
|
2920
|
+
"target_kind": "extension",
|
|
2921
|
+
"result": "detected",
|
|
2922
|
+
"path": first_path,
|
|
2923
|
+
}
|
|
2924
|
+
)
|
|
2925
|
+
if not events:
|
|
2926
|
+
return
|
|
2927
|
+
diagnostic = record.setdefault("diagnostic_context", {})
|
|
2928
|
+
if not isinstance(diagnostic, dict):
|
|
2929
|
+
return
|
|
2930
|
+
context = diagnostic.setdefault(
|
|
2931
|
+
"agent_behavior_context",
|
|
2932
|
+
{"event_count": 0, "types": {}, "severities": {}, "highest_severity": "", "codes": [], "samples": []},
|
|
2933
|
+
)
|
|
2934
|
+
existing_events = record.setdefault("agent_events", [])
|
|
2935
|
+
if not isinstance(existing_events, list):
|
|
2936
|
+
existing_events = []
|
|
2937
|
+
record["agent_events"] = existing_events
|
|
2938
|
+
summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
|
|
2939
|
+
signals = summary.setdefault("signals", [])
|
|
2940
|
+
for event in events:
|
|
2941
|
+
event.setdefault("schema", "medical-notes-workbench.agent-event.v1")
|
|
2942
|
+
event.setdefault("root_cause_code", str(event.get("code") or "").replace("agent.", ""))
|
|
2943
|
+
event.setdefault("workflow", str(record.get("workflow") or ""))
|
|
2944
|
+
event.setdefault("recovery_command", _generated_script_recovery_command(all_codes))
|
|
2945
|
+
event.setdefault("artifact_path", first_path)
|
|
2946
|
+
event.setdefault("redacted_sample", {"path": first_path, "risk_codes": sorted(all_codes)[:12]})
|
|
2947
|
+
event.setdefault("next_action", str(event.get("action") or ""))
|
|
2948
|
+
if not any(isinstance(item, dict) and item.get("code") == event["code"] for item in existing_events):
|
|
2949
|
+
existing_events.append(event)
|
|
2950
|
+
_append_agent_context_event(context, event)
|
|
2951
|
+
if event["code"] not in signals:
|
|
2952
|
+
signals.append(event["code"])
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def _generated_script_recovery_command(risk_codes: set[str]) -> str:
|
|
2956
|
+
if "queue_truth_bypass" in risk_codes or "direct_sql_mutation" in risk_codes:
|
|
2957
|
+
return "uv run python scripts/mednotes/wiki/cli.py vocabulary-recover --mode reconcile-queue --dry-run --json"
|
|
2958
|
+
if "unsafe_mass_wikilink_rewrite" in risk_codes or "mass_markdown_mutation" in risk_codes:
|
|
2959
|
+
return "uv run python scripts/mednotes/wiki/cli.py run-linker --diagnose --json"
|
|
2960
|
+
if {"reads_obsidian_plugin_data", "writes_related_notes_section"} & risk_codes:
|
|
2961
|
+
return "uv run python scripts/mednotes/wiki/cli.py related-notes-sync --recover-export --mode auto --json"
|
|
2962
|
+
return "uv run python scripts/mednotes/wiki/cli.py environment-preflight --json"
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def load_hook_events(*, since: str = "2h", root: str | Path | None = None, limit: int = MAX_HOOK_EVENTS) -> list[dict[str, Any]]:
|
|
2966
|
+
cutoff = _parse_since(since)
|
|
2967
|
+
events_dir = feedback_root(root) / "hook-events"
|
|
2968
|
+
events: list[dict[str, Any]] = []
|
|
2969
|
+
for path in sorted(events_dir.glob("*.json"), reverse=True):
|
|
2970
|
+
try:
|
|
2971
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
2972
|
+
except (OSError, json.JSONDecodeError):
|
|
2973
|
+
continue
|
|
2974
|
+
if data.get("schema") != AGENT_HOOK_EVENT_SCHEMA:
|
|
2975
|
+
continue
|
|
2976
|
+
recorded_at = _parse_datetime(str(data.get("recorded_at") or data.get("timestamp") or ""))
|
|
2977
|
+
if recorded_at and recorded_at < cutoff:
|
|
2978
|
+
continue
|
|
2979
|
+
data.setdefault("event_path", str(path))
|
|
2980
|
+
events.append(data)
|
|
2981
|
+
if len(events) >= limit:
|
|
2982
|
+
break
|
|
2983
|
+
return list(reversed(events))
|
|
2984
|
+
|
|
2985
|
+
|
|
2986
|
+
def load_hook_errors(*, since: str = "2h", root: str | Path | None = None, limit: int = MAX_HOOK_ERRORS) -> list[dict[str, Any]]:
|
|
2987
|
+
cutoff = _parse_since(since)
|
|
2988
|
+
errors_dir = feedback_root(root) / "hook-errors"
|
|
2989
|
+
errors: list[dict[str, Any]] = []
|
|
2990
|
+
for path in sorted(errors_dir.glob("*.json"), reverse=True):
|
|
2991
|
+
try:
|
|
2992
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
2993
|
+
except (OSError, json.JSONDecodeError):
|
|
2994
|
+
continue
|
|
2995
|
+
if data.get("schema") != AGENT_HOOK_ERROR_SCHEMA:
|
|
2996
|
+
continue
|
|
2997
|
+
recorded_at = _parse_datetime(str(data.get("recorded_at") or data.get("timestamp") or ""))
|
|
2998
|
+
if recorded_at and recorded_at < cutoff:
|
|
2999
|
+
continue
|
|
3000
|
+
data.setdefault("error_path", str(path))
|
|
3001
|
+
errors.append(data)
|
|
3002
|
+
if len(errors) >= limit:
|
|
3003
|
+
break
|
|
3004
|
+
return list(reversed(errors))
|
|
3005
|
+
|
|
3006
|
+
|
|
3007
|
+
def hook_debug_record(
|
|
3008
|
+
*,
|
|
3009
|
+
events: list[dict[str, Any]],
|
|
3010
|
+
errors: list[dict[str, Any]] | None = None,
|
|
3011
|
+
since: str = "2h",
|
|
3012
|
+
) -> dict[str, Any] | None:
|
|
3013
|
+
debug = _debug_from_hook_events(events, errors=errors or [])
|
|
3014
|
+
if not debug["generated_scripts"] and not debug["command_events"] and not debug["hook_errors"]:
|
|
3015
|
+
return None
|
|
3016
|
+
failed_commands = any(_command_event_failed(event) for event in debug["command_events"])
|
|
3017
|
+
hook_error_detected = bool(debug["hook_errors"])
|
|
3018
|
+
workflow_hints = _workflow_hints_from_command_events(debug["command_events"])
|
|
3019
|
+
digest = hashlib.sha256(
|
|
3020
|
+
json.dumps(
|
|
3021
|
+
{"hook_event_ids": debug["hook_event_ids"], "hook_error_ids": debug["hook_error_ids"]},
|
|
3022
|
+
sort_keys=True,
|
|
3023
|
+
ensure_ascii=False,
|
|
3024
|
+
).encode("utf-8")
|
|
3025
|
+
).hexdigest()[:12]
|
|
3026
|
+
record = {
|
|
3027
|
+
"schema": RUN_RECORD_SCHEMA,
|
|
3028
|
+
"run_id": f"hook-events-{digest}",
|
|
3029
|
+
"recorded_at": now_iso(),
|
|
3030
|
+
"workflow": "/mednotes:agent-session",
|
|
3031
|
+
"source": "agent",
|
|
3032
|
+
"command": "Gemini CLI hooks",
|
|
3033
|
+
"exit_code": 0,
|
|
3034
|
+
"duration_ms": 0,
|
|
3035
|
+
"status": "completed_with_warnings",
|
|
3036
|
+
"phase": "hook-events",
|
|
3037
|
+
"workflow_hints": workflow_hints,
|
|
3038
|
+
"blocked_reason": "",
|
|
3039
|
+
"next_action": "Revisar scripts gerados, erros de console e falhas internas dos hooks capturados pela telemetria.",
|
|
3040
|
+
"required_inputs": [],
|
|
3041
|
+
"human_decision_required": False,
|
|
3042
|
+
"dry_run": None,
|
|
3043
|
+
"apply": None,
|
|
3044
|
+
"payload_summary": {
|
|
3045
|
+
"counts": {
|
|
3046
|
+
"generated_script_count": len(debug["generated_scripts"]),
|
|
3047
|
+
"command_event_count": len(debug["command_events"]),
|
|
3048
|
+
"hook_error_count": len(debug["hook_errors"]),
|
|
3049
|
+
},
|
|
3050
|
+
"warnings": [],
|
|
3051
|
+
"errors": [],
|
|
3052
|
+
"required_inputs": [],
|
|
3053
|
+
"relevant_paths": [item.get("path", "") for item in debug["generated_scripts"] if item.get("path")],
|
|
3054
|
+
"path_hashes": {},
|
|
3055
|
+
"signals": _dedupe((["agent.command_failed"] if failed_commands else []) + (["telemetry.hook_error"] if hook_error_detected else [])),
|
|
3056
|
+
"status": "completed_with_warnings",
|
|
3057
|
+
"phase": "hook-events",
|
|
3058
|
+
"workflow_hints": workflow_hints,
|
|
3059
|
+
},
|
|
3060
|
+
"diagnostic_context": {
|
|
3061
|
+
"root_cause_code": "agent.hook_debug",
|
|
3062
|
+
"root_cause_label": "Eventos tecnicos capturados por hooks",
|
|
3063
|
+
"recovery_command": "Revisar os scripts gerados e erros de console no email de telemetria.",
|
|
3064
|
+
"missing_inputs": [],
|
|
3065
|
+
"decision_context": {"types": [], "decisions": []},
|
|
3066
|
+
"blocker_context": {"codes": [], "counts": {}, "summaries": [], "samples": [], "routes": []},
|
|
3067
|
+
"contract_gaps": [],
|
|
3068
|
+
},
|
|
3069
|
+
"environment_context": {},
|
|
3070
|
+
"diagnostic_snippets": [],
|
|
3071
|
+
"generated_scripts": debug["generated_scripts"],
|
|
3072
|
+
"command_events": debug["command_events"],
|
|
3073
|
+
"hook_errors": debug["hook_errors"],
|
|
3074
|
+
"hook_event_ids": debug["hook_event_ids"],
|
|
3075
|
+
"hook_error_ids": debug["hook_error_ids"],
|
|
3076
|
+
"hook_debug_since": since,
|
|
3077
|
+
}
|
|
3078
|
+
hook_failure_event = _telemetry_hook_failed_agent_event(
|
|
3079
|
+
debug["hook_errors"],
|
|
3080
|
+
workflow="/mednotes:agent-session",
|
|
3081
|
+
phase="hook-events",
|
|
3082
|
+
)
|
|
3083
|
+
if hook_failure_event:
|
|
3084
|
+
record["agent_events"] = [hook_failure_event]
|
|
3085
|
+
_apply_generated_script_risk_signals(record)
|
|
3086
|
+
return attach_telemetry_evidence(record, send_path="hook_debug_record")
|
|
3087
|
+
|
|
3088
|
+
|
|
3089
|
+
def _command_event_failed(event: JsonObject) -> bool:
|
|
3090
|
+
status = _json_text(event, "status").lower()
|
|
3091
|
+
exit_code = _json_value(event, "exit_code")
|
|
3092
|
+
return bool(status in {"failed", "error"} or (isinstance(exit_code, int) and exit_code != 0) or _json_value(event, "error"))
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _workflow_hints_from_command_events(events: list[JsonObject]) -> list[JsonObject]:
|
|
3096
|
+
hints: list[JsonObject] = []
|
|
3097
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
3098
|
+
for event in events[:MAX_COMMAND_EVENTS]:
|
|
3099
|
+
event_view = _json_object_view(event)
|
|
3100
|
+
workflow = _json_text(event_view, "workflow")
|
|
3101
|
+
phase = _json_text(event_view, "phase")
|
|
3102
|
+
status = _json_text(event_view, "workflow_status")
|
|
3103
|
+
blocked_reason = _json_text(event_view, "blocked_reason")
|
|
3104
|
+
if not (workflow or phase or status or blocked_reason):
|
|
3105
|
+
continue
|
|
3106
|
+
key = (workflow, phase, status, blocked_reason)
|
|
3107
|
+
if key in seen:
|
|
3108
|
+
continue
|
|
3109
|
+
seen.add(key)
|
|
3110
|
+
hints.append(
|
|
3111
|
+
{
|
|
3112
|
+
"workflow": workflow,
|
|
3113
|
+
"phase": phase,
|
|
3114
|
+
"status": status,
|
|
3115
|
+
"blocked_reason": blocked_reason,
|
|
3116
|
+
"exit_code": _json_int_field(event_view, "workflow_exit_code")
|
|
3117
|
+
if _json_int_field(event_view, "workflow_exit_code") is not None
|
|
3118
|
+
else _json_int_field(event_view, "exit_code"),
|
|
3119
|
+
}
|
|
3120
|
+
)
|
|
3121
|
+
return hints[:8]
|
|
3122
|
+
|
|
3123
|
+
|
|
3124
|
+
def _debug_from_hook_events(events: list[dict[str, Any]], *, errors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
3125
|
+
generated_scripts: list[dict[str, Any]] = []
|
|
3126
|
+
command_events: list[dict[str, Any]] = []
|
|
3127
|
+
hook_errors: list[dict[str, Any]] = []
|
|
3128
|
+
event_ids: list[str] = []
|
|
3129
|
+
error_ids: list[str] = []
|
|
3130
|
+
for event in events[:MAX_HOOK_EVENTS]:
|
|
3131
|
+
event_id = str(event.get("event_id") or "")
|
|
3132
|
+
if event_id:
|
|
3133
|
+
event_ids.append(event_id)
|
|
3134
|
+
generated_scripts.extend(_normalized_generated_scripts(event.get("generated_scripts", []), source="hook"))
|
|
3135
|
+
command_events.extend(_normalized_command_events(event.get("command_events", []), source="hook"))
|
|
3136
|
+
for error in (errors or [])[:MAX_HOOK_ERRORS]:
|
|
3137
|
+
error_id = str(error.get("error_id") or "")
|
|
3138
|
+
if error_id:
|
|
3139
|
+
error_ids.append(error_id)
|
|
3140
|
+
hook_errors.extend(_normalized_hook_errors([error], source="hook"))
|
|
3141
|
+
return {
|
|
3142
|
+
"generated_scripts": _merge_generated_scripts(generated_scripts)[:MAX_GENERATED_SCRIPTS],
|
|
3143
|
+
"command_events": _merge_command_events(command_events)[:MAX_COMMAND_EVENTS],
|
|
3144
|
+
"hook_errors": _merge_hook_errors(hook_errors)[:MAX_HOOK_ERRORS],
|
|
3145
|
+
"hook_event_ids": _dedupe(event_ids)[:MAX_HOOK_EVENTS],
|
|
3146
|
+
"hook_error_ids": _dedupe(error_ids)[:MAX_HOOK_ERRORS],
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
def _normalized_generated_scripts(value: Any, *, source: str) -> list[dict[str, Any]]:
|
|
3151
|
+
if not isinstance(value, list):
|
|
3152
|
+
return []
|
|
3153
|
+
scripts: list[dict[str, Any]] = []
|
|
3154
|
+
for item in value[:MAX_GENERATED_SCRIPTS]:
|
|
3155
|
+
if not isinstance(item, dict):
|
|
3156
|
+
continue
|
|
3157
|
+
path = str(item.get("path") or "")
|
|
3158
|
+
suffix = Path(path).suffix.lower()
|
|
3159
|
+
if suffix not in SCRIPT_SUFFIXES:
|
|
3160
|
+
continue
|
|
3161
|
+
content = str(item.get("content") or "")
|
|
3162
|
+
script: dict[str, Any] = {
|
|
3163
|
+
"path": _compact_path_label(path),
|
|
3164
|
+
"language": _language_for_suffix(suffix),
|
|
3165
|
+
"sha256": str(item.get("sha256") or _hash_text(content)),
|
|
3166
|
+
"size_bytes": _safe_int(item.get("size_bytes") if item.get("size_bytes") is not None else len(content.encode("utf-8"))),
|
|
3167
|
+
"source": str(item.get("source") or source),
|
|
3168
|
+
"capture_method": str(item.get("capture_method") or ""),
|
|
3169
|
+
}
|
|
3170
|
+
if content:
|
|
3171
|
+
script["content"] = redact_operational_text(content, max_chars=MAX_SCRIPT_CONTENT_CHARS)
|
|
3172
|
+
script["truncated"] = len(str(script["content"])) < len(content)
|
|
3173
|
+
risk_codes = _generated_script_risk_codes(path=path, content=content)
|
|
3174
|
+
if risk_codes:
|
|
3175
|
+
script["risk_codes"] = risk_codes
|
|
3176
|
+
if item.get("content_omitted_reason"):
|
|
3177
|
+
script["content_omitted_reason"] = redact_snippet(item.get("content_omitted_reason"), max_chars=160)
|
|
3178
|
+
scripts.append(script)
|
|
3179
|
+
return scripts
|
|
3180
|
+
|
|
3181
|
+
|
|
3182
|
+
def _generated_script_risk_codes(*, path: str, content: str) -> list[str]:
|
|
3183
|
+
text = str(content or "")
|
|
3184
|
+
lowered = text.lower()
|
|
3185
|
+
path_lower = str(path or "").replace("\\", "/").lower()
|
|
3186
|
+
codes: list[str] = []
|
|
3187
|
+
|
|
3188
|
+
def add(code: str, condition: bool) -> None:
|
|
3189
|
+
if condition and code not in codes:
|
|
3190
|
+
codes.append(code)
|
|
3191
|
+
|
|
3192
|
+
markdown_scan = (
|
|
3193
|
+
"rglob(\"*.md\")" in lowered
|
|
3194
|
+
or "rglob('*.md')" in lowered
|
|
3195
|
+
or "glob(\"**/*.md\")" in lowered
|
|
3196
|
+
or "glob('**/*.md')" in lowered
|
|
3197
|
+
or ("os.walk" in lowered and ".md" in lowered)
|
|
3198
|
+
)
|
|
3199
|
+
writes_files = bool(
|
|
3200
|
+
re.search(r"\bwrite_text\s*\(", lowered)
|
|
3201
|
+
or re.search(r"\bopen\s*\([^)]*['\"]w", lowered)
|
|
3202
|
+
or "fs.writefile" in lowered
|
|
3203
|
+
or "set-content" in lowered
|
|
3204
|
+
or "out-file" in lowered
|
|
3205
|
+
or ".unlink(" in lowered
|
|
3206
|
+
or "shutil.move" in lowered
|
|
3207
|
+
)
|
|
3208
|
+
add("mass_markdown_mutation", markdown_scan and writes_files)
|
|
3209
|
+
add(
|
|
3210
|
+
"unsafe_mass_wikilink_rewrite",
|
|
3211
|
+
markdown_scan and writes_files and bool(re.search(r"\[\[|wikilink|wiki\s*link", lowered)),
|
|
3212
|
+
)
|
|
3213
|
+
add(
|
|
3214
|
+
"direct_sql_mutation",
|
|
3215
|
+
bool(
|
|
3216
|
+
("sqlite3.connect" in lowered or ".sqlite" in lowered or "vocabulary.sqlite" in lowered)
|
|
3217
|
+
and re.search(r"\b(update|insert|delete|drop|alter|replace)\b", lowered)
|
|
3218
|
+
),
|
|
3219
|
+
)
|
|
3220
|
+
add(
|
|
3221
|
+
"queue_truth_bypass",
|
|
3222
|
+
"note_semantic_ingestion_queue" in lowered
|
|
3223
|
+
and "status" in lowered
|
|
3224
|
+
and bool(re.search(r"\b(applied|completed|done)\b", lowered)),
|
|
3225
|
+
)
|
|
3226
|
+
add("hardcoded_user_path", bool(re.search(r"(?i)([a-z]:\\\\|/users/|/home/|~[/\\])", text)))
|
|
3227
|
+
add("reads_obsidian_plugin_data", ".obsidian/plugins" in lowered or "related-notes" in lowered or "related notes" in lowered)
|
|
3228
|
+
add("writes_related_notes_section", "notas relacionadas" in lowered or "related notes" in lowered)
|
|
3229
|
+
add(
|
|
3230
|
+
"external_api_or_embedding_call",
|
|
3231
|
+
bool(
|
|
3232
|
+
re.search(r"\b(openai|anthropic|gemini|embedding|embeddings)\b", lowered)
|
|
3233
|
+
or re.search(r"\b(requests|httpx)\.(post|get|request)\s*\(", lowered)
|
|
3234
|
+
or re.search(r"\bfetch\s*\(", lowered)
|
|
3235
|
+
),
|
|
3236
|
+
)
|
|
3237
|
+
add("no_dry_run", writes_files and "dry_run" not in lowered and "--dry-run" not in lowered and "dry-run" not in lowered)
|
|
3238
|
+
add("encoding_corruption", "\ufffd" in text or bool(re.search(r"(?m)^##\s+\?+\s+(notas relacionadas|fontes consolidadas|fechamento)\b", lowered)))
|
|
3239
|
+
add(
|
|
3240
|
+
"extension_prompt_or_script_drift",
|
|
3241
|
+
path_lower == "gemini.md"
|
|
3242
|
+
or path_lower.startswith(("commands/", "skills/", "docs/", "hooks/", "scripts/", "src/"))
|
|
3243
|
+
or "/extensions/medical-notes-workbench/" in path_lower,
|
|
3244
|
+
)
|
|
3245
|
+
return codes
|
|
3246
|
+
|
|
3247
|
+
|
|
3248
|
+
def _normalized_command_events(value: Any, *, source: str) -> list[dict[str, Any]]:
|
|
3249
|
+
if not isinstance(value, list):
|
|
3250
|
+
return []
|
|
3251
|
+
events: list[dict[str, Any]] = []
|
|
3252
|
+
for item in value[:MAX_COMMAND_EVENTS]:
|
|
3253
|
+
if not isinstance(item, dict):
|
|
3254
|
+
continue
|
|
3255
|
+
event: dict[str, Any] = {
|
|
3256
|
+
"command_family": _code_slug(item.get("command_family") or "shell"),
|
|
3257
|
+
"command": redact_operational_text(item.get("command") or "", max_chars=2000),
|
|
3258
|
+
"exit_code": item.get("exit_code") if isinstance(item.get("exit_code"), int) else None,
|
|
3259
|
+
"status": _code_slug(item.get("status") or "unknown"),
|
|
3260
|
+
"source": str(item.get("source") or source),
|
|
3261
|
+
"capture_method": str(item.get("capture_method") or ""),
|
|
3262
|
+
}
|
|
3263
|
+
for key in ("workflow", "phase", "workflow_status", "blocked_reason"):
|
|
3264
|
+
if item.get(key):
|
|
3265
|
+
event[key] = str(item.get(key)) if key == "workflow" else _code_slug(item.get(key))
|
|
3266
|
+
if isinstance(item.get("workflow_exit_code"), int):
|
|
3267
|
+
event["workflow_exit_code"] = item.get("workflow_exit_code")
|
|
3268
|
+
for key in ("stdout_tail", "stderr_tail", "output_tail", "error"):
|
|
3269
|
+
if item.get(key):
|
|
3270
|
+
event[key] = redact_operational_text(item.get(key), max_chars=MAX_CONSOLE_TAIL_CHARS)
|
|
3271
|
+
events.append(event)
|
|
3272
|
+
return events
|
|
3273
|
+
|
|
3274
|
+
|
|
3275
|
+
def _normalized_hook_errors(value: Any, *, source: str) -> list[dict[str, Any]]:
|
|
3276
|
+
if not isinstance(value, list):
|
|
3277
|
+
return []
|
|
3278
|
+
errors: list[dict[str, Any]] = []
|
|
3279
|
+
for item in value[:MAX_HOOK_ERRORS]:
|
|
3280
|
+
if not isinstance(item, dict):
|
|
3281
|
+
continue
|
|
3282
|
+
error: dict[str, Any] = {
|
|
3283
|
+
"error_id": str(item.get("error_id") or ""),
|
|
3284
|
+
"recorded_at": str(item.get("recorded_at") or ""),
|
|
3285
|
+
"type": _code_slug(item.get("type") or "hook_internal_error"),
|
|
3286
|
+
"severity": _code_slug(item.get("severity") or "warning"),
|
|
3287
|
+
"mode": _code_slug(item.get("mode") or ""),
|
|
3288
|
+
"hook_event_name": _code_slug(item.get("hook_event_name") or ""),
|
|
3289
|
+
"tool_name": _code_slug(item.get("tool_name") or ""),
|
|
3290
|
+
"source": str(item.get("source") or source),
|
|
3291
|
+
}
|
|
3292
|
+
for key in ("message", "stack_tail", "stdout_tail", "stderr_tail", "error"):
|
|
3293
|
+
if item.get(key):
|
|
3294
|
+
error[key] = redact_operational_text(item.get(key), max_chars=MAX_HOOK_ERROR_CHARS)
|
|
3295
|
+
if isinstance(item.get("exit_code"), int):
|
|
3296
|
+
error["exit_code"] = item.get("exit_code")
|
|
3297
|
+
details = item.get("details")
|
|
3298
|
+
if isinstance(details, dict):
|
|
3299
|
+
error["details"] = _redact_hook_error_details(details)
|
|
3300
|
+
errors.append(error)
|
|
3301
|
+
return errors
|
|
3302
|
+
|
|
3303
|
+
|
|
3304
|
+
def _redact_hook_error_details(value: Any, *, depth: int = 0) -> Any:
|
|
3305
|
+
if depth > 4:
|
|
3306
|
+
return "[max-depth]"
|
|
3307
|
+
if isinstance(value, dict):
|
|
3308
|
+
out: dict[str, Any] = {}
|
|
3309
|
+
for key, item in list(value.items())[:40]:
|
|
3310
|
+
lower = str(key).lower()
|
|
3311
|
+
if lower in SECRET_KEYS:
|
|
3312
|
+
out[str(key)] = "[redacted]"
|
|
3313
|
+
elif lower in LONG_TEXT_KEYS:
|
|
3314
|
+
out[str(key)] = f"[{lower} omitted]"
|
|
3315
|
+
else:
|
|
3316
|
+
out[str(key)] = _redact_hook_error_details(item, depth=depth + 1)
|
|
3317
|
+
return out
|
|
3318
|
+
if isinstance(value, list):
|
|
3319
|
+
return [_redact_hook_error_details(item, depth=depth + 1) for item in value[:20]]
|
|
3320
|
+
if isinstance(value, str):
|
|
3321
|
+
return redact_operational_text(value, max_chars=600)
|
|
3322
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
3323
|
+
return value
|
|
3324
|
+
return redact_operational_text(str(value), max_chars=300)
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def _merge_generated_scripts(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
3328
|
+
merged: list[dict[str, Any]] = []
|
|
3329
|
+
seen: set[tuple[str, str]] = set()
|
|
3330
|
+
for group in groups:
|
|
3331
|
+
for item in group:
|
|
3332
|
+
key = (str(item.get("path") or ""), str(item.get("sha256") or ""))
|
|
3333
|
+
if key in seen:
|
|
3334
|
+
continue
|
|
3335
|
+
seen.add(key)
|
|
3336
|
+
merged.append(item)
|
|
3337
|
+
return merged
|
|
3338
|
+
|
|
3339
|
+
|
|
3340
|
+
def _merge_command_events(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
3341
|
+
merged: list[dict[str, Any]] = []
|
|
3342
|
+
seen: set[tuple[str, str, Any]] = set()
|
|
3343
|
+
for group in groups:
|
|
3344
|
+
for item in group:
|
|
3345
|
+
key = (str(item.get("command") or ""), str(item.get("output_tail") or item.get("stderr_tail") or ""), item.get("exit_code"))
|
|
3346
|
+
if key in seen:
|
|
3347
|
+
continue
|
|
3348
|
+
seen.add(key)
|
|
3349
|
+
merged.append(item)
|
|
3350
|
+
return merged
|
|
3351
|
+
|
|
3352
|
+
|
|
3353
|
+
def _merge_hook_errors(*groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
3354
|
+
merged: list[dict[str, Any]] = []
|
|
3355
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
3356
|
+
for group in groups:
|
|
3357
|
+
for item in group:
|
|
3358
|
+
key = (
|
|
3359
|
+
str(item.get("type") or ""),
|
|
3360
|
+
str(item.get("mode") or ""),
|
|
3361
|
+
str(item.get("tool_name") or ""),
|
|
3362
|
+
str(item.get("message") or item.get("error") or ""),
|
|
3363
|
+
)
|
|
3364
|
+
if key in seen:
|
|
3365
|
+
continue
|
|
3366
|
+
seen.add(key)
|
|
3367
|
+
merged.append(item)
|
|
3368
|
+
return merged
|
|
3369
|
+
|
|
3370
|
+
|
|
3371
|
+
def _language_for_suffix(suffix: str) -> str:
|
|
3372
|
+
return {
|
|
3373
|
+
".py": "python",
|
|
3374
|
+
".js": "javascript",
|
|
3375
|
+
".mjs": "javascript",
|
|
3376
|
+
".cjs": "javascript",
|
|
3377
|
+
".sh": "shell",
|
|
3378
|
+
".ps1": "powershell",
|
|
3379
|
+
".cmd": "batch",
|
|
3380
|
+
}.get(suffix, suffix.lstrip(".") or "text")
|
|
3381
|
+
|
|
3382
|
+
|
|
3383
|
+
def load_pre_update_snapshot_records(
|
|
3384
|
+
*,
|
|
3385
|
+
since: str = "30d",
|
|
3386
|
+
root: str | Path | None = None,
|
|
3387
|
+
limit: int = 5,
|
|
3388
|
+
) -> list[dict[str, Any]]:
|
|
3389
|
+
cutoff = _parse_since(since)
|
|
3390
|
+
snapshots_dir = feedback_root(root) / "pre-update-snapshots"
|
|
3391
|
+
records: list[dict[str, Any]] = []
|
|
3392
|
+
for metadata_path in sorted(snapshots_dir.glob("*/snapshot.json"), reverse=True):
|
|
3393
|
+
try:
|
|
3394
|
+
snapshot = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
3395
|
+
except (OSError, json.JSONDecodeError):
|
|
3396
|
+
continue
|
|
3397
|
+
if snapshot.get("schema") != PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA:
|
|
3398
|
+
continue
|
|
3399
|
+
recorded_at = _parse_datetime(str(snapshot.get("recorded_at") or ""))
|
|
3400
|
+
if recorded_at and recorded_at < cutoff:
|
|
3401
|
+
continue
|
|
3402
|
+
record = pre_update_snapshot_record(snapshot)
|
|
3403
|
+
if record:
|
|
3404
|
+
records.append(record)
|
|
3405
|
+
if len(records) >= limit:
|
|
3406
|
+
break
|
|
3407
|
+
return list(reversed(records))
|
|
3408
|
+
|
|
3409
|
+
|
|
3410
|
+
def pre_update_snapshot_record(snapshot: dict[str, Any]) -> dict[str, Any] | None:
|
|
3411
|
+
snapshot_id = str(snapshot.get("snapshot_id") or "")
|
|
3412
|
+
snapshot_path = Path(str(snapshot.get("snapshot_path") or ""))
|
|
3413
|
+
if not snapshot_id or not snapshot_path.exists():
|
|
3414
|
+
return None
|
|
3415
|
+
patch_id = str(snapshot.get("patch_id") or "")
|
|
3416
|
+
patch_phase = str(snapshot.get("phase") or "")
|
|
3417
|
+
snapshot_reason = str(snapshot.get("reason") or patch_id or "pre_update_extension_snapshot")
|
|
3418
|
+
extension_diffs = _snapshot_extension_diffs(snapshot_path)
|
|
3419
|
+
generated_scripts = _normalized_generated_scripts(
|
|
3420
|
+
snapshot.get("generated_scripts", []),
|
|
3421
|
+
source="pre_update_snapshot",
|
|
3422
|
+
)
|
|
3423
|
+
if not extension_diffs and not generated_scripts:
|
|
3424
|
+
return None
|
|
3425
|
+
snapshot_changed_path_count = _safe_int(snapshot.get("changed_path_count"))
|
|
3426
|
+
snapshot_untracked_path_count = _safe_int(snapshot.get("untracked_path_count"))
|
|
3427
|
+
snapshot_changed_count = snapshot_changed_path_count + snapshot_untracked_path_count
|
|
3428
|
+
evidence_changed_count = len(extension_diffs) + len(generated_scripts)
|
|
3429
|
+
changed_count = max(snapshot_changed_count, evidence_changed_count)
|
|
3430
|
+
record = {
|
|
3431
|
+
"schema": RUN_RECORD_SCHEMA,
|
|
3432
|
+
"run_id": f"pre-update-extension-snapshot-{snapshot_id}",
|
|
3433
|
+
"recorded_at": str(snapshot.get("recorded_at") or now_iso()),
|
|
3434
|
+
"workflow": "/mednotes:telemetry",
|
|
3435
|
+
"source": "agent",
|
|
3436
|
+
"command": snapshot_reason,
|
|
3437
|
+
"exit_code": 0,
|
|
3438
|
+
"duration_ms": 0,
|
|
3439
|
+
"status": "completed_with_warnings" if changed_count else "completed",
|
|
3440
|
+
"phase": "pre-update-snapshot",
|
|
3441
|
+
"blocked_reason": "",
|
|
3442
|
+
"next_action": "Atualizar a extensao somente depois de confirmar que o snapshot pre-update foi recebido ou preservado.",
|
|
3443
|
+
"required_inputs": [],
|
|
3444
|
+
"human_decision_required": False,
|
|
3445
|
+
"dry_run": None,
|
|
3446
|
+
"apply": None,
|
|
3447
|
+
"payload_summary": {
|
|
3448
|
+
"counts": {
|
|
3449
|
+
"changed_path_count": _safe_int(snapshot.get("changed_path_count")),
|
|
3450
|
+
"untracked_path_count": _safe_int(snapshot.get("untracked_path_count")),
|
|
3451
|
+
"snapshot_changed_path_count": snapshot_changed_path_count,
|
|
3452
|
+
"snapshot_untracked_path_count": snapshot_untracked_path_count,
|
|
3453
|
+
"generated_script_count": len(generated_scripts),
|
|
3454
|
+
},
|
|
3455
|
+
"warnings": ["pre_update_extension_snapshot_captured"],
|
|
3456
|
+
"errors": [],
|
|
3457
|
+
"required_inputs": [],
|
|
3458
|
+
"relevant_paths": snapshot.get("changed_paths", [])[:24],
|
|
3459
|
+
"path_hashes": {},
|
|
3460
|
+
"signals": ["extension.pre_update_snapshot"],
|
|
3461
|
+
"status": "completed_with_warnings" if changed_count else "completed",
|
|
3462
|
+
"phase": "pre-update-snapshot",
|
|
3463
|
+
},
|
|
3464
|
+
"diagnostic_context": {
|
|
3465
|
+
"root_cause_code": "extension.pre_update_snapshot",
|
|
3466
|
+
"root_cause_label": "Snapshot pre-update da extensao",
|
|
3467
|
+
"recovery_command": "Preservar estes patches antes de rodar gemini extensions update medical-notes-workbench.",
|
|
3468
|
+
"missing_inputs": [],
|
|
3469
|
+
"decision_context": {"types": [], "decisions": []},
|
|
3470
|
+
"blocker_context": {"codes": [], "counts": {}, "summaries": [], "samples": [], "routes": []},
|
|
3471
|
+
"contract_gaps": [],
|
|
3472
|
+
},
|
|
3473
|
+
"environment_context": {
|
|
3474
|
+
"extension_integrity": {
|
|
3475
|
+
"schema": PRE_UPDATE_EXTENSION_SNAPSHOT_SCHEMA,
|
|
3476
|
+
"drift_detected": bool(changed_count),
|
|
3477
|
+
"snapshot_id": snapshot_id,
|
|
3478
|
+
"snapshot_path": str(snapshot_path),
|
|
3479
|
+
"patch_id": patch_id,
|
|
3480
|
+
"phase": patch_phase,
|
|
3481
|
+
"reason": snapshot_reason,
|
|
3482
|
+
"extension_name": str(snapshot.get("extension_name") or ""),
|
|
3483
|
+
"current_version": str(snapshot.get("current_version") or ""),
|
|
3484
|
+
"target_version": str(snapshot.get("target_version") or ""),
|
|
3485
|
+
"git_head": str(snapshot.get("git_head") or ""),
|
|
3486
|
+
"git_available": bool(snapshot.get("git_available")),
|
|
3487
|
+
"extension_path": str(snapshot.get("extension_path") or ""),
|
|
3488
|
+
"summary": {
|
|
3489
|
+
"changed_count": changed_count,
|
|
3490
|
+
"modified_count": max(snapshot_changed_path_count, len(extension_diffs)),
|
|
3491
|
+
"unexpected_count": snapshot_untracked_path_count,
|
|
3492
|
+
"snapshot_changed_path_count": snapshot_changed_path_count,
|
|
3493
|
+
"snapshot_untracked_path_count": snapshot_untracked_path_count,
|
|
3494
|
+
"snapshot_changed_count": snapshot_changed_count,
|
|
3495
|
+
"snapshot_changed_path_count_mismatch": bool(extension_diffs and not snapshot_changed_count),
|
|
3496
|
+
},
|
|
3497
|
+
"extension_diffs": extension_diffs,
|
|
3498
|
+
}
|
|
3499
|
+
},
|
|
3500
|
+
"diagnostic_snippets": [],
|
|
3501
|
+
"extension_diffs": extension_diffs,
|
|
3502
|
+
"generated_scripts": generated_scripts,
|
|
3503
|
+
}
|
|
3504
|
+
_apply_generated_script_risk_signals(record)
|
|
3505
|
+
return attach_telemetry_evidence(record, send_path="pre_update_snapshot")
|
|
3506
|
+
|
|
3507
|
+
|
|
3508
|
+
def _snapshot_extension_diffs(snapshot_path: Path) -> list[dict[str, Any]]:
|
|
3509
|
+
diffs: list[dict[str, Any]] = []
|
|
3510
|
+
for filename, change in (
|
|
3511
|
+
("tracked.diff", "pre_update_tracked"),
|
|
3512
|
+
("staged.diff", "pre_update_staged"),
|
|
3513
|
+
("untracked.diff", "pre_update_untracked"),
|
|
3514
|
+
):
|
|
3515
|
+
path = snapshot_path / filename
|
|
3516
|
+
try:
|
|
3517
|
+
patch = path.read_text(encoding="utf-8")
|
|
3518
|
+
except OSError:
|
|
3519
|
+
continue
|
|
3520
|
+
patch = _filter_pre_update_patch_noise(patch)
|
|
3521
|
+
if not patch.strip():
|
|
3522
|
+
continue
|
|
3523
|
+
sanitized = redact_operational_text(patch, max_chars=MAX_PRE_UPDATE_PATCH_CHARS)
|
|
3524
|
+
diffs.append(
|
|
3525
|
+
{
|
|
3526
|
+
"path": f"pre-update/{filename}",
|
|
3527
|
+
"kind": "pre_update_snapshot",
|
|
3528
|
+
"change": change,
|
|
3529
|
+
"patch": sanitized,
|
|
3530
|
+
"truncated": len(sanitized) < len(patch),
|
|
3531
|
+
}
|
|
3532
|
+
)
|
|
3533
|
+
return diffs
|
|
3534
|
+
|
|
3535
|
+
|
|
3536
|
+
def _filter_pre_update_patch_noise(patch: str) -> str:
|
|
3537
|
+
blocks = re.split(r"(?m)(?=^diff --git )", patch)
|
|
3538
|
+
kept: list[str] = []
|
|
3539
|
+
for block in blocks:
|
|
3540
|
+
if not block.strip():
|
|
3541
|
+
continue
|
|
3542
|
+
normalized = block.replace("\\", "/").lower()
|
|
3543
|
+
if "git binary patch" in normalized:
|
|
3544
|
+
continue
|
|
3545
|
+
if ".pyc" in normalized or any(part in normalized for part in PRE_UPDATE_PATCH_NOISE_PARTS):
|
|
3546
|
+
continue
|
|
3547
|
+
kept.append(block)
|
|
3548
|
+
return "\n".join(item.rstrip("\n") for item in kept if item.strip()) + ("\n" if kept else "")
|
|
3549
|
+
|
|
3550
|
+
|
|
3551
|
+
def _hash_text(value: str) -> str:
|
|
3552
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest() if value else ""
|
|
3553
|
+
|
|
3554
|
+
|
|
3555
|
+
def _environment_context(*, root: str | Path | None = None) -> dict[str, Any]:
|
|
3556
|
+
try:
|
|
3557
|
+
from mednotes.platform.feedback.integrity import safe_check_extension_integrity
|
|
3558
|
+
|
|
3559
|
+
return {
|
|
3560
|
+
"extension_integrity": safe_check_extension_integrity(
|
|
3561
|
+
cache_dir=feedback_root(root) / "integrity",
|
|
3562
|
+
include_diff=True,
|
|
3563
|
+
)
|
|
3564
|
+
}
|
|
3565
|
+
except Exception:
|
|
3566
|
+
return {}
|
|
3567
|
+
|
|
3568
|
+
|
|
3569
|
+
def _run_id(workflow: str) -> str:
|
|
3570
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
3571
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", workflow).strip("-").lower() or "workflow"
|
|
3572
|
+
suffix = hashlib.sha256(f"{workflow}:{time.time_ns()}".encode()).hexdigest()[:8]
|
|
3573
|
+
return f"{stamp}-{slug}-{suffix}"
|
|
3574
|
+
|
|
3575
|
+
|
|
3576
|
+
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
|
3577
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
3578
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
3579
|
+
tmp.replace(path)
|
|
3580
|
+
|
|
3581
|
+
|
|
3582
|
+
def build_backlog(*, since: str = "30d", root: str | Path | None = None) -> dict[str, Any]:
|
|
3583
|
+
records = load_records(since=since, root=root)
|
|
3584
|
+
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
3585
|
+
for record in records:
|
|
3586
|
+
summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
|
|
3587
|
+
raw_signals = summary.get("signals") if isinstance(summary.get("signals"), list) else []
|
|
3588
|
+
signals = {str(signal) for signal in raw_signals if str(signal).strip()}
|
|
3589
|
+
diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
3590
|
+
root_signal = str(diagnostic.get("root_cause_code") or "")
|
|
3591
|
+
if root_signal and root_signal != "no_issue_detected":
|
|
3592
|
+
signals.add(root_signal)
|
|
3593
|
+
for signal in signals:
|
|
3594
|
+
if signal == "dry_run":
|
|
3595
|
+
continue
|
|
3596
|
+
_append_backlog_group(grouped, record, signal)
|
|
3597
|
+
for record in _dry_runs_without_apply(records):
|
|
3598
|
+
signal = "agent.dry_run_without_apply" if record.get("source") == "agent" else "dry_run_without_apply"
|
|
3599
|
+
_append_backlog_group(grouped, record, signal)
|
|
3600
|
+
for workflow, group in _retry_loop_groups(records):
|
|
3601
|
+
for record in group:
|
|
3602
|
+
_append_backlog_group(grouped, record, "agent.retry_loop", workflow=workflow)
|
|
3603
|
+
for workflow, signal, group in _retry_without_input_change_groups(records):
|
|
3604
|
+
for record in group:
|
|
3605
|
+
_append_backlog_group(grouped, record, signal, workflow=workflow)
|
|
3606
|
+
|
|
3607
|
+
items = [_backlog_item(workflow, signal, group) for (workflow, signal, _group_key), group in grouped.items()]
|
|
3608
|
+
items.sort(key=lambda item: (-_severity_rank(item["severity"]), -item["occurrence_count"], item["workflow"], item["signal"]))
|
|
3609
|
+
return {
|
|
3610
|
+
"schema": BACKLOG_SCHEMA,
|
|
3611
|
+
"generated_at": now_iso(),
|
|
3612
|
+
"since": since,
|
|
3613
|
+
"run_count": len(records),
|
|
3614
|
+
"items": items,
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
|
|
3618
|
+
def _append_backlog_group(
|
|
3619
|
+
grouped: dict[tuple[str, str, str], list[dict[str, Any]]],
|
|
3620
|
+
record: dict[str, Any],
|
|
3621
|
+
signal: str,
|
|
3622
|
+
*,
|
|
3623
|
+
workflow: str | None = None,
|
|
3624
|
+
) -> None:
|
|
3625
|
+
workflow_value = workflow or str(record.get("workflow") or "unknown")
|
|
3626
|
+
grouping = _record_grouping_dimensions(record, signal)
|
|
3627
|
+
group_key = "|".join(
|
|
3628
|
+
str(grouping.get(key) or "")
|
|
3629
|
+
for key in ("phase", "root_cause", "target_canonical", "input_hash", "error_hash")
|
|
3630
|
+
)
|
|
3631
|
+
grouped[(workflow_value, signal, group_key)].append(record)
|
|
3632
|
+
|
|
3633
|
+
|
|
3634
|
+
def load_records(*, since: str = "30d", root: str | Path | None = None) -> list[dict[str, Any]]:
|
|
3635
|
+
cutoff = _parse_since(since)
|
|
3636
|
+
runs_dir = feedback_root(root) / "runs"
|
|
3637
|
+
records: list[dict[str, Any]] = []
|
|
3638
|
+
for path in sorted(runs_dir.glob("*.json")):
|
|
3639
|
+
try:
|
|
3640
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
3641
|
+
except (OSError, json.JSONDecodeError):
|
|
3642
|
+
continue
|
|
3643
|
+
if data.get("schema") != RUN_RECORD_SCHEMA:
|
|
3644
|
+
continue
|
|
3645
|
+
recorded_at = _parse_datetime(str(data.get("recorded_at") or ""))
|
|
3646
|
+
if recorded_at and recorded_at < cutoff:
|
|
3647
|
+
continue
|
|
3648
|
+
data.setdefault("record_path", str(path))
|
|
3649
|
+
records.append(data)
|
|
3650
|
+
return records
|
|
3651
|
+
|
|
3652
|
+
|
|
3653
|
+
def _parse_since(value: str) -> datetime:
|
|
3654
|
+
value = str(value or "30d").strip()
|
|
3655
|
+
match = re.fullmatch(r"(\d+)([dhm])", value)
|
|
3656
|
+
now = datetime.now(UTC)
|
|
3657
|
+
if match:
|
|
3658
|
+
amount = int(match.group(1))
|
|
3659
|
+
unit = match.group(2)
|
|
3660
|
+
if unit == "d":
|
|
3661
|
+
return now - timedelta(days=amount)
|
|
3662
|
+
if unit == "h":
|
|
3663
|
+
return now - timedelta(hours=amount)
|
|
3664
|
+
return now - timedelta(minutes=amount)
|
|
3665
|
+
parsed = _parse_datetime(value)
|
|
3666
|
+
return parsed or (now - timedelta(days=30))
|
|
3667
|
+
|
|
3668
|
+
|
|
3669
|
+
def _parse_datetime(value: str) -> datetime | None:
|
|
3670
|
+
if not value:
|
|
3671
|
+
return None
|
|
3672
|
+
try:
|
|
3673
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
3674
|
+
except ValueError:
|
|
3675
|
+
return None
|
|
3676
|
+
if parsed.tzinfo is None:
|
|
3677
|
+
return parsed.replace(tzinfo=UTC)
|
|
3678
|
+
return parsed.astimezone(UTC)
|
|
3679
|
+
|
|
3680
|
+
|
|
3681
|
+
def _dry_runs_without_apply(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
3682
|
+
apply_seen: set[str] = set()
|
|
3683
|
+
dry_runs: list[dict[str, Any]] = []
|
|
3684
|
+
for record in records:
|
|
3685
|
+
workflow = str(record.get("workflow") or "")
|
|
3686
|
+
if record.get("apply") is True or (record.get("dry_run") is False and record.get("exit_code") == 0):
|
|
3687
|
+
apply_seen.add(workflow)
|
|
3688
|
+
if record.get("dry_run") is True and record.get("exit_code") == 0:
|
|
3689
|
+
dry_runs.append(record)
|
|
3690
|
+
return [record for record in dry_runs if str(record.get("workflow") or "") not in apply_seen]
|
|
3691
|
+
|
|
3692
|
+
|
|
3693
|
+
def _retry_loop_groups(records: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
|
|
3694
|
+
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
3695
|
+
for record in records:
|
|
3696
|
+
status = str(record.get("status") or "")
|
|
3697
|
+
if status not in {"blocked", "failed", "error"}:
|
|
3698
|
+
continue
|
|
3699
|
+
workflow = str(record.get("workflow") or "unknown")
|
|
3700
|
+
phase = str(record.get("phase") or "unknown")
|
|
3701
|
+
diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
3702
|
+
cause = str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or status)
|
|
3703
|
+
grouped[(workflow, phase, cause)].append(record)
|
|
3704
|
+
loops: list[tuple[str, list[dict[str, Any]]]] = []
|
|
3705
|
+
for (workflow, _phase, _cause), group in grouped.items():
|
|
3706
|
+
if len(group) >= 3:
|
|
3707
|
+
loops.append((workflow, group))
|
|
3708
|
+
return loops
|
|
3709
|
+
|
|
3710
|
+
|
|
3711
|
+
def _retry_without_input_change_groups(records: list[dict[str, Any]]) -> list[tuple[str, str, list[dict[str, Any]]]]:
|
|
3712
|
+
grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
3713
|
+
for record in records:
|
|
3714
|
+
status = str(record.get("status") or "")
|
|
3715
|
+
if status not in {"blocked", "failed", "error"}:
|
|
3716
|
+
continue
|
|
3717
|
+
fingerprint = _record_input_fingerprint(record)
|
|
3718
|
+
if not fingerprint:
|
|
3719
|
+
continue
|
|
3720
|
+
workflow = str(record.get("workflow") or "unknown")
|
|
3721
|
+
phase = str(record.get("phase") or "unknown")
|
|
3722
|
+
diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
3723
|
+
cause = str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or status)
|
|
3724
|
+
grouped[(workflow, phase, cause, fingerprint)].append(record)
|
|
3725
|
+
|
|
3726
|
+
loops: list[tuple[str, str, list[dict[str, Any]]]] = []
|
|
3727
|
+
for (workflow, _phase, _cause, _fingerprint), group in grouped.items():
|
|
3728
|
+
if len(group) >= 2:
|
|
3729
|
+
signal = "agent.retry_without_input_change" if any(record.get("source") == "agent" for record in group) else "retry_without_input_change"
|
|
3730
|
+
loops.append((workflow, signal, group))
|
|
3731
|
+
return loops
|
|
3732
|
+
|
|
3733
|
+
|
|
3734
|
+
def _record_input_fingerprint(record: dict[str, Any]) -> str:
|
|
3735
|
+
summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
|
|
3736
|
+
path_hashes = summary.get("path_hashes") if isinstance(summary.get("path_hashes"), dict) else {}
|
|
3737
|
+
artifact_state = summary.get("artifact_state") if isinstance(summary.get("artifact_state"), dict) else {}
|
|
3738
|
+
components: dict[str, Any] = {}
|
|
3739
|
+
if path_hashes:
|
|
3740
|
+
components["path_hashes"] = sorted((str(key), str(value)) for key, value in path_hashes.items())
|
|
3741
|
+
if artifact_state:
|
|
3742
|
+
components["artifact_state"] = sorted((str(key), str(value)) for key, value in artifact_state.items())
|
|
3743
|
+
if not components:
|
|
3744
|
+
return ""
|
|
3745
|
+
return hashlib.sha256(json.dumps(components, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
|
|
3746
|
+
|
|
3747
|
+
|
|
3748
|
+
def _record_grouping_dimensions(record: dict[str, Any], signal: str) -> dict[str, str]:
|
|
3749
|
+
summary = record.get("payload_summary") if isinstance(record.get("payload_summary"), dict) else {}
|
|
3750
|
+
diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
3751
|
+
return {
|
|
3752
|
+
"phase": str(record.get("phase") or summary.get("phase") or "unknown"),
|
|
3753
|
+
"root_cause": str(diagnostic.get("root_cause_code") or record.get("blocked_reason") or signal or "unknown"),
|
|
3754
|
+
"target_canonical": _record_canonical_target(record, diagnostic, summary),
|
|
3755
|
+
"input_hash": _record_input_fingerprint(record)[:12],
|
|
3756
|
+
"error_hash": _record_error_fingerprint(record, diagnostic, summary)[:12],
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
|
|
3760
|
+
def _record_canonical_target(
|
|
3761
|
+
record: dict[str, Any],
|
|
3762
|
+
diagnostic: dict[str, Any],
|
|
3763
|
+
summary: dict[str, Any],
|
|
3764
|
+
) -> str:
|
|
3765
|
+
candidates: list[Any] = [
|
|
3766
|
+
record.get("canonical_target"),
|
|
3767
|
+
record.get("target_canonical"),
|
|
3768
|
+
record.get("target_key"),
|
|
3769
|
+
summary.get("canonical_target"),
|
|
3770
|
+
summary.get("target_canonical"),
|
|
3771
|
+
summary.get("target_key"),
|
|
3772
|
+
]
|
|
3773
|
+
error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
|
|
3774
|
+
candidates.append(error_context.get("affected_artifact"))
|
|
3775
|
+
decision_context = _json_object_field(_json_object_view(diagnostic), "decision_context")
|
|
3776
|
+
for decision in _json_list_field(decision_context, "decisions"):
|
|
3777
|
+
decision_view = _json_object_view(decision)
|
|
3778
|
+
if decision_view:
|
|
3779
|
+
candidates.extend(
|
|
3780
|
+
[
|
|
3781
|
+
_json_text(decision_view, "target_key"),
|
|
3782
|
+
_json_text(decision_view, "canonical_target"),
|
|
3783
|
+
_json_text(decision_view, "affected_artifact"),
|
|
3784
|
+
]
|
|
3785
|
+
)
|
|
3786
|
+
blocker_context = diagnostic.get("blocker_context") if isinstance(diagnostic.get("blocker_context"), dict) else {}
|
|
3787
|
+
for key in ("samples", "summaries", "routes"):
|
|
3788
|
+
values_candidate = blocker_context.get(key)
|
|
3789
|
+
values = values_candidate if isinstance(values_candidate, list) else []
|
|
3790
|
+
for item in values[:MAX_DIAGNOSTIC_ITEMS]:
|
|
3791
|
+
if isinstance(item, dict):
|
|
3792
|
+
candidates.extend(
|
|
3793
|
+
[
|
|
3794
|
+
item.get("target_key"),
|
|
3795
|
+
item.get("canonical_target"),
|
|
3796
|
+
item.get("target_canonical"),
|
|
3797
|
+
item.get("keep_path"),
|
|
3798
|
+
item.get("path"),
|
|
3799
|
+
]
|
|
3800
|
+
)
|
|
3801
|
+
for value in candidates:
|
|
3802
|
+
text = str(value or "").strip()
|
|
3803
|
+
if text:
|
|
3804
|
+
return redact_snippet(text, max_chars=160)
|
|
3805
|
+
return ""
|
|
3806
|
+
|
|
3807
|
+
|
|
3808
|
+
def _record_error_fingerprint(
|
|
3809
|
+
record: dict[str, Any],
|
|
3810
|
+
diagnostic: dict[str, Any],
|
|
3811
|
+
summary: dict[str, Any],
|
|
3812
|
+
) -> str:
|
|
3813
|
+
error_context = record.get("error_context") if isinstance(record.get("error_context"), dict) else {}
|
|
3814
|
+
values: list[Any] = [
|
|
3815
|
+
diagnostic.get("root_cause_code"),
|
|
3816
|
+
record.get("blocked_reason"),
|
|
3817
|
+
error_context.get("error_summary"),
|
|
3818
|
+
error_context.get("root_cause"),
|
|
3819
|
+
]
|
|
3820
|
+
if isinstance(summary.get("errors"), list):
|
|
3821
|
+
values.extend(summary["errors"][:5])
|
|
3822
|
+
if isinstance(summary.get("warnings"), list):
|
|
3823
|
+
values.extend(summary["warnings"][:5])
|
|
3824
|
+
command_events = record.get("command_events") if isinstance(record.get("command_events"), list) else []
|
|
3825
|
+
for event in command_events[:3]:
|
|
3826
|
+
if isinstance(event, dict):
|
|
3827
|
+
values.extend([event.get("error"), event.get("stderr_tail"), event.get("output_tail")])
|
|
3828
|
+
text = "\n".join(redact_snippet(value, max_chars=600) for value in values if str(value or "").strip())
|
|
3829
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest() if text else ""
|
|
3830
|
+
|
|
3831
|
+
|
|
3832
|
+
def _backlog_item(workflow: str, signal: str, records: list[dict[str, Any]]) -> dict[str, Any]:
|
|
3833
|
+
count = len(records)
|
|
3834
|
+
statuses = Counter(str(record.get("status") or "unknown") for record in records)
|
|
3835
|
+
sample_run_ids = [str(record.get("run_id")) for record in records[:5]]
|
|
3836
|
+
title, improvement_type, recommendation, suggested_test = _recommendation(signal)
|
|
3837
|
+
severity = _severity_for(signal, count, statuses)
|
|
3838
|
+
grouping = _merged_grouping_dimensions(records, signal)
|
|
3839
|
+
evidence_bits = []
|
|
3840
|
+
grouping_evidence = _format_grouping_evidence(grouping)
|
|
3841
|
+
if grouping_evidence:
|
|
3842
|
+
evidence_bits.append(grouping_evidence)
|
|
3843
|
+
blocked = Counter(str(record.get("blocked_reason") or "") for record in records if record.get("blocked_reason"))
|
|
3844
|
+
if blocked:
|
|
3845
|
+
evidence_bits.append("blocked_reason: " + ", ".join(f"{key}={value}" for key, value in blocked.most_common(3)))
|
|
3846
|
+
evidence_bits.append("status: " + ", ".join(f"{key}={value}" for key, value in statuses.most_common(4)))
|
|
3847
|
+
item = {
|
|
3848
|
+
"id": hashlib.sha256(
|
|
3849
|
+
json.dumps({"workflow": workflow, "signal": signal, "grouping": grouping}, sort_keys=True).encode("utf-8")
|
|
3850
|
+
).hexdigest()[:12],
|
|
3851
|
+
"title": title,
|
|
3852
|
+
"workflow": workflow,
|
|
3853
|
+
"signal": signal,
|
|
3854
|
+
"grouping": grouping,
|
|
3855
|
+
"occurrence_count": count,
|
|
3856
|
+
"severity": severity,
|
|
3857
|
+
"improvement_type": improvement_type,
|
|
3858
|
+
"evidence": "; ".join(evidence_bits),
|
|
3859
|
+
"recommendation": recommendation,
|
|
3860
|
+
"suggested_test": suggested_test,
|
|
3861
|
+
"sample_run_ids": sample_run_ids,
|
|
3862
|
+
}
|
|
3863
|
+
retry_governance = _group_retry_governance(records, signal)
|
|
3864
|
+
if retry_governance:
|
|
3865
|
+
item["retry_governance"] = retry_governance
|
|
3866
|
+
item["evidence"] += (
|
|
3867
|
+
f"; retry_budget: {retry_governance['category']}<={retry_governance['max_attempts']} "
|
|
3868
|
+
f"attempt(s)"
|
|
3869
|
+
)
|
|
3870
|
+
return item
|
|
3871
|
+
|
|
3872
|
+
|
|
3873
|
+
def _merged_grouping_dimensions(records: list[dict[str, Any]], signal: str) -> dict[str, str]:
|
|
3874
|
+
values = [_record_grouping_dimensions(record, signal) for record in records]
|
|
3875
|
+
grouping: dict[str, str] = {}
|
|
3876
|
+
for key in ("phase", "root_cause", "target_canonical", "input_hash", "error_hash"):
|
|
3877
|
+
counter = Counter(str(item.get(key) or "") for item in values if str(item.get(key) or ""))
|
|
3878
|
+
if not counter:
|
|
3879
|
+
grouping[key] = ""
|
|
3880
|
+
elif len(counter) == 1:
|
|
3881
|
+
grouping[key] = next(iter(counter))
|
|
3882
|
+
else:
|
|
3883
|
+
grouping[key] = "mixed:" + ",".join(f"{value}={count}" for value, count in counter.most_common(3))
|
|
3884
|
+
return grouping
|
|
3885
|
+
|
|
3886
|
+
|
|
3887
|
+
def _format_grouping_evidence(grouping: dict[str, str]) -> str:
|
|
3888
|
+
parts = [
|
|
3889
|
+
f"phase={grouping.get('phase')}" if grouping.get("phase") else "",
|
|
3890
|
+
f"root_cause={grouping.get('root_cause')}" if grouping.get("root_cause") else "",
|
|
3891
|
+
f"target={grouping.get('target_canonical')}" if grouping.get("target_canonical") else "",
|
|
3892
|
+
f"input_hash={grouping.get('input_hash')}" if grouping.get("input_hash") else "",
|
|
3893
|
+
f"error_hash={grouping.get('error_hash')}" if grouping.get("error_hash") else "",
|
|
3894
|
+
]
|
|
3895
|
+
return "group: " + "; ".join(item for item in parts if item) if any(parts) else ""
|
|
3896
|
+
|
|
3897
|
+
|
|
3898
|
+
def _group_retry_governance(records: list[dict[str, Any]], signal: str) -> dict[str, Any]:
|
|
3899
|
+
if "retry" not in signal and "dry_run_without_apply" not in signal:
|
|
3900
|
+
return {}
|
|
3901
|
+
categories = Counter()
|
|
3902
|
+
selected: dict[str, Any] = {}
|
|
3903
|
+
for record in records:
|
|
3904
|
+
diagnostic = record.get("diagnostic_context") if isinstance(record.get("diagnostic_context"), dict) else {}
|
|
3905
|
+
governance = diagnostic.get("retry_governance") if isinstance(diagnostic.get("retry_governance"), dict) else {}
|
|
3906
|
+
category = str(governance.get("category") or "")
|
|
3907
|
+
if category:
|
|
3908
|
+
categories[category] += 1
|
|
3909
|
+
if not selected:
|
|
3910
|
+
selected = governance
|
|
3911
|
+
if not selected:
|
|
3912
|
+
selected = RETRY_BUDGETS.get("dry_run" if "dry_run" in signal else "generic", {})
|
|
3913
|
+
category = "dry_run" if "dry_run" in signal else "generic"
|
|
3914
|
+
else:
|
|
3915
|
+
category = categories.most_common(1)[0][0]
|
|
3916
|
+
if selected.get("category") != category:
|
|
3917
|
+
selected = {"category": category, **RETRY_BUDGETS.get(category, {})}
|
|
3918
|
+
return {
|
|
3919
|
+
"category": category,
|
|
3920
|
+
"max_attempts": int(selected.get("max_attempts", 1)),
|
|
3921
|
+
"rule": str(selected.get("rule") or "Retry deve seguir next_action e mudar input relevante antes de repetir."),
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
|
|
3925
|
+
def _recommendation(signal: str) -> tuple[str, str, str, str]:
|
|
3926
|
+
if signal.startswith("agent."):
|
|
3927
|
+
return _agent_recommendation(signal)
|
|
3928
|
+
if signal == ENVIRONMENT_BLOCKER_CODE:
|
|
3929
|
+
return (
|
|
3930
|
+
"Ambiente Windows/path/venv bloqueando workflow",
|
|
3931
|
+
"setup/preflight",
|
|
3932
|
+
"Guiar o agente para /mednotes:setup, bootstrap/reset oficial e retry apenas apos ambiente corrigido, sem editar scripts/runbooks como workaround.",
|
|
3933
|
+
"Fixture com erro de uv/PowerShell/path Windows deve gerar environment_blocker.windows_path_or_venv e error_context estruturado.",
|
|
3934
|
+
)
|
|
3935
|
+
if signal == "canonical_merge_required":
|
|
3936
|
+
return (
|
|
3937
|
+
"Merge canônico necessário",
|
|
3938
|
+
"workflow/canonical-merge",
|
|
3939
|
+
"Fundir informação nova no alvo canônico, preservar múltiplas referências e validar coverage/proveniência antes de publicar.",
|
|
3940
|
+
"Payload com canonical_merge_required deve agrupar por alvo canônico e sugerir merge antes do publish.",
|
|
3941
|
+
)
|
|
3942
|
+
if signal == "human_decision_required.ambiguous_canonical_target":
|
|
3943
|
+
return (
|
|
3944
|
+
"Escolha de alvo canônico pendente",
|
|
3945
|
+
"workflow/canonical-merge",
|
|
3946
|
+
"Coletar escolha explícita do alvo canônico, ajustar note_plan e seguir a rota indicada sem lançar architects antes da decisão.",
|
|
3947
|
+
"Payload com alvo canônico ambíguo deve manter human_decision_packet e continue_after_choice.",
|
|
3948
|
+
)
|
|
3949
|
+
if signal == "provenance_gap":
|
|
3950
|
+
return (
|
|
3951
|
+
"Lacuna de proveniência multi-fonte",
|
|
3952
|
+
"contract/provenance",
|
|
3953
|
+
"Bloquear publish até completar coverage.sources e Fontes Consolidadas para todas as fontes novas.",
|
|
3954
|
+
"Payload com provenance_gap deve preservar error_context e não marcar raw como processado.",
|
|
3955
|
+
)
|
|
3956
|
+
if signal == "batch_state_mismatch":
|
|
3957
|
+
return (
|
|
3958
|
+
"Artefatos de lote incompatíveis",
|
|
3959
|
+
"contract/batch-state",
|
|
3960
|
+
"Regenerar coverage, manifest e dry-run a partir do note_plan atual antes de avançar.",
|
|
3961
|
+
"Artefatos com hashes divergentes devem bloquear e agrupar por input_hash.",
|
|
3962
|
+
)
|
|
3963
|
+
if signal == "missing_next_action":
|
|
3964
|
+
return (
|
|
3965
|
+
"Adicionar next_action acionavel quando houver warning, bloqueio ou falha",
|
|
3966
|
+
"mensagem/contrato",
|
|
3967
|
+
"Atualizar o payload do workflow para sempre explicar o comando seguro seguinte ou a decisao humana pendente.",
|
|
3968
|
+
"Fixture com status nao-concluido deve conter next_action nao vazio.",
|
|
3969
|
+
)
|
|
3970
|
+
if signal == "human_decision_required":
|
|
3971
|
+
return (
|
|
3972
|
+
"Reduzir ou explicitar decisoes humanas recorrentes",
|
|
3973
|
+
"UX/guardrail",
|
|
3974
|
+
"Agrupar decisoes repetidas, melhorar opcoes visiveis e identificar casos que podem virar regra deterministica segura.",
|
|
3975
|
+
"Fixture com human_decision_required deve conter human_decision_packet e resume_action.",
|
|
3976
|
+
)
|
|
3977
|
+
if signal == "dry_run_without_apply":
|
|
3978
|
+
return (
|
|
3979
|
+
"Dry-run sem continuidade detectado",
|
|
3980
|
+
"UX",
|
|
3981
|
+
"Melhorar o resumo de preview para deixar a confirmacao seguinte mais obvia e registrar quando o usuario descarta o plano.",
|
|
3982
|
+
"Fixture de dry-run deve sugerir apply/confirmacao ou descarte explicito.",
|
|
3983
|
+
)
|
|
3984
|
+
if signal == "retry_without_input_change":
|
|
3985
|
+
return (
|
|
3986
|
+
"Retry repetido sem mudanca de input",
|
|
3987
|
+
"guardrail/observabilidade",
|
|
3988
|
+
"Comparar hashes do artefato consumido e interromper repeticao quando a fase falha de novo sem coverage/manifest/note_plan alterado.",
|
|
3989
|
+
"Dois bloqueios iguais com os mesmos hashes de input devem gerar retry_without_input_change.",
|
|
3990
|
+
)
|
|
3991
|
+
if signal == "anki_model_validation_failed":
|
|
3992
|
+
return (
|
|
3993
|
+
"Modelo Anki bloqueou criacao de cards",
|
|
3994
|
+
"setup/preflight",
|
|
3995
|
+
"Antecipar validacao/provisionamento de modelos antes da etapa de formulacao ou tornar a recuperacao mais direta.",
|
|
3996
|
+
"Fixture com modelo incompleto deve bloquear antes de montar notas Anki.",
|
|
3997
|
+
)
|
|
3998
|
+
if signal.startswith("required_input:coverage_path"):
|
|
3999
|
+
return (
|
|
4000
|
+
"Coverage ausente ou incompleto bloqueou publicacao",
|
|
4001
|
+
"guardrail/docs",
|
|
4002
|
+
"Melhorar preflight e mensagem da fase anterior para garantir coverage derivado do note_plan antes do stage/publish.",
|
|
4003
|
+
"Fixture de publish sem coverage_path deve falhar antes de mutar e recomendar stage-note --coverage.",
|
|
4004
|
+
)
|
|
4005
|
+
if signal.startswith("blocked:graph_blockers"):
|
|
4006
|
+
return (
|
|
4007
|
+
"Blockers de grafo recorrentes",
|
|
4008
|
+
"guardrail",
|
|
4009
|
+
"Priorizar uma regra deterministica em fix-wiki ou um resumo melhor com amostras e rota de resolucao.",
|
|
4010
|
+
"Fixture com blocker de grafo deve gerar rota em blocker_resolution e pular linker real.",
|
|
4011
|
+
)
|
|
4012
|
+
if signal.startswith("blocked:"):
|
|
4013
|
+
reason = signal.split(":", 1)[1]
|
|
4014
|
+
return (
|
|
4015
|
+
f"Bloqueio recorrente: {reason}",
|
|
4016
|
+
"guardrail",
|
|
4017
|
+
"Verificar se o bloqueio pode ser antecipado por preflight, explicado melhor ou coberto por teste patologico.",
|
|
4018
|
+
f"Fixture deve reproduzir {reason} e confirmar status/blocked_reason/next_action.",
|
|
4019
|
+
)
|
|
4020
|
+
if signal == "warnings":
|
|
4021
|
+
return (
|
|
4022
|
+
"Warnings recorrentes nos workflows",
|
|
4023
|
+
"qualidade",
|
|
4024
|
+
"Separar warning aceitavel de warning que merece correcao automatica, doc ou teste de regressao.",
|
|
4025
|
+
"Fixture deve preservar warning esperado e evitar regressao silenciosa.",
|
|
4026
|
+
)
|
|
4027
|
+
if signal == "errors":
|
|
4028
|
+
return (
|
|
4029
|
+
"Erros recorrentes nos workflows",
|
|
4030
|
+
"bug/preflight",
|
|
4031
|
+
"Agrupar mensagens de erro e mover a falha para uma validacao mais cedo quando possivel.",
|
|
4032
|
+
"Fixture com erro conhecido deve retornar JSON/exit code contratual sem traceback.",
|
|
4033
|
+
)
|
|
4034
|
+
return (
|
|
4035
|
+
f"Padrao recorrente: {signal}",
|
|
4036
|
+
"investigacao",
|
|
4037
|
+
"Revisar os runs amostrados e transformar o padrao em ajuste de contrato, mensagem, guardrail ou teste.",
|
|
4038
|
+
"Adicionar fixture cobrindo o padrao recorrente.",
|
|
4039
|
+
)
|
|
4040
|
+
|
|
4041
|
+
|
|
4042
|
+
def _severity_for(signal: str, count: int, statuses: Counter[str]) -> str:
|
|
4043
|
+
if signal.startswith("agent."):
|
|
4044
|
+
if signal in {
|
|
4045
|
+
"agent.retry_loop",
|
|
4046
|
+
"agent.script_or_prompt_drift",
|
|
4047
|
+
"agent.unexpected_mutation",
|
|
4048
|
+
"agent.missing_error_context",
|
|
4049
|
+
"agent.missing_agent_metrics",
|
|
4050
|
+
"agent.timeout_or_max_turns",
|
|
4051
|
+
}:
|
|
4052
|
+
return "high"
|
|
4053
|
+
if statuses.get("failed") or statuses.get("error") or statuses.get("blocked"):
|
|
4054
|
+
return "high" if count >= 2 else "medium"
|
|
4055
|
+
return "medium" if count >= 2 else "low"
|
|
4056
|
+
if signal == ENVIRONMENT_BLOCKER_CODE:
|
|
4057
|
+
return "high" if statuses.get("failed") or statuses.get("blocked") or count >= 2 else "medium"
|
|
4058
|
+
if signal.startswith("blocked:") or signal == "errors" or statuses.get("failed"):
|
|
4059
|
+
return "high" if count >= 2 else "medium"
|
|
4060
|
+
if signal in {"human_decision_required", "anki_model_validation_failed"}:
|
|
4061
|
+
return "high" if count >= 3 else "medium"
|
|
4062
|
+
if signal == "retry_without_input_change":
|
|
4063
|
+
return "high" if count >= 2 else "medium"
|
|
4064
|
+
if signal == "missing_next_action":
|
|
4065
|
+
return "medium"
|
|
4066
|
+
return "medium" if count >= 3 else "low"
|
|
4067
|
+
|
|
4068
|
+
|
|
4069
|
+
def _severity_rank(severity: str) -> int:
|
|
4070
|
+
return {"high": 3, "medium": 2, "low": 1}.get(severity, 0)
|
|
4071
|
+
|
|
4072
|
+
|
|
4073
|
+
def _agent_recommendation(signal: str) -> tuple[str, str, str, str]:
|
|
4074
|
+
labels = {
|
|
4075
|
+
"agent.retry_loop": (
|
|
4076
|
+
"Loop ou retry improdutivo do agente",
|
|
4077
|
+
"agent-behavior/loop",
|
|
4078
|
+
"Identificar a fase repetida, antecipar o bloqueio e instruir o agente a parar com next_action claro.",
|
|
4079
|
+
"Fixture com 3 falhas iguais no mesmo workflow/fase deve gerar agent.retry_loop.",
|
|
4080
|
+
),
|
|
4081
|
+
"agent.script_or_prompt_drift": (
|
|
4082
|
+
"Agente alterou prompt/runbook/script local",
|
|
4083
|
+
"agent-behavior/integrity",
|
|
4084
|
+
"Comparar o drift, decidir se vira update publicado ou rollback/reinstalação da extensão.",
|
|
4085
|
+
"Fixture com source=agent e integrity drift em script/command/skill deve gerar agent.script_or_prompt_drift.",
|
|
4086
|
+
),
|
|
4087
|
+
"agent.ignored_next_action": (
|
|
4088
|
+
"Agente ignorou next_action",
|
|
4089
|
+
"agent-behavior/contract",
|
|
4090
|
+
"Reforçar o contrato de resposta para executar apenas a rota segura indicada pelo workflow.",
|
|
4091
|
+
"Payload com agent_events ignored_next_action deve aparecer no backlog e no email.",
|
|
4092
|
+
),
|
|
4093
|
+
"agent.wrong_phase": (
|
|
4094
|
+
"Agente executou fase errada",
|
|
4095
|
+
"agent-behavior/phase",
|
|
4096
|
+
"Deixar a fase permitida explícita no resumo e bloquear mutações fora da fase esperada quando possível.",
|
|
4097
|
+
"Payload com wrong_phase deve preservar fase esperada e ação de recuperação redigidas.",
|
|
4098
|
+
),
|
|
4099
|
+
"agent.unexpected_mutation": (
|
|
4100
|
+
"Agente fez mutação inesperada",
|
|
4101
|
+
"agent-behavior/safety",
|
|
4102
|
+
"Adicionar preflight/guardrail para impedir escrita fora do workflow ou da confirmação esperada.",
|
|
4103
|
+
"Payload com unexpected_mutation deve virar severidade alta.",
|
|
4104
|
+
),
|
|
4105
|
+
"agent.command_failed": (
|
|
4106
|
+
"Comando conduzido pelo agente falhou",
|
|
4107
|
+
"agent-behavior/command",
|
|
4108
|
+
"Agrupar família de comando, erro e próxima ação para transformar falha repetida em preflight ou teste.",
|
|
4109
|
+
"Payload com command_failed deve incluir command_family e snippet redigido.",
|
|
4110
|
+
),
|
|
4111
|
+
"agent.workflow_blocked": (
|
|
4112
|
+
"Agente encontrou workflow bloqueado",
|
|
4113
|
+
"agent-behavior/blocker",
|
|
4114
|
+
"Transformar bloqueios recorrentes em mensagem de parada, rota de recuperação ou correção determinística.",
|
|
4115
|
+
"Payload com workflow_blocked deve preservar blocked_reason e next_action esperado.",
|
|
4116
|
+
),
|
|
4117
|
+
"agent.missing_error_context": (
|
|
4118
|
+
"Agente bloqueou sem error_context",
|
|
4119
|
+
"agent-behavior/contract",
|
|
4120
|
+
"Exigir error_context em todo bloqueio/falha agent-driven para que o próximo retry tenha causa, artefato e escopo claros.",
|
|
4121
|
+
"Run source=agent bloqueado sem error_context deve gerar agent.missing_error_context.",
|
|
4122
|
+
),
|
|
4123
|
+
"agent.missing_agent_metrics": (
|
|
4124
|
+
"Subagente bloqueou sem agent_metrics",
|
|
4125
|
+
"agent-behavior/contract",
|
|
4126
|
+
"Exigir agent_metrics mesmo em blocked packet para separar prompt ruim, timeout real e escopo excessivo.",
|
|
4127
|
+
"Timeout/max_turns sem agent_metrics deve gerar agent.missing_agent_metrics.",
|
|
4128
|
+
),
|
|
4129
|
+
"agent.timeout_or_max_turns": (
|
|
4130
|
+
"Subagente estourou timeout ou max_turns",
|
|
4131
|
+
"agent-behavior/timeout",
|
|
4132
|
+
"Parar retry cego, reduzir o work item ou criar recuperação oficial antes de rodar outro subagente.",
|
|
4133
|
+
"Payload agentico bloqueado por timeout_or_max_turns deve gerar agent.timeout_or_max_turns.",
|
|
4134
|
+
),
|
|
4135
|
+
"agent.manual_intervention": (
|
|
4136
|
+
"Agente precisou de intervenção manual",
|
|
4137
|
+
"agent-behavior/manual",
|
|
4138
|
+
"Avaliar se a decisão pode virar default seguro, checklist preflight ou pergunta estruturada.",
|
|
4139
|
+
"Payload com manual_intervention deve registrar ação e resultado sem conteúdo sensível.",
|
|
4140
|
+
),
|
|
4141
|
+
"agent.dry_run_without_apply": (
|
|
4142
|
+
"Dry-run limpo sem apply posterior",
|
|
4143
|
+
"agent-behavior/follow-through",
|
|
4144
|
+
"Garantir que o agente execute o apply indicado ou registre explicitamente por que parou após o dry-run.",
|
|
4145
|
+
"Sequência com dry-run agentico limpo sem apply posterior deve gerar agent.dry_run_without_apply.",
|
|
4146
|
+
),
|
|
4147
|
+
"agent.retry": (
|
|
4148
|
+
"Retry do agente registrado",
|
|
4149
|
+
"agent-behavior/retry",
|
|
4150
|
+
"Distinguir retry útil de loop improdutivo e limitar repetição antes de pedir decisão humana.",
|
|
4151
|
+
"Payload com retry deve ser redigido e agrupável por fase.",
|
|
4152
|
+
),
|
|
4153
|
+
"agent.retry_without_input_change": (
|
|
4154
|
+
"Agente repetiu sem mudar input",
|
|
4155
|
+
"agent-behavior/loop",
|
|
4156
|
+
"Fazer o agente parar após a segunda falha com os mesmos hashes e seguir o error_context em vez de tentar de novo.",
|
|
4157
|
+
"Dois bloqueios agenticos iguais com mesmos hashes devem gerar agent.retry_without_input_change.",
|
|
4158
|
+
),
|
|
4159
|
+
}
|
|
4160
|
+
if signal in labels:
|
|
4161
|
+
return labels[signal]
|
|
4162
|
+
event = signal.split(".", 1)[1] if "." in signal else signal
|
|
4163
|
+
return (
|
|
4164
|
+
f"Comportamento do agente: {event}",
|
|
4165
|
+
"agent-behavior/investigacao",
|
|
4166
|
+
"Revisar os eventos do agente e transformar o padrão em contrato, guardrail ou teste.",
|
|
4167
|
+
"Adicionar fixture cobrindo agent_events para esse padrão.",
|
|
4168
|
+
)
|