marvisx-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (587) hide show
  1. core/api/__init__.py +0 -0
  2. core/api/agents/__init__.py +0 -0
  3. core/api/agents/session_health.py +59 -0
  4. core/api/agents/session_manager.py +206 -0
  5. core/api/bin/marvisx-state-hook.py +182 -0
  6. core/api/config.py +533 -0
  7. core/api/db.py +1516 -0
  8. core/api/dependencies/__init__.py +0 -0
  9. core/api/dependencies/tenant.py +34 -0
  10. core/api/main.py +1641 -0
  11. core/api/mcp/__init__.py +8 -0
  12. core/api/mcp/_adapter.py +184 -0
  13. core/api/mcp/server.py +58 -0
  14. core/api/mcp/tools/__init__.py +59 -0
  15. core/api/mcp/tools/brain.py +599 -0
  16. core/api/mcp/tools/graph.py +380 -0
  17. core/api/mcp/tools/handoffs.py +112 -0
  18. core/api/mcp/tools/ingest.py +326 -0
  19. core/api/mcp/tools/learnings.py +144 -0
  20. core/api/mcp/tools/projects.py +99 -0
  21. core/api/mcp/tools/pull_requests.py +173 -0
  22. core/api/mcp/tools/safety.py +111 -0
  23. core/api/mcp/tools/search.py +79 -0
  24. core/api/mcp/tools/tasks.py +258 -0
  25. core/api/middleware/__init__.py +0 -0
  26. core/api/middleware/tool_call_audit.py +111 -0
  27. core/api/models/__init__.py +346 -0
  28. core/api/models/auth.py +48 -0
  29. core/api/models/brain.py +1006 -0
  30. core/api/models/common.py +76 -0
  31. core/api/models/costs.py +91 -0
  32. core/api/models/graph.py +66 -0
  33. core/api/models/graph_cosmo.py +125 -0
  34. core/api/models/graph_pr_impact.py +257 -0
  35. core/api/models/graph_ux.py +141 -0
  36. core/api/models/inbox.py +230 -0
  37. core/api/models/ingest_keys.py +108 -0
  38. core/api/models/kg.py +41 -0
  39. core/api/models/llm_config.py +56 -0
  40. core/api/models/monitoring.py +234 -0
  41. core/api/models/projects.py +161 -0
  42. core/api/models/search.py +42 -0
  43. core/api/models/sessions.py +322 -0
  44. core/api/models/tasks.py +184 -0
  45. core/api/models/teams.py +63 -0
  46. core/api/models/users.py +184 -0
  47. core/api/observability/__init__.py +0 -0
  48. core/api/observability/tracing.py +92 -0
  49. core/api/paths.py +26 -0
  50. core/api/rate_limit.py +24 -0
  51. core/api/rbac.py +112 -0
  52. core/api/routers/__init__.py +0 -0
  53. core/api/routers/_adapter.py +24 -0
  54. core/api/routers/admin_pr_impact.py +230 -0
  55. core/api/routers/admin_settings.py +147 -0
  56. core/api/routers/agent.py +1079 -0
  57. core/api/routers/agent_tokens.py +276 -0
  58. core/api/routers/app_settings.py +112 -0
  59. core/api/routers/audit.py +89 -0
  60. core/api/routers/auth.py +586 -0
  61. core/api/routers/bench.py +161 -0
  62. core/api/routers/brain.py +881 -0
  63. core/api/routers/brain_directions.py +527 -0
  64. core/api/routers/ci_checks.py +140 -0
  65. core/api/routers/comments.py +273 -0
  66. core/api/routers/costs.py +148 -0
  67. core/api/routers/docs_coverage.py +217 -0
  68. core/api/routers/docs_governance.py +63 -0
  69. core/api/routers/documents.py +318 -0
  70. core/api/routers/files.py +163 -0
  71. core/api/routers/finder.py +987 -0
  72. core/api/routers/graph.py +836 -0
  73. core/api/routers/handoffs.py +156 -0
  74. core/api/routers/inbox.py +496 -0
  75. core/api/routers/ingest_api_keys.py +205 -0
  76. core/api/routers/ingest_triage.py +1227 -0
  77. core/api/routers/judge.py +306 -0
  78. core/api/routers/kg.py +336 -0
  79. core/api/routers/learnings.py +253 -0
  80. core/api/routers/llm_config.py +130 -0
  81. core/api/routers/monitoring.py +347 -0
  82. core/api/routers/notifications.py +125 -0
  83. core/api/routers/pr_impact.py +315 -0
  84. core/api/routers/projects.py +1061 -0
  85. core/api/routers/pull_requests.py +312 -0
  86. core/api/routers/push.py +67 -0
  87. core/api/routers/raci.py +228 -0
  88. core/api/routers/search.py +125 -0
  89. core/api/routers/sessions.py +3100 -0
  90. core/api/routers/settings.py +90 -0
  91. core/api/routers/share_repo.py +68 -0
  92. core/api/routers/status_updates.py +96 -0
  93. core/api/routers/tags.py +45 -0
  94. core/api/routers/tasks.py +526 -0
  95. core/api/routers/teams.py +425 -0
  96. core/api/routers/terminal.py +105 -0
  97. core/api/routers/users.py +331 -0
  98. core/api/routers/webhooks.py +330 -0
  99. core/api/runtime_settings.py +84 -0
  100. core/api/security.py +652 -0
  101. core/api/services/__init__.py +0 -0
  102. core/api/services/audit.py +58 -0
  103. core/api/services/auto_approval.py +82 -0
  104. core/api/services/brain/__init__.py +52 -0
  105. core/api/services/brain/baseline.py +230 -0
  106. core/api/services/brain/capabilities.py +75 -0
  107. core/api/services/brain/cascade_rollup.py +388 -0
  108. core/api/services/brain/compound_bridge.py +215 -0
  109. core/api/services/brain/cycle.py +1242 -0
  110. core/api/services/brain/cycle_snapshot.py +371 -0
  111. core/api/services/brain/digest_collector.py +147 -0
  112. core/api/services/brain/direction.py +421 -0
  113. core/api/services/brain/drift.py +356 -0
  114. core/api/services/brain/drift_router.py +409 -0
  115. core/api/services/brain/edge_metrics.py +79 -0
  116. core/api/services/brain/events_reader.py +222 -0
  117. core/api/services/brain/findings.py +1379 -0
  118. core/api/services/brain/findings_reader.py +1006 -0
  119. core/api/services/brain/jobs.py +733 -0
  120. core/api/services/brain/journal.py +206 -0
  121. core/api/services/brain/knowledge_forms.py +92 -0
  122. core/api/services/brain/llm/__init__.py +37 -0
  123. core/api/services/brain/llm/_runner.py +62 -0
  124. core/api/services/brain/llm/base.py +70 -0
  125. core/api/services/brain/llm/cache.py +99 -0
  126. core/api/services/brain/llm/constants.py +46 -0
  127. core/api/services/brain/llm/direction_alignment.py +289 -0
  128. core/api/services/brain/llm/factory.py +132 -0
  129. core/api/services/brain/llm/finding_reasoning.py +98 -0
  130. core/api/services/brain/llm/finding_summary.py +92 -0
  131. core/api/services/brain/llm/grounding.py +46 -0
  132. core/api/services/brain/llm/journal_polish.py +96 -0
  133. core/api/services/brain/llm/local_gateway.py +426 -0
  134. core/api/services/brain/llm/parsers.py +71 -0
  135. core/api/services/brain/llm/router_glue.py +422 -0
  136. core/api/services/brain/memory_ops.py +1677 -0
  137. core/api/services/brain/models.py +140 -0
  138. core/api/services/brain/owner_hint.py +211 -0
  139. core/api/services/brain/recap.py +307 -0
  140. core/api/services/brain/rules/__init__.py +65 -0
  141. core/api/services/brain/rules/_signals.py +205 -0
  142. core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
  143. core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
  144. core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
  145. core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
  146. core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
  147. core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
  148. core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
  149. core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
  150. core/api/services/brain/runs_reader.py +485 -0
  151. core/api/services/brain/scope.py +79 -0
  152. core/api/services/brain/sources/__init__.py +46 -0
  153. core/api/services/brain/sources/base.py +86 -0
  154. core/api/services/brain/sources/git_kg.py +393 -0
  155. core/api/services/brain/sources/handoffs.py +157 -0
  156. core/api/services/brain/sources/ingestor.py +130 -0
  157. core/api/services/brain/sources/learnings.py +121 -0
  158. core/api/services/brain/sources/pir_tasks.py +245 -0
  159. core/api/services/brain/watermarks.py +147 -0
  160. core/api/services/brain/ws_emitter.py +170 -0
  161. core/api/services/cc_tasks_reader.py +76 -0
  162. core/api/services/ci_service.py +263 -0
  163. core/api/services/claude_metrics.py +796 -0
  164. core/api/services/codex_metrics.py +364 -0
  165. core/api/services/conversation_reader.py +102 -0
  166. core/api/services/cost_service.py +243 -0
  167. core/api/services/crypto.py +147 -0
  168. core/api/services/docs_governance/__init__.py +1 -0
  169. core/api/services/docs_governance/confidence.py +230 -0
  170. core/api/services/docs_governance/config.py +87 -0
  171. core/api/services/docs_governance/enrichment.py +65 -0
  172. core/api/services/docs_governance/frontmatter_validator.py +83 -0
  173. core/api/services/docs_governance/hard_gates.py +221 -0
  174. core/api/services/docs_governance/triage_orchestrator.py +98 -0
  175. core/api/services/embedding_internal.py +395 -0
  176. core/api/services/embedding_service.py +832 -0
  177. core/api/services/event_dispatcher.py +167 -0
  178. core/api/services/events.py +70 -0
  179. core/api/services/git_ops.py +621 -0
  180. core/api/services/graph_cosmo_service.py +440 -0
  181. core/api/services/graph_ranker.py +306 -0
  182. core/api/services/graph_service.py +1589 -0
  183. core/api/services/inbox.py +800 -0
  184. core/api/services/inbox_digest.py +221 -0
  185. core/api/services/inbox_digest_deep_research.py +80 -0
  186. core/api/services/inbox_digest_jobs.py +595 -0
  187. core/api/services/inbox_gmail_sync.py +167 -0
  188. core/api/services/inbox_llm_classifier.py +906 -0
  189. core/api/services/inbox_source_identity.py +116 -0
  190. core/api/services/inbox_sources.py +456 -0
  191. core/api/services/inbox_taxonomy.py +195 -0
  192. core/api/services/inbox_tldr.py +1079 -0
  193. core/api/services/inbox_triage.py +899 -0
  194. core/api/services/ingest/__init__.py +13 -0
  195. core/api/services/ingest/api_key_auth.py +136 -0
  196. core/api/services/ingest/auto_approve.py +120 -0
  197. core/api/services/ingest/classifier.py +138 -0
  198. core/api/services/ingest/confidence.py +173 -0
  199. core/api/services/ingest/dispatch.py +88 -0
  200. core/api/services/ingest/embedding_router.py +272 -0
  201. core/api/services/ingest/events.py +33 -0
  202. core/api/services/ingest/ignore_patterns.py +79 -0
  203. core/api/services/ingest/image_probe.py +218 -0
  204. core/api/services/ingest/ingress.py +263 -0
  205. core/api/services/ingest/insert_saga.py +793 -0
  206. core/api/services/ingest/llm/__init__.py +13 -0
  207. core/api/services/ingest/llm/anthropic_haiku.py +23 -0
  208. core/api/services/ingest/llm/base.py +59 -0
  209. core/api/services/ingest/llm/byok_provider.py +130 -0
  210. core/api/services/ingest/llm/classification_context.py +301 -0
  211. core/api/services/ingest/llm/config_store.py +246 -0
  212. core/api/services/ingest/llm/factory.py +24 -0
  213. core/api/services/ingest/llm/kg_enricher.py +306 -0
  214. core/api/services/ingest/llm/local_gateway.py +821 -0
  215. core/api/services/ingest/llm/local_vllm.py +23 -0
  216. core/api/services/ingest/llm/openai_nano.py +349 -0
  217. core/api/services/ingest/lock_advisory.py +57 -0
  218. core/api/services/ingest/parser_router.py +1756 -0
  219. core/api/services/ingest/parsers/__init__.py +1 -0
  220. core/api/services/ingest/parsers/docling_parser.py +142 -0
  221. core/api/services/ingest/parsers/docparse_gateway.py +178 -0
  222. core/api/services/ingest/parsers/docx_parser.py +127 -0
  223. core/api/services/ingest/parsers/folder_unpacker.py +85 -0
  224. core/api/services/ingest/parsers/gateway_aux.py +147 -0
  225. core/api/services/ingest/parsers/image_parser.py +251 -0
  226. core/api/services/ingest/parsers/internal_markdown.py +89 -0
  227. core/api/services/ingest/parsers/ocr_gateway.py +117 -0
  228. core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
  229. core/api/services/ingest/parsers/pdf_types.py +13 -0
  230. core/api/services/ingest/parsers/transcript_parser.py +445 -0
  231. core/api/services/ingest/parsers/vision_gateway.py +186 -0
  232. core/api/services/ingest/parsers/xlsx_parser.py +91 -0
  233. core/api/services/ingest/parsers/zip_unpacker.py +126 -0
  234. core/api/services/ingest/preflight.py +393 -0
  235. core/api/services/ingest/retry_voyage.py +88 -0
  236. core/api/services/ingest/routing_policy.py +307 -0
  237. core/api/services/ingest/serializers/__init__.py +1 -0
  238. core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
  239. core/api/services/ingest/skip_log.py +74 -0
  240. core/api/services/ingest/watcher.py +637 -0
  241. core/api/services/kg/__init__.py +0 -0
  242. core/api/services/kg/audit.py +49 -0
  243. core/api/services/kg/hybrid_search.py +691 -0
  244. core/api/services/kg/lens.py +339 -0
  245. core/api/services/kg/pr_impact.py +770 -0
  246. core/api/services/kg/queries.py +152 -0
  247. core/api/services/kg/ranking.py +89 -0
  248. core/api/services/kg/rrf.py +143 -0
  249. core/api/services/kg_watcher_control.py +161 -0
  250. core/api/services/local_llm/__init__.py +19 -0
  251. core/api/services/local_llm/async_client.py +385 -0
  252. core/api/services/local_llm/client.py +173 -0
  253. core/api/services/local_llm/url_validator.py +44 -0
  254. core/api/services/metrics_collector.py +646 -0
  255. core/api/services/metrics_providers.py +65 -0
  256. core/api/services/model_registry.py +266 -0
  257. core/api/services/model_router.py +137 -0
  258. core/api/services/n8n_client.py +77 -0
  259. core/api/services/newsletter_llm_gateway.py +66 -0
  260. core/api/services/notification_service.py +134 -0
  261. core/api/services/openai_responses.py +55 -0
  262. core/api/services/opencode_metrics.py +375 -0
  263. core/api/services/opencode_sessions.py +173 -0
  264. core/api/services/pii_redactor.py +138 -0
  265. core/api/services/pr_impact_pipeline/__init__.py +21 -0
  266. core/api/services/pr_impact_pipeline/differ.py +421 -0
  267. core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
  268. core/api/services/pr_impact_pipeline/gc.py +93 -0
  269. core/api/services/pr_impact_pipeline/languages.py +192 -0
  270. core/api/services/pr_impact_pipeline/parser.py +178 -0
  271. core/api/services/pr_impact_pipeline/writer.py +394 -0
  272. core/api/services/pr_service.py +1393 -0
  273. core/api/services/project_paths.py +70 -0
  274. core/api/services/project_status_updates.py +265 -0
  275. core/api/services/providers.py +276 -0
  276. core/api/services/push_service.py +170 -0
  277. core/api/services/reminder_service.py +89 -0
  278. core/api/services/runas.py +41 -0
  279. core/api/services/salience_service.py +69 -0
  280. core/api/services/security_collector.py +281 -0
  281. core/api/services/session_catalog.py +385 -0
  282. core/api/services/session_metrics_service.py +301 -0
  283. core/api/services/session_ops.py +272 -0
  284. core/api/services/session_state.py +173 -0
  285. core/api/services/share_links.py +222 -0
  286. core/api/services/task_transitions.py +146 -0
  287. core/api/services/terminal_metrics.py +462 -0
  288. core/api/services/terminal_metrics_dump.py +203 -0
  289. core/api/services/tmux.py +1205 -0
  290. core/api/services/webhook_service.py +422 -0
  291. core/api/services/workspace_sync.py +164 -0
  292. core/api/templates/__init__.py +1 -0
  293. core/api/templates/markdown_share.py +164 -0
  294. core/api/terminal.py +1031 -0
  295. core/api/tests/__init__.py +0 -0
  296. core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
  297. core/api/tests/test_audit_permissions.py +133 -0
  298. core/api/tests/test_backfill_session_conversations.py +90 -0
  299. core/api/tests/test_backfill_working_seconds_msg.py +129 -0
  300. core/api/tests/test_claude_metrics.py +326 -0
  301. core/api/tests/test_codex_metrics.py +189 -0
  302. core/api/tests/test_finder_paths.py +74 -0
  303. core/api/tests/test_git_ops_merge.py +155 -0
  304. core/api/tests/test_learnings_check_search.py +81 -0
  305. core/api/tests/test_metrics_providers.py +133 -0
  306. core/api/tests/test_migration_087.py +164 -0
  307. core/api/tests/test_migration_088.py +94 -0
  308. core/api/tests/test_migration_089.py +116 -0
  309. core/api/tests/test_openai_responses.py +24 -0
  310. core/api/tests/test_opencode_metrics.py +740 -0
  311. core/api/tests/test_opencode_sessions.py +321 -0
  312. core/api/tests/test_pr_workflow_e2e.py +457 -0
  313. core/api/tests/test_projects_handoffs.py +31 -0
  314. core/api/tests/test_providers.py +138 -0
  315. core/api/tests/test_safety_bridge.py +347 -0
  316. core/api/tests/test_session_catalog.py +142 -0
  317. core/api/tests/test_session_conversations.py +512 -0
  318. core/api/tests/test_session_metrics_service.py +270 -0
  319. core/api/tests/test_session_resume_paths.py +548 -0
  320. core/api/tests/test_session_theme_mode_migration.py +56 -0
  321. core/api/tests/test_sessions_rbac.py +131 -0
  322. core/api/tests/test_share_edit.py +398 -0
  323. core/api/tests/test_share_repo.py +200 -0
  324. core/api/tests/test_terminal_session_manager.py +98 -0
  325. core/api/tests/test_terminal_upload.py +34 -0
  326. core/api/tests/test_tmux.py +272 -0
  327. core/api/tests/test_workspace_sync.py +186 -0
  328. core/api/tests/test_ws_ticket_in_memory.py +73 -0
  329. core/api/use_cases/__init__.py +11 -0
  330. core/api/use_cases/_context.py +89 -0
  331. core/api/use_cases/_errors.py +62 -0
  332. core/api/use_cases/_roles.py +16 -0
  333. core/api/use_cases/audit.py +171 -0
  334. core/api/use_cases/brain.py +1232 -0
  335. core/api/use_cases/costs.py +249 -0
  336. core/api/use_cases/graph.py +1153 -0
  337. core/api/use_cases/handoffs.py +506 -0
  338. core/api/use_cases/ingest_triage.py +1229 -0
  339. core/api/use_cases/learnings.py +538 -0
  340. core/api/use_cases/projects.py +705 -0
  341. core/api/use_cases/pull_requests.py +415 -0
  342. core/api/use_cases/search.py +926 -0
  343. core/api/use_cases/tasks.py +1495 -0
  344. core/api/visibility.py +141 -0
  345. core/cli/__init__.py +5 -0
  346. core/cli/_index_source.py +632 -0
  347. core/cli/_runtime_ctx.py +160 -0
  348. core/cli/_transmute.py +241 -0
  349. core/cli/marvis_doctor.py +704 -0
  350. core/cli/marvis_feedback.py +396 -0
  351. core/cli/marvis_governance.py +315 -0
  352. core/cli/marvis_hooks.py +515 -0
  353. core/cli/marvis_init.py +757 -0
  354. core/cli/marvis_mcp.py +401 -0
  355. core/cli/marvis_runtime.py +855 -0
  356. core/cli/marvis_telemetry.py +228 -0
  357. core/scripts/_drift_check.py +716 -0
  358. core/scripts/_frontmatter.py +66 -0
  359. core/scripts/_graph_writer.py +189 -0
  360. core/scripts/ast_parser.py +1553 -0
  361. core/scripts/install_hooks/__init__.py +1 -0
  362. core/scripts/install_hooks/_config.sh +109 -0
  363. core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
  364. core/scripts/install_hooks/block-db-direct-write.sh +23 -0
  365. core/scripts/install_hooks/block-push-no-task.sh +23 -0
  366. core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
  367. core/scripts/install_hooks/block-subtree-push.sh +23 -0
  368. core/scripts/install_hooks/config.json +53 -0
  369. core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
  370. core/scripts/install_hooks/enforce-worktree.sh +23 -0
  371. core/scripts/install_hooks/quality-gate.sh +170 -0
  372. core/scripts/install_hooks/safety_bridge.py +968 -0
  373. core/scripts/install_hooks/secret-scan.sh +23 -0
  374. core/scripts/migrate_spike_node_ids.py +122 -0
  375. core/scripts/populate_artifacts.py +2198 -0
  376. core/scripts/populate_cross_project.py +2457 -0
  377. core/scripts/populate_inbox_nodes.py +357 -0
  378. core/scripts/populate_pr_impact.py +267 -0
  379. core/scripts/populate_project_nodes.py +603 -0
  380. core/scripts/populate_touch_counter.py +337 -0
  381. core/scripts/reparse_failed.py +57 -0
  382. core/scripts/safety_bridge.py +968 -0
  383. core/telemetry/__init__.py +9 -0
  384. core/telemetry/client.py +405 -0
  385. core/telemetry/schema.py +122 -0
  386. core/wizard/__init__.py +65 -0
  387. core/wizard/byok_vault.py +147 -0
  388. core/wizard/defaults.py +58 -0
  389. core/wizard/state.py +117 -0
  390. core/wizard/steps.py +70 -0
  391. core/wizard/validation.py +136 -0
  392. marvisx_cli-0.1.0.dist-info/METADATA +201 -0
  393. marvisx_cli-0.1.0.dist-info/RECORD +587 -0
  394. marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
  395. marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
  396. marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
  397. marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
  398. migrations/001_initial.sql +33 -0
  399. migrations/002_tasks.sql +30 -0
  400. migrations/003_session_management.sql +7 -0
  401. migrations/004_projects_comments.sql +65 -0
  402. migrations/005_session_intelligence.sql +15 -0
  403. migrations/006_settings.sql +12 -0
  404. migrations/007_task_scoring.sql +12 -0
  405. migrations/008_cost_tracking.sql +31 -0
  406. migrations/009_session_card_metrics.sql +3 -0
  407. migrations/010_monitoring.sql +55 -0
  408. migrations/012_agent_api.sql +21 -0
  409. migrations/013_session_complete.sql +8 -0
  410. migrations/015_pull_requests.sql +43 -0
  411. migrations/015_pull_requests_down.sql +5 -0
  412. migrations/016_users_raci.sql +116 -0
  413. migrations/017_task_cost_entries.sql +87 -0
  414. migrations/018_agents.sql +73 -0
  415. migrations/018_agents_down.sql +13 -0
  416. migrations/019_review_feedback.sql +18 -0
  417. migrations/020_pr_commit_sha.sql +4 -0
  418. migrations/021_webhook_events.sql +18 -0
  419. migrations/022_devx_agent_managed.sql +11 -0
  420. migrations/022_devx_agent_managed_down.sql +6 -0
  421. migrations/023_devx_p1_gate.sql +7 -0
  422. migrations/023_devx_p1_gate_down.sql +3 -0
  423. migrations/024_chat_messages.sql +16 -0
  424. migrations/024_pr_conversation_id.sql +8 -0
  425. migrations/024_task_indexes.sql +21 -0
  426. migrations/024_task_indexes_down.sql +7 -0
  427. migrations/025_audit_log.sql +17 -0
  428. migrations/026_agent_tokens.sql +20 -0
  429. migrations/027_teams_auth_phase_b.sql +35 -0
  430. migrations/028_learnings.sql +23 -0
  431. migrations/029_team_roles.sql +14 -0
  432. migrations/030_finder_pins.sql +10 -0
  433. migrations/031_pr_deploy_status.sql +9 -0
  434. migrations/032_task_reminders.sql +7 -0
  435. migrations/033_events_retry_count.sql +6 -0
  436. migrations/033_session_owner.sql +9 -0
  437. migrations/034_notifications.sql +38 -0
  438. migrations/035_shared_links.sql +15 -0
  439. migrations/036_session_index_upgrade.sql +29 -0
  440. migrations/037_pr_approval.sql +15 -0
  441. migrations/038_pr_submitted_by.sql +6 -0
  442. migrations/039_push_subscriptions.sql +17 -0
  443. migrations/040_semantic_search.sql +16 -0
  444. migrations/041_workspaces.sql +63 -0
  445. migrations/042_oidc_providers.sql +24 -0
  446. migrations/043_ci_checks.sql +31 -0
  447. migrations/044_agent_metrics.sql +30 -0
  448. migrations/045_documents_doc_type.sql +5 -0
  449. migrations/046_salience.sql +13 -0
  450. migrations/047_seed_missing_agents.sql +6 -0
  451. migrations/048_fix_agent_paths_roles.sql +5 -0
  452. migrations/049_agent_role_and_learnings_schema.sql +3 -0
  453. migrations/050_session_provider.sql +2 -0
  454. migrations/051_session_launch_profile.sql +4 -0
  455. migrations/052_session_theme_mode.sql +2 -0
  456. migrations/052_task_kind.sql +4 -0
  457. migrations/053_inbox_items.sql +31 -0
  458. migrations/054_inbox_triage_contract.sql +30 -0
  459. migrations/055_inbox_topic_treatment.sql +12 -0
  460. migrations/056_inbox_treatment_read_save.sql +57 -0
  461. migrations/057_session_theme_mode_backfill.sql +4 -0
  462. migrations/058_inbox_item_status_lifecycle.sql +13 -0
  463. migrations/059_inbox_tldr_and_source_scores.sql +18 -0
  464. migrations/060_newsletter.sql +16 -0
  465. migrations/061_inbox_redesign.sql +69 -0
  466. migrations/062_fix_inbox_sources_backfill.sql +37 -0
  467. migrations/063_task_completion_mode.sql +23 -0
  468. migrations/064_judge_mode_setting.sql +4 -0
  469. migrations/065_knowledge_graph_spike.sql +40 -0
  470. migrations/066_digest_ranking_inputs.sql +10 -0
  471. migrations/066_kg_artifact_nodes.sql +129 -0
  472. migrations/067_inbox_digest_selections.sql +28 -0
  473. migrations/067_kg_temporal.sql +53 -0
  474. migrations/068_inbox_digest_app_settings.sql +9 -0
  475. migrations/068_kg_touch_counter.sql +52 -0
  476. migrations/069_kg_doc_types.sql +117 -0
  477. migrations/070_digest_ranking_inputs_recovery.sql +3 -0
  478. migrations/071_inbox_digest_selections_recovery.sql +3 -0
  479. migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
  480. migrations/073_kg_cross_project.sql +216 -0
  481. migrations/073_kg_cross_project_down.sql +77 -0
  482. migrations/074_kg_infra_types.sql +208 -0
  483. migrations/074_kg_infra_types_down.sql +80 -0
  484. migrations/075_kg_file_state_recovery.sql +35 -0
  485. migrations/075_kg_file_state_recovery_down.sql +5 -0
  486. migrations/076_kg_watcher_state.sql +33 -0
  487. migrations/076_kg_watcher_state_down.sql +3 -0
  488. migrations/077_kg_doc_types_extend.sql +226 -0
  489. migrations/077_kg_doc_types_extend_down.sql +80 -0
  490. migrations/078_kg_fts5.sql +102 -0
  491. migrations/078_kg_fts5_down.sql +14 -0
  492. migrations/079_kg_missing_indexes.sql +31 -0
  493. migrations/079_kg_missing_indexes_down.sql +10 -0
  494. migrations/080_kg_fts5_extended.sql +232 -0
  495. migrations/080_kg_fts5_extended_down.sql +25 -0
  496. migrations/081_kg_lens_indexes.sql +9 -0
  497. migrations/081_kg_lens_indexes_down.sql +3 -0
  498. migrations/082_kg_pins.sql +26 -0
  499. migrations/082_kg_pins_down.sql +14 -0
  500. migrations/083_kg_graph_nodes_degree.sql +20 -0
  501. migrations/083_kg_graph_nodes_degree_down.sql +15 -0
  502. migrations/084_drop_legacy_scheduler_tables.sql +58 -0
  503. migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
  504. migrations/085_kg_edge_resolves_to.sql +142 -0
  505. migrations/085_kg_edge_resolves_to_down.sql +66 -0
  506. migrations/086_project_status_updates_feed.sql +20 -0
  507. migrations/086_project_status_updates_feed_down.sql +36 -0
  508. migrations/087_session_metrics_dual.sql +50 -0
  509. migrations/087_session_metrics_dual_down.sql +21 -0
  510. migrations/088_rename_context_pct_legacy.sql +23 -0
  511. migrations/088_rename_context_pct_legacy_down.sql +8 -0
  512. migrations/089_session_metrics_equivalent_cost.sql +26 -0
  513. migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
  514. migrations/090_kg_inbox_node_type.sql +26 -0
  515. migrations/090_kg_inbox_node_type_down.sql +20 -0
  516. migrations/091_kg_inbox_node_type_check.sql +265 -0
  517. migrations/091_kg_inbox_node_type_check_down.sql +129 -0
  518. migrations/092_sessions_activity_state_ts.sql +29 -0
  519. migrations/092_sessions_activity_state_ts_down.sql +14 -0
  520. migrations/093_sessions_activity_state_column.sql +29 -0
  521. migrations/093_sessions_activity_state_column_down.sql +10 -0
  522. migrations/094_ingest_pending.sql +55 -0
  523. migrations/094_ingest_pending_down.sql +15 -0
  524. migrations/095_kg_intent_first.sql +77 -0
  525. migrations/095_kg_intent_first_down.sql +25 -0
  526. migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
  527. migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
  528. migrations/097_ingest_change_history.sql +37 -0
  529. migrations/097_ingest_change_history_down.sql +13 -0
  530. migrations/098_kg_node_type_business.sql +254 -0
  531. migrations/098_kg_node_type_business_down.sql +195 -0
  532. migrations/099_kg_edges_restore_weight.sql +58 -0
  533. migrations/099_kg_edges_restore_weight_down.sql +12 -0
  534. migrations/100_kg_enriched_at.sql +25 -0
  535. migrations/100_kg_enriched_at_down.sql +12 -0
  536. migrations/101_local_llm_shadow_comparisons.sql +66 -0
  537. migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
  538. migrations/102_promote_llm_costs.sql +69 -0
  539. migrations/102_promote_llm_costs_down.sql +19 -0
  540. migrations/103_ingest_skipped_log.sql +46 -0
  541. migrations/103_ingest_skipped_log_down.sql +15 -0
  542. migrations/120_docs_governance.sql +50 -0
  543. migrations/120_docs_governance_down.sql +11 -0
  544. migrations/121_notification_event_fk_cleanup.sql +21 -0
  545. migrations/121_notification_event_fk_cleanup_down.sql +10 -0
  546. migrations/122_docs_drift_history.sql +34 -0
  547. migrations/122_docs_drift_history_down.sql +15 -0
  548. migrations/123_ingest_parser_waiting_status.sql +69 -0
  549. migrations/123_ingest_parser_waiting_status_down.sql +69 -0
  550. migrations/124_heypocket_recordings.sql +63 -0
  551. migrations/124_heypocket_recordings_down.sql +13 -0
  552. migrations/125_kg_node_type_record.sql +219 -0
  553. migrations/125_kg_node_type_record_down.sql +205 -0
  554. migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
  555. migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
  556. migrations/127_brain_v1_substrate.sql +200 -0
  557. migrations/127_brain_v1_substrate_down.sql +32 -0
  558. migrations/128_brain_drift_signals.sql +157 -0
  559. migrations/128_brain_drift_signals_down.sql +23 -0
  560. migrations/129_brain_memory_operations.sql +232 -0
  561. migrations/129_brain_memory_operations_down.sql +27 -0
  562. migrations/130_brain_findings.sql +258 -0
  563. migrations/130_brain_findings_down.sql +29 -0
  564. migrations/132_kg_pr_modifies.sql +242 -0
  565. migrations/132_kg_pr_modifies_down.sql +99 -0
  566. migrations/133_brain_v1_2_direction_schema.sql +476 -0
  567. migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
  568. migrations/134_brain_journal_narrative_polished.sql +8 -0
  569. migrations/134_brain_journal_narrative_polished_down.sql +6 -0
  570. migrations/135_kg_edges_provider.sql +21 -0
  571. migrations/136_documents_fts.sql +56 -0
  572. migrations/137_promote_llm_costs.sql +59 -0
  573. migrations/137_promote_llm_costs_down.sql +19 -0
  574. migrations/138_ingest_api_keys.sql +39 -0
  575. migrations/138_ingest_api_keys_down.sql +8 -0
  576. migrations/139_ingest_pending_ingress.sql +91 -0
  577. migrations/139_ingest_pending_ingress_down.sql +73 -0
  578. migrations/140_ingest_idempotency_quota.sql +45 -0
  579. migrations/140_ingest_idempotency_quota_down.sql +9 -0
  580. migrations/141_ingest_pending_metadata.sql +16 -0
  581. migrations/141_ingest_pending_metadata_down.sql +7 -0
  582. migrations/142_llm_function_config.sql +36 -0
  583. migrations/142_llm_function_config_down.sql +8 -0
  584. migrations/143_kg_code_embeddings.sql +25 -0
  585. migrations/143_kg_code_embeddings_down.sql +5 -0
  586. migrations/__init__.py +4 -0
  587. projects/_template/project.yaml +46 -0
@@ -0,0 +1,1379 @@
1
+ # Brain v1 — Learn Findings service (sub-04 L5 — §4 / §5 / §10).
2
+ #
3
+ # Findings is the FINAL phase of brain_runs (after Memory-Ops). The
4
+ # orchestrator:
5
+ # 1. Builds a FindingSnapshot (read-only projection — digest events +
6
+ # journal entries + drift signals + memory operations of the current
7
+ # run_id).
8
+ # 2. Invokes each F-rule (F1-F6) with a 15s timeout; per-rule failures
9
+ # isolated and reported via partial_failures_json.
10
+ # 3. Persists findings via INSERT OR IGNORE (BLAKE2b stable id).
11
+ # 4. Updates supersede chain across prior open findings sharing the same
12
+ # proposal_fingerprint AND bumps recurrence_count on continuing rows.
13
+ #
14
+ # Layering invariants (parent §9, sub-04 §7 / §10.Z):
15
+ # * NO LLM imports (parent §9.3). AST-grep test enforces.
16
+ # * NO raw SQL on substrate (tasks/PR/handoffs/learnings/kg_edges).
17
+ # * NO mutation of substrate from this module.
18
+ # * Findings NEVER re-reads substrate — only L2 events + journal entries
19
+ # + L3 drift signals + L4 memory operations scoped to the current
20
+ # run_id (sub-04 §10.Y — same cycle envelope).
21
+ # * Stable BLAKE2b 16-byte finding_id. EXCLUDES: severity, confidence,
22
+ # summary, title, approval_state, owner_hint, suggested_artifact
23
+ # (sub-04 §7.2).
24
+ # * confidence is a CATEGORICAL TIER (low|medium|high), NEVER float.
25
+ # * Apply guidance ONLY — no auto-write to substrate (sub-04 F1 binding).
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import hashlib
30
+ import json
31
+ import logging
32
+ import uuid
33
+ from collections import defaultdict
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timedelta, timezone
36
+ from typing import Any, Iterable
37
+
38
+ import aiosqlite
39
+
40
+ from core.api.db import acquire_db, write_db
41
+ from core.api.models.brain import (
42
+ ArtifactSelector,
43
+ ClosureArtifactExists,
44
+ ClosureCondition,
45
+ ClosureDriftSignalClears,
46
+ ClosureManualAttest,
47
+ ClosureMemoryOpApplied,
48
+ ConfidenceTier,
49
+ Finding,
50
+ FindingApprovalState,
51
+ FindingType,
52
+ OwnerHint,
53
+ ScopeTypeL4,
54
+ Severity,
55
+ SuggestedArtifact,
56
+ )
57
+ from core.api.services.brain.owner_hint import compute_owner_hint
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+ DEFAULT_RULE_TIMEOUT_S = 15
62
+ DEFAULT_OPEN_TTL_DAYS = 60
63
+ PER_TYPE_CAP_DEFAULT = 100
64
+
65
+ _SEVERITY_RANK_INT: dict[Severity, int] = {
66
+ "low": 1,
67
+ "medium": 2,
68
+ "high": 3,
69
+ "critical": 4,
70
+ }
71
+
72
+ _CONFIDENCE_RANK_INT: dict[ConfidenceTier, int] = {
73
+ "low": 1,
74
+ "medium": 2,
75
+ "high": 3,
76
+ }
77
+
78
+
79
+ def _utc_iso(dt: datetime) -> str:
80
+ return dt.astimezone(timezone.utc).isoformat()
81
+
82
+
83
+ def _parse_iso(value: str | None) -> datetime | None:
84
+ if value is None:
85
+ return None
86
+ try:
87
+ dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
88
+ except ValueError:
89
+ return None
90
+ if dt.tzinfo is None:
91
+ dt = dt.replace(tzinfo=timezone.utc)
92
+ return dt
93
+
94
+
95
+ def _canonical_evidence(evidence: Iterable[str]) -> str:
96
+ """Stable JSON for hash. Mirror sub-02 / sub-03 helper (intentionally
97
+ duplicated to avoid importing a private helper across layers)."""
98
+ norm = sorted(str(item) for item in evidence)
99
+ return json.dumps(norm, sort_keys=False, ensure_ascii=False, separators=(",", ":"))
100
+
101
+
102
+ def evidence_hash(evidence: Iterable[str]) -> str:
103
+ """sha256 64-char hex per sub-04 §7 contract."""
104
+ return hashlib.sha256(_canonical_evidence(evidence).encode("utf-8")).hexdigest()
105
+
106
+
107
+ def _closure_condition_hash(closure: ClosureCondition) -> str:
108
+ """BLAKE2b-8 of canonical JSON serialization (kind + sorted params).
109
+
110
+ Used as a salt in the finding_id payload so two findings with identical
111
+ natural keys but different closure conditions get distinct stable IDs.
112
+ """
113
+ if hasattr(closure, "model_dump"):
114
+ payload = closure.model_dump(mode="json")
115
+ else:
116
+ payload = closure
117
+ return hashlib.blake2b(
118
+ json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8"),
119
+ digest_size=8,
120
+ ).hexdigest()
121
+
122
+
123
+ def make_finding_id(
124
+ *,
125
+ cycle_key: str,
126
+ finding_type: FindingType,
127
+ scope_type: ScopeTypeL4,
128
+ scope_key: str,
129
+ evidence_source_refs_sorted: list[str],
130
+ closure_condition_hash_hex: str,
131
+ ) -> str:
132
+ """Stable BLAKE2b-16 hex (sub-04 §7.2).
133
+
134
+ EXCLUDES: severity, confidence, summary, title, approval_state,
135
+ owner_hint, suggested_artifact. Recompute idempotent across interpreter
136
+ runs given identical natural-key inputs.
137
+ """
138
+ payload = (
139
+ f"{cycle_key}|{finding_type}|{scope_type}|{scope_key}|"
140
+ f"{'|'.join(evidence_source_refs_sorted)}|{closure_condition_hash_hex}"
141
+ ).encode("utf-8")
142
+ return hashlib.blake2b(payload, digest_size=16).hexdigest()
143
+
144
+
145
+ def make_proposal_fingerprint(
146
+ *,
147
+ finding_type: FindingType,
148
+ scope_type: ScopeTypeL4,
149
+ scope_key: str,
150
+ evidence_source_refs_sorted: list[str],
151
+ closure_condition_hash_hex: str,
152
+ ) -> str:
153
+ """BLAKE2b-16 hex without cycle_key — groups same finding across cycles."""
154
+ payload = (
155
+ f"{finding_type}|{scope_type}|{scope_key}|"
156
+ f"{'|'.join(evidence_source_refs_sorted)}|{closure_condition_hash_hex}"
157
+ ).encode("utf-8")
158
+ return hashlib.blake2b(payload, digest_size=16).hexdigest()
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Snapshot (read-only projection consumed by all F-rules)
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ @dataclass(slots=True, frozen=True)
167
+ class DigestEventRow:
168
+ event_id: str
169
+ cycle_key: str
170
+ event_type: str
171
+ source_system: str
172
+ source_project: str | None
173
+ target_project: str | None
174
+ program_key: str | None
175
+ source_ref: str
176
+ title: str
177
+ summary: str
178
+ observed_at: datetime
179
+ evidence: dict[str, Any]
180
+
181
+
182
+ @dataclass(slots=True, frozen=True)
183
+ class JournalEntryRow:
184
+ entry_id: str
185
+ cycle_key: str
186
+ scope_type: str
187
+ scope_key: str
188
+ program_key: str | None
189
+ body: dict[str, Any]
190
+ is_empty: bool
191
+ published_at: datetime
192
+
193
+
194
+ @dataclass(slots=True, frozen=True)
195
+ class DriftSignalRow:
196
+ signal_id: str
197
+ cycle_key: str
198
+ rule_id: str
199
+ signal_type: str
200
+ knowledge_form: str
201
+ scope_type: str
202
+ scope_key: str
203
+ program_key: str | None
204
+ observed_direction_ref: str
205
+ severity: str
206
+ confidence: float
207
+ recurrence_key: str
208
+ involved_projects: list[str]
209
+
210
+
211
+ @dataclass(slots=True, frozen=True)
212
+ class MemoryOpRow:
213
+ operation_id: str
214
+ cycle_key: str
215
+ operation_type: str
216
+ scope_type: str
217
+ scope_key: str
218
+ program_key: str | None
219
+ source_ref: str
220
+ target_ref: str
221
+ approval_state: str
222
+ score: float
223
+ recurrence_count: int
224
+ involved_projects: list[str]
225
+
226
+
227
+ @dataclass(slots=True, frozen=True)
228
+ class FindingSnapshot:
229
+ """Read-only L2/L3/L4 projection consumed by all F-rules."""
230
+
231
+ cycle_key: str
232
+ run_id: str
233
+ workspace_id: str
234
+ as_of: datetime
235
+ events: tuple[DigestEventRow, ...]
236
+ journal_entries: tuple[JournalEntryRow, ...]
237
+ drift_signals: tuple[DriftSignalRow, ...]
238
+ memory_ops: tuple[MemoryOpRow, ...]
239
+
240
+
241
+ @dataclass(slots=True)
242
+ class FindingsReport:
243
+ """Return envelope for findings.run_phase()."""
244
+
245
+ run_id: str
246
+ cycle_key: str
247
+ finding_count: int = 0
248
+ partial_failures: list[dict[str, str]] = field(default_factory=list)
249
+
250
+
251
+ def _parse_json_dict(raw: str | None) -> dict[str, Any]:
252
+ if not raw:
253
+ return {}
254
+ try:
255
+ decoded = json.loads(raw)
256
+ except (TypeError, json.JSONDecodeError):
257
+ return {}
258
+ return decoded if isinstance(decoded, dict) else {}
259
+
260
+
261
+ def _parse_json_list(raw: str | None) -> list[Any]:
262
+ if not raw:
263
+ return []
264
+ try:
265
+ decoded = json.loads(raw)
266
+ except (TypeError, json.JSONDecodeError):
267
+ return []
268
+ return decoded if isinstance(decoded, list) else []
269
+
270
+
271
+ async def build_snapshot(
272
+ *,
273
+ run_id: str,
274
+ cycle_key: str,
275
+ workspace_id: str = "ws_default",
276
+ as_of: datetime | None = None,
277
+ ) -> FindingSnapshot:
278
+ """Read-only projection: digest events + journal entries + drift signals
279
+ + memory operations for the current run_id. Findings NEVER re-reads
280
+ substrate (sub-04 §10.Y / §10.Z invariant 4).
281
+ """
282
+ now = as_of or datetime.now(timezone.utc)
283
+ async with acquire_db() as db:
284
+ db.row_factory = aiosqlite.Row
285
+ ev_rows = await (
286
+ await db.execute(
287
+ "SELECT event_id, cycle_key, event_type, source_system, source_project,"
288
+ " target_project, program_key, source_ref, title, summary, observed_at,"
289
+ " evidence_json FROM brain_digest_events WHERE run_id = ?",
290
+ (run_id,),
291
+ )
292
+ ).fetchall()
293
+ jn_rows = await (
294
+ await db.execute(
295
+ "SELECT entry_id, cycle_key, scope_type, scope_key, program_key,"
296
+ " body_json, is_empty, published_at"
297
+ " FROM brain_journal_entries WHERE run_id = ?",
298
+ (run_id,),
299
+ )
300
+ ).fetchall()
301
+ dr_rows = await (
302
+ await db.execute(
303
+ "SELECT signal_id, cycle_key, rule_id, signal_type, knowledge_form,"
304
+ " scope_type, scope_key, program_key, observed_direction_ref,"
305
+ " severity, confidence, recurrence_key, involved_projects_json"
306
+ " FROM brain_drift_signals WHERE run_id = ?",
307
+ (run_id,),
308
+ )
309
+ ).fetchall()
310
+ mo_rows = await (
311
+ await db.execute(
312
+ "SELECT operation_id, cycle_key, operation_type, scope_type, scope_key,"
313
+ " program_key, source_ref, target_ref, approval_state, score,"
314
+ " recurrence_count, involved_projects_json"
315
+ " FROM brain_memory_operations WHERE run_id = ?",
316
+ (run_id,),
317
+ )
318
+ ).fetchall()
319
+
320
+ events = tuple(
321
+ DigestEventRow(
322
+ event_id=r["event_id"],
323
+ cycle_key=r["cycle_key"],
324
+ event_type=r["event_type"],
325
+ source_system=r["source_system"],
326
+ source_project=r["source_project"],
327
+ target_project=r["target_project"],
328
+ program_key=r["program_key"],
329
+ source_ref=r["source_ref"],
330
+ title=r["title"] or "",
331
+ summary=r["summary"] or "",
332
+ observed_at=_parse_iso(r["observed_at"]) or now,
333
+ evidence=_parse_json_dict(r["evidence_json"]),
334
+ )
335
+ for r in ev_rows
336
+ )
337
+ journals = tuple(
338
+ JournalEntryRow(
339
+ entry_id=r["entry_id"],
340
+ cycle_key=r["cycle_key"],
341
+ scope_type=r["scope_type"],
342
+ scope_key=r["scope_key"],
343
+ program_key=r["program_key"],
344
+ body=_parse_json_dict(r["body_json"]),
345
+ is_empty=bool(r["is_empty"]),
346
+ published_at=_parse_iso(r["published_at"]) or now,
347
+ )
348
+ for r in jn_rows
349
+ )
350
+ signals = tuple(
351
+ DriftSignalRow(
352
+ signal_id=r["signal_id"],
353
+ cycle_key=r["cycle_key"],
354
+ rule_id=r["rule_id"],
355
+ signal_type=r["signal_type"],
356
+ knowledge_form=r["knowledge_form"],
357
+ scope_type=r["scope_type"],
358
+ scope_key=r["scope_key"],
359
+ program_key=r["program_key"],
360
+ observed_direction_ref=r["observed_direction_ref"],
361
+ severity=r["severity"],
362
+ confidence=float(r["confidence"] or 0.0),
363
+ recurrence_key=r["recurrence_key"],
364
+ involved_projects=[
365
+ str(p) for p in _parse_json_list(r["involved_projects_json"])
366
+ ],
367
+ )
368
+ for r in dr_rows
369
+ )
370
+ memory_ops = tuple(
371
+ MemoryOpRow(
372
+ operation_id=r["operation_id"],
373
+ cycle_key=r["cycle_key"],
374
+ operation_type=r["operation_type"],
375
+ scope_type=r["scope_type"],
376
+ scope_key=r["scope_key"],
377
+ program_key=r["program_key"],
378
+ source_ref=r["source_ref"],
379
+ target_ref=r["target_ref"] or "",
380
+ approval_state=r["approval_state"],
381
+ score=float(r["score"] or 0.0),
382
+ recurrence_count=int(r["recurrence_count"] or 1),
383
+ involved_projects=[
384
+ str(p) for p in _parse_json_list(r["involved_projects_json"])
385
+ ],
386
+ )
387
+ for r in mo_rows
388
+ )
389
+
390
+ return FindingSnapshot(
391
+ cycle_key=cycle_key,
392
+ run_id=run_id,
393
+ workspace_id=workspace_id,
394
+ as_of=now,
395
+ events=events,
396
+ journal_entries=journals,
397
+ drift_signals=signals,
398
+ memory_ops=memory_ops,
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Builder
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ @dataclass(slots=True)
408
+ class FindingDraft:
409
+ """Raw rule output before stable-id derivation + persistence."""
410
+
411
+ finding_type: FindingType
412
+ scope_type: ScopeTypeL4
413
+ scope_key: str
414
+ program_key: str | None
415
+ title: str
416
+ summary: str
417
+ why_now: str
418
+ evidence: list[str]
419
+ suggested_artifact: SuggestedArtifact
420
+ closure_condition: ClosureCondition
421
+ severity: Severity
422
+ confidence: ConfidenceTier
423
+ involved_projects: list[str] = field(default_factory=list)
424
+ owner_hint: OwnerHint | None = None
425
+ closure_condition_human: str | None = None
426
+
427
+
428
+ def _derive_confidence(
429
+ *,
430
+ drift_refs: int,
431
+ memory_op_refs: int,
432
+ recurrence_count: int,
433
+ ) -> ConfidenceTier:
434
+ """Sub-04 §4 / §7.4 prose:
435
+ High: deterministic evidence (drift + memory op) + recurrence >= 3.
436
+ Medium: deterministic drift OR memory op + single-cycle evidence.
437
+ Low: weak/noisy source (no drift, no memory op).
438
+
439
+ Drift signals and memory ops are BOTH "deterministic" evidence sources
440
+ (both are produced by deterministic rules upstream of Findings). Recurrence
441
+ over 3 cycles bumps to high regardless of which path supplied the signal.
442
+ """
443
+ deterministic = drift_refs + memory_op_refs
444
+ if deterministic >= 1 and recurrence_count >= 3:
445
+ return "high"
446
+ if deterministic >= 1:
447
+ return "medium"
448
+ return "low"
449
+
450
+
451
+ async def finalize_finding(
452
+ *,
453
+ draft: FindingDraft,
454
+ run_id: str,
455
+ cycle_key: str,
456
+ now: datetime,
457
+ recurrence_count: int = 1,
458
+ first_seen_cycle_key: str | None = None,
459
+ open_ttl_days: int = DEFAULT_OPEN_TTL_DAYS,
460
+ db: aiosqlite.Connection | None = None,
461
+ ) -> Finding:
462
+ """Derive id/hash/owner_hint and construct a Finding Pydantic instance.
463
+
464
+ `db` is optional — if supplied, the owner_hint lookup reuses the
465
+ existing connection; otherwise a short-lived read pool connection is
466
+ acquired. Owner hint failure degrades to None (sub-04 §7.5).
467
+ """
468
+ evidence_sorted = sorted(set(draft.evidence))
469
+ ev_hash = evidence_hash(evidence_sorted)
470
+ cc_hash = _closure_condition_hash(draft.closure_condition)
471
+ finding_id = make_finding_id(
472
+ cycle_key=cycle_key,
473
+ finding_type=draft.finding_type,
474
+ scope_type=draft.scope_type,
475
+ scope_key=draft.scope_key,
476
+ evidence_source_refs_sorted=evidence_sorted,
477
+ closure_condition_hash_hex=cc_hash,
478
+ )
479
+ proposal_fp = make_proposal_fingerprint(
480
+ finding_type=draft.finding_type,
481
+ scope_type=draft.scope_type,
482
+ scope_key=draft.scope_key,
483
+ evidence_source_refs_sorted=evidence_sorted,
484
+ closure_condition_hash_hex=cc_hash,
485
+ )
486
+
487
+ owner_hint = draft.owner_hint
488
+ if owner_hint is None:
489
+ try:
490
+ owner_hint = await compute_owner_hint(
491
+ scope_type=draft.scope_type, scope_key=draft.scope_key, db=db
492
+ )
493
+ except Exception:
494
+ owner_hint = None
495
+
496
+ return Finding(
497
+ finding_id=finding_id,
498
+ run_id=run_id,
499
+ cycle_key=cycle_key,
500
+ detected_at=now.astimezone(timezone.utc),
501
+ finding_type=draft.finding_type,
502
+ schema_version=1,
503
+ scope_type=draft.scope_type,
504
+ scope_key=draft.scope_key,
505
+ program_key=draft.program_key,
506
+ title=draft.title[:200],
507
+ summary=draft.summary[:2000],
508
+ why_now=draft.why_now[:500],
509
+ evidence=evidence_sorted,
510
+ evidence_hash=ev_hash,
511
+ involved_projects=sorted(set(draft.involved_projects)),
512
+ suggested_artifact=draft.suggested_artifact,
513
+ owner_hint=owner_hint,
514
+ closure_condition=draft.closure_condition,
515
+ closure_condition_human=draft.closure_condition_human,
516
+ severity=draft.severity,
517
+ confidence=draft.confidence,
518
+ approval_state="open",
519
+ regression_of_finding_id=None,
520
+ proposal_fingerprint=proposal_fp,
521
+ recurrence_count=recurrence_count,
522
+ first_seen_cycle_key=first_seen_cycle_key or cycle_key,
523
+ last_seen_cycle_key=cycle_key,
524
+ expires_at=(now + timedelta(days=open_ttl_days)).astimezone(timezone.utc),
525
+ )
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # Rule builders — F1-F6 (sub-04 §3 mapping rules)
530
+ # ---------------------------------------------------------------------------
531
+ #
532
+ # Each rule consumes ONLY the snapshot. Drift signals are the dominant
533
+ # producer (5/6 mappings); memory operations contribute through F6
534
+ # promotion_candidate. F-rules NEVER touch substrate (parent §9 invariant).
535
+
536
+
537
+ def _project_scope(slug: str | None) -> tuple[ScopeTypeL4, str]:
538
+ return ("project", slug) if slug else ("company", "__company__")
539
+
540
+
541
+ def _coerce_scope_type(value: str) -> ScopeTypeL4:
542
+ if value in ("company", "program", "project", "artifact"):
543
+ return value # type: ignore[return-value]
544
+ return "company"
545
+
546
+
547
+ def _drift_signal_evidence(signal: DriftSignalRow) -> list[str]:
548
+ return [f"drift_signal:{signal.signal_id}"]
549
+
550
+
551
+ def _memory_op_evidence(op: MemoryOpRow) -> list[str]:
552
+ return [f"memory_op:{op.operation_id}"]
553
+
554
+
555
+ def _truncate(s: str, *, limit: int) -> str:
556
+ s = (s or "").strip()
557
+ if len(s) <= limit:
558
+ return s
559
+ return s[: limit - 1] + "…"
560
+
561
+
562
+ async def _f1_decision_without_adr(
563
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
564
+ ) -> list[FindingDraft]:
565
+ """DR2 decision_without_adr → open_question OR task_candidate (sub-04 §3)."""
566
+ drafts: list[FindingDraft] = []
567
+ for sig in snapshot.drift_signals:
568
+ if sig.signal_type != "decision_without_adr":
569
+ continue
570
+ scope_type = _coerce_scope_type(sig.scope_type)
571
+ scope_key = sig.scope_key if scope_type != "company" else "__company__"
572
+ evidence = _drift_signal_evidence(sig)
573
+ severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
574
+ confidence = _derive_confidence(drift_refs=1, memory_op_refs=0, recurrence_count=1)
575
+ is_task = sig.severity in ("high", "critical")
576
+ finding_type: FindingType = "task_candidate" if is_task else "open_question"
577
+ suggested: SuggestedArtifact = "adr"
578
+ title = _truncate(
579
+ f"Decision without ADR on {sig.observed_direction_ref}",
580
+ limit=200,
581
+ )
582
+ summary = _truncate(
583
+ f"DR2 {sig.signal_id}: scope={scope_type}/{scope_key} flags "
584
+ f"{sig.observed_direction_ref} as decision_without_adr "
585
+ f"(knowledge_form={sig.knowledge_form}).",
586
+ limit=2000,
587
+ )
588
+ why_now = _truncate(
589
+ f"Drift rule DR2 fired this cycle for {sig.observed_direction_ref}.",
590
+ limit=500,
591
+ )
592
+ closure: ClosureCondition = ClosureArtifactExists(
593
+ artifact_kind="adr",
594
+ selector=ArtifactSelector(
595
+ tag_match="brain_finding:{finding_id}",
596
+ ),
597
+ )
598
+ drafts.append(
599
+ FindingDraft(
600
+ finding_type=finding_type,
601
+ scope_type=scope_type,
602
+ scope_key=scope_key,
603
+ program_key=sig.program_key,
604
+ title=title,
605
+ summary=summary,
606
+ why_now=why_now,
607
+ evidence=evidence,
608
+ suggested_artifact=suggested,
609
+ closure_condition=closure,
610
+ severity=severity,
611
+ confidence=confidence,
612
+ involved_projects=list(sig.involved_projects),
613
+ )
614
+ )
615
+ return drafts
616
+
617
+
618
+ async def _f2_playbook_changed(
619
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
620
+ ) -> list[FindingDraft]:
621
+ """playbook_changed drift → procedure_change finding (sub-04 §3)."""
622
+ drafts: list[FindingDraft] = []
623
+ for sig in snapshot.drift_signals:
624
+ if sig.signal_type != "playbook_changed":
625
+ continue
626
+ scope_type = _coerce_scope_type(sig.scope_type)
627
+ scope_key = sig.scope_key if scope_type != "company" else "__company__"
628
+ severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
629
+ evidence = _drift_signal_evidence(sig)
630
+ title = _truncate(
631
+ f"Playbook change observed for {sig.observed_direction_ref}",
632
+ limit=200,
633
+ )
634
+ summary = _truncate(
635
+ f"DR3 / playbook_changed: {sig.observed_direction_ref} "
636
+ f"(knowledge_form={sig.knowledge_form}) changed without a guide update.",
637
+ limit=2000,
638
+ )
639
+ why_now = _truncate(
640
+ f"Drift signal {sig.signal_id} flags playbook drift this cycle.",
641
+ limit=500,
642
+ )
643
+ closure: ClosureCondition = ClosureArtifactExists(
644
+ artifact_kind="guide",
645
+ selector=ArtifactSelector(
646
+ tag_match="brain_finding:{finding_id}",
647
+ ),
648
+ )
649
+ drafts.append(
650
+ FindingDraft(
651
+ finding_type="procedure_change",
652
+ scope_type=scope_type,
653
+ scope_key=scope_key,
654
+ program_key=sig.program_key,
655
+ title=title,
656
+ summary=summary,
657
+ why_now=why_now,
658
+ evidence=evidence,
659
+ suggested_artifact="guide",
660
+ closure_condition=closure,
661
+ severity=severity,
662
+ confidence=_derive_confidence(
663
+ drift_refs=1, memory_op_refs=0, recurrence_count=1
664
+ ),
665
+ involved_projects=list(sig.involved_projects),
666
+ )
667
+ )
668
+ return drafts
669
+
670
+
671
+ async def _f3_stale_open_loop(
672
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
673
+ ) -> list[FindingDraft]:
674
+ """stale_open_loop drift → task_candidate (sub-04 §3)."""
675
+ drafts: list[FindingDraft] = []
676
+ for sig in snapshot.drift_signals:
677
+ if sig.signal_type != "stale_open_loop":
678
+ continue
679
+ scope_type = _coerce_scope_type(sig.scope_type)
680
+ scope_key = sig.scope_key if scope_type != "company" else "__company__"
681
+ severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
682
+ evidence = _drift_signal_evidence(sig)
683
+ title = _truncate(
684
+ f"Stale open loop on {sig.observed_direction_ref}",
685
+ limit=200,
686
+ )
687
+ summary = _truncate(
688
+ f"DR4 / stale_open_loop: {sig.observed_direction_ref} has had no "
689
+ f"observable progress (knowledge_form={sig.knowledge_form}).",
690
+ limit=2000,
691
+ )
692
+ why_now = _truncate(
693
+ f"Drift signal {sig.signal_id} flags stale loop this cycle.",
694
+ limit=500,
695
+ )
696
+ closure: ClosureCondition = ClosureDriftSignalClears(
697
+ drift_signal_id=sig.signal_id,
698
+ consecutive_clear_cycles=2,
699
+ )
700
+ drafts.append(
701
+ FindingDraft(
702
+ finding_type="task_candidate",
703
+ scope_type=scope_type,
704
+ scope_key=scope_key,
705
+ program_key=sig.program_key,
706
+ title=title,
707
+ summary=summary,
708
+ why_now=why_now,
709
+ evidence=evidence,
710
+ suggested_artifact="task",
711
+ closure_condition=closure,
712
+ severity=severity,
713
+ confidence=_derive_confidence(
714
+ drift_refs=1, memory_op_refs=0, recurrence_count=1
715
+ ),
716
+ involved_projects=list(sig.involved_projects),
717
+ )
718
+ )
719
+ return drafts
720
+
721
+
722
+ async def _f4_external_update_unpropagated(
723
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
724
+ ) -> list[FindingDraft]:
725
+ """external_update_unpropagated → task_candidate (sub-04 §3)."""
726
+ drafts: list[FindingDraft] = []
727
+ for sig in snapshot.drift_signals:
728
+ if sig.signal_type != "external_update_unpropagated":
729
+ continue
730
+ scope_type = _coerce_scope_type(sig.scope_type)
731
+ scope_key = sig.scope_key if scope_type != "company" else "__company__"
732
+ severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
733
+ evidence = _drift_signal_evidence(sig)
734
+ title = _truncate(
735
+ f"External update needs propagation: {sig.observed_direction_ref}",
736
+ limit=200,
737
+ )
738
+ summary = _truncate(
739
+ f"DR6 / external_update_unpropagated: {sig.observed_direction_ref} "
740
+ "carries upstream changes not yet reflected in our context.",
741
+ limit=2000,
742
+ )
743
+ why_now = _truncate(
744
+ f"Drift signal {sig.signal_id} flags unpropagated update this cycle.",
745
+ limit=500,
746
+ )
747
+ closure: ClosureCondition = ClosureArtifactExists(
748
+ artifact_kind="task",
749
+ selector=ArtifactSelector(
750
+ tag_match="brain_finding:{finding_id}",
751
+ ),
752
+ )
753
+ drafts.append(
754
+ FindingDraft(
755
+ finding_type="task_candidate",
756
+ scope_type=scope_type,
757
+ scope_key=scope_key,
758
+ program_key=sig.program_key,
759
+ title=title,
760
+ summary=summary,
761
+ why_now=why_now,
762
+ evidence=evidence,
763
+ suggested_artifact="task",
764
+ closure_condition=closure,
765
+ severity=severity,
766
+ confidence=_derive_confidence(
767
+ drift_refs=1, memory_op_refs=0, recurrence_count=1
768
+ ),
769
+ involved_projects=list(sig.involved_projects),
770
+ )
771
+ )
772
+ return drafts
773
+
774
+
775
+ async def _f5_claimed_decision_gap(
776
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
777
+ ) -> list[FindingDraft]:
778
+ """claimed_decision_gap → scope_gap (sub-04 §3)."""
779
+ drafts: list[FindingDraft] = []
780
+ for sig in snapshot.drift_signals:
781
+ if sig.signal_type != "claimed_decision_gap":
782
+ continue
783
+ scope_type = _coerce_scope_type(sig.scope_type)
784
+ scope_key = sig.scope_key if scope_type != "company" else "__company__"
785
+ severity: Severity = sig.severity if sig.severity in _SEVERITY_RANK_INT else "medium" # type: ignore[assignment]
786
+ evidence = _drift_signal_evidence(sig)
787
+ title = _truncate(
788
+ f"Claimed decision lacks evidence: {sig.observed_direction_ref}",
789
+ limit=200,
790
+ )
791
+ summary = _truncate(
792
+ f"DR7 / claimed_decision_gap: {sig.observed_direction_ref} surfaces "
793
+ "as a claimed decision without the substrate to back it.",
794
+ limit=2000,
795
+ )
796
+ why_now = _truncate(
797
+ f"Drift signal {sig.signal_id} flags decision gap this cycle.",
798
+ limit=500,
799
+ )
800
+ closure: ClosureCondition = ClosureManualAttest(
801
+ instruction="Audit follow-up: verify the claimed decision and record evidence.",
802
+ )
803
+ drafts.append(
804
+ FindingDraft(
805
+ finding_type="scope_gap",
806
+ scope_type=scope_type,
807
+ scope_key=scope_key,
808
+ program_key=sig.program_key,
809
+ title=title,
810
+ summary=summary,
811
+ why_now=why_now,
812
+ evidence=evidence,
813
+ suggested_artifact="status_update",
814
+ closure_condition=closure,
815
+ severity=severity,
816
+ confidence=_derive_confidence(
817
+ drift_refs=1, memory_op_refs=0, recurrence_count=1
818
+ ),
819
+ involved_projects=list(sig.involved_projects),
820
+ )
821
+ )
822
+ return drafts
823
+
824
+
825
+ async def _f6_contradiction(
826
+ snapshot: FindingSnapshot, *, run_id: str, now: datetime
827
+ ) -> list[FindingDraft]:
828
+ """contradiction_detected memory ops → contradiction finding (sub-04 §3).
829
+
830
+ Sources:
831
+ * Memory ops with operation_type='contradiction_detected' (M7 output).
832
+ * Promotion candidates (M-rule promotion_candidate) emit idea findings —
833
+ handled inline here as the secondary trigger so we keep F-count to 6.
834
+ """
835
+ drafts: list[FindingDraft] = []
836
+ for op in snapshot.memory_ops:
837
+ if op.operation_type == "contradiction_detected":
838
+ scope_type = _coerce_scope_type(op.scope_type)
839
+ scope_key = op.scope_key if scope_type != "company" else "__company__"
840
+ evidence = _memory_op_evidence(op)
841
+ title = _truncate(
842
+ f"Contradiction between {op.source_ref} and {op.target_ref}",
843
+ limit=200,
844
+ )
845
+ summary = _truncate(
846
+ f"Memory op {op.operation_id} surfaces two refs in tension. "
847
+ f"Manual resolution required.",
848
+ limit=2000,
849
+ )
850
+ why_now = _truncate(
851
+ f"Memory operation {op.operation_id} fired this cycle.",
852
+ limit=500,
853
+ )
854
+ closure: ClosureCondition = ClosureMemoryOpApplied(
855
+ memory_operation_id=op.operation_id,
856
+ )
857
+ drafts.append(
858
+ FindingDraft(
859
+ finding_type="contradiction",
860
+ scope_type=scope_type,
861
+ scope_key=scope_key,
862
+ program_key=op.program_key,
863
+ title=title,
864
+ summary=summary,
865
+ why_now=why_now,
866
+ evidence=evidence,
867
+ suggested_artifact="task",
868
+ closure_condition=closure,
869
+ severity="high",
870
+ confidence=_derive_confidence(
871
+ drift_refs=0,
872
+ memory_op_refs=1,
873
+ recurrence_count=op.recurrence_count,
874
+ ),
875
+ involved_projects=list(op.involved_projects),
876
+ )
877
+ )
878
+ continue
879
+ if op.operation_type == "promotion_candidate":
880
+ scope_type = _coerce_scope_type(op.scope_type)
881
+ scope_key = op.scope_key if scope_type != "company" else "__company__"
882
+ evidence = _memory_op_evidence(op)
883
+ title = _truncate(
884
+ f"Promotion candidate: {op.source_ref}",
885
+ limit=200,
886
+ )
887
+ summary = _truncate(
888
+ f"Memory op {op.operation_id} signals {op.source_ref} as a "
889
+ "stable pattern worth promoting to a learning or guide.",
890
+ limit=2000,
891
+ )
892
+ why_now = _truncate(
893
+ f"Memory operation {op.operation_id} reached promotion threshold this cycle.",
894
+ limit=500,
895
+ )
896
+ closure_artifact: ClosureCondition = ClosureArtifactExists(
897
+ artifact_kind="learning",
898
+ selector=ArtifactSelector(
899
+ tag_match="brain_finding:{finding_id}",
900
+ ),
901
+ )
902
+ drafts.append(
903
+ FindingDraft(
904
+ finding_type="idea",
905
+ scope_type=scope_type,
906
+ scope_key=scope_key,
907
+ program_key=op.program_key,
908
+ title=title,
909
+ summary=summary,
910
+ why_now=why_now,
911
+ evidence=evidence,
912
+ suggested_artifact="learning",
913
+ closure_condition=closure_artifact,
914
+ severity="low",
915
+ confidence=_derive_confidence(
916
+ drift_refs=0,
917
+ memory_op_refs=1,
918
+ recurrence_count=op.recurrence_count,
919
+ ),
920
+ involved_projects=list(op.involved_projects),
921
+ )
922
+ )
923
+ return drafts
924
+
925
+
926
+ REGISTERED_RULES: tuple[tuple[str, Any], ...] = (
927
+ ("F1", _f1_decision_without_adr),
928
+ ("F2", _f2_playbook_changed),
929
+ ("F3", _f3_stale_open_loop),
930
+ ("F4", _f4_external_update_unpropagated),
931
+ ("F5", _f5_claimed_decision_gap),
932
+ ("F6", _f6_contradiction),
933
+ )
934
+
935
+
936
+ # ---------------------------------------------------------------------------
937
+ # Persistence
938
+ # ---------------------------------------------------------------------------
939
+
940
+
941
+ def _serialize_owner_hint(owner_hint: OwnerHint | None) -> str:
942
+ if owner_hint is None:
943
+ return "{}"
944
+ return owner_hint.model_dump_json()
945
+
946
+
947
+ def _serialize_closure(closure: ClosureCondition) -> tuple[str, str]:
948
+ """Return (kind, json_str)."""
949
+ if hasattr(closure, "model_dump_json"):
950
+ return (closure.kind, closure.model_dump_json()) # type: ignore[union-attr]
951
+ raise TypeError(f"closure must be a Pydantic model, got {type(closure)!r}")
952
+
953
+
954
+ def _evidence_kind_for(ref: str) -> tuple[str, str]:
955
+ if ":" in ref:
956
+ prefix, rest = ref.split(":", 1)
957
+ else:
958
+ prefix, rest = "kg_node", ref
959
+ mapping = {
960
+ "event": "digest_event",
961
+ "digest_event": "digest_event",
962
+ "drift": "drift_signal",
963
+ "drift_signal": "drift_signal",
964
+ "journal": "journal_entry",
965
+ "journal_entry": "journal_entry",
966
+ "memory_op": "memory_op",
967
+ "handoff": "handoff",
968
+ "learning": "learning",
969
+ "kg": "kg_node",
970
+ "kg_node": "kg_node",
971
+ "audit": "audit_log",
972
+ "audit_log": "audit_log",
973
+ "task": "task",
974
+ "pr": "pr",
975
+ "commit": "commit",
976
+ }
977
+ return (mapping.get(prefix, "kg_node"), rest)
978
+
979
+
980
+ async def _fetch_existing_finding(
981
+ db: aiosqlite.Connection, finding_id: str
982
+ ) -> aiosqlite.Row | None:
983
+ return await (
984
+ await db.execute(
985
+ "SELECT finding_id, recurrence_count, first_seen_cycle_key,"
986
+ " approval_state FROM brain_findings WHERE finding_id = ?",
987
+ (finding_id,),
988
+ )
989
+ ).fetchone()
990
+
991
+
992
+ async def _persist_findings(
993
+ *, run_id: str, findings: list[Finding]
994
+ ) -> tuple[int, list[str]]:
995
+ if not findings:
996
+ return (0, [])
997
+ persisted = 0
998
+ fingerprints: list[str] = []
999
+ async with write_db() as db:
1000
+ for f in findings:
1001
+ existing = await _fetch_existing_finding(db, f.finding_id)
1002
+ closure_kind, closure_json = _serialize_closure(f.closure_condition)
1003
+ if existing is not None:
1004
+ old_state = existing["approval_state"] if hasattr(existing, "keys") else existing[3]
1005
+ if old_state == "open":
1006
+ await db.execute(
1007
+ "UPDATE brain_findings SET"
1008
+ " last_seen_cycle_key = ?,"
1009
+ " recurrence_count = recurrence_count + 1"
1010
+ " WHERE finding_id = ?",
1011
+ (f.cycle_key, f.finding_id),
1012
+ )
1013
+ continue
1014
+ await db.execute(
1015
+ "INSERT INTO brain_findings ("
1016
+ " finding_id, run_id, cycle_key, detected_at, finding_type,"
1017
+ " schema_version, scope_type, scope_key, program_key,"
1018
+ " title, summary, why_now, evidence_hash, involved_projects_json,"
1019
+ " suggested_artifact, owner_hint_json, closure_condition_kind,"
1020
+ " closure_condition_json, closure_condition_human,"
1021
+ " severity, confidence, approval_state, regression_of_finding_id,"
1022
+ " proposal_fingerprint, recurrence_count, first_seen_cycle_key,"
1023
+ " last_seen_cycle_key, expires_at"
1024
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,"
1025
+ " ?, ?, 'open', ?, ?, ?, ?, ?, ?)",
1026
+ (
1027
+ f.finding_id,
1028
+ f.run_id,
1029
+ f.cycle_key,
1030
+ _utc_iso(f.detected_at),
1031
+ f.finding_type,
1032
+ f.schema_version,
1033
+ f.scope_type,
1034
+ f.scope_key,
1035
+ f.program_key,
1036
+ f.title,
1037
+ f.summary,
1038
+ f.why_now,
1039
+ f.evidence_hash,
1040
+ json.dumps(f.involved_projects, sort_keys=True, ensure_ascii=False),
1041
+ f.suggested_artifact,
1042
+ _serialize_owner_hint(f.owner_hint),
1043
+ closure_kind,
1044
+ closure_json,
1045
+ f.closure_condition_human,
1046
+ f.severity,
1047
+ f.confidence,
1048
+ f.regression_of_finding_id,
1049
+ f.proposal_fingerprint,
1050
+ f.recurrence_count,
1051
+ f.first_seen_cycle_key,
1052
+ f.last_seen_cycle_key,
1053
+ _utc_iso(f.expires_at),
1054
+ ),
1055
+ )
1056
+ await db.execute(
1057
+ "INSERT INTO brain_finding_states ("
1058
+ " state_id, finding_id, from_state, to_state, actor_user_id, reason"
1059
+ ") VALUES (?, ?, NULL, 'open', NULL, NULL)",
1060
+ (uuid.uuid4().hex, f.finding_id),
1061
+ )
1062
+ for pos, ev in enumerate(f.evidence):
1063
+ kind, ref = _evidence_kind_for(ev)
1064
+ await db.execute(
1065
+ "INSERT OR IGNORE INTO brain_finding_evidence ("
1066
+ " finding_id, position, evidence_kind, evidence_ref,"
1067
+ " weight, cycle_key"
1068
+ ") VALUES (?, ?, ?, ?, 1.0, ?)",
1069
+ (f.finding_id, pos, kind, ref, f.cycle_key),
1070
+ )
1071
+ persisted += 1
1072
+ fingerprints.append(f.proposal_fingerprint)
1073
+ return (persisted, fingerprints)
1074
+
1075
+
1076
+ async def _supersede_prior(*, run_id: str, fingerprints: list[str]) -> int:
1077
+ """Mark prior `open` findings with the same fingerprint as `superseded`.
1078
+
1079
+ Approved/dismissed/resolved rows are NEVER auto-superseded (human decision
1080
+ preserved — sub-04 §8 invariant). Only one new row per fingerprint per
1081
+ cycle exists by natural UK; we point predecessors at that new row.
1082
+ """
1083
+ if not fingerprints:
1084
+ return 0
1085
+ superseded = 0
1086
+ async with write_db() as db:
1087
+ for fp in set(fingerprints):
1088
+ new_row = await (
1089
+ await db.execute(
1090
+ "SELECT finding_id FROM brain_findings"
1091
+ " WHERE run_id = ? AND proposal_fingerprint = ?",
1092
+ (run_id, fp),
1093
+ )
1094
+ ).fetchone()
1095
+ if new_row is None:
1096
+ continue
1097
+ new_id = new_row[0] if not hasattr(new_row, "keys") else new_row["finding_id"]
1098
+ await db.execute(
1099
+ "UPDATE brain_findings SET"
1100
+ " approval_state = 'superseded',"
1101
+ " superseded_by_finding_id = ?"
1102
+ " WHERE proposal_fingerprint = ?"
1103
+ " AND approval_state = 'open'"
1104
+ " AND finding_id <> ?",
1105
+ (new_id, fp, new_id),
1106
+ )
1107
+ superseded += 1
1108
+ return superseded
1109
+
1110
+
1111
+ async def _run_one_rule(
1112
+ rule_id: str,
1113
+ builder,
1114
+ *,
1115
+ snapshot: FindingSnapshot,
1116
+ run_id: str,
1117
+ now: datetime,
1118
+ timeout_s: int,
1119
+ ) -> list[FindingDraft]:
1120
+ async with asyncio.timeout(timeout_s):
1121
+ result = await builder(snapshot, run_id=run_id, now=now)
1122
+ return list(result)
1123
+
1124
+
1125
+ async def run_phase(
1126
+ *,
1127
+ run_id: str,
1128
+ cycle_key: str,
1129
+ workspace_id: str = "ws_default",
1130
+ now: datetime | None = None,
1131
+ rule_timeout_s: int = DEFAULT_RULE_TIMEOUT_S,
1132
+ ) -> FindingsReport:
1133
+ """Findings phase entry. Caller (jobs._execute_cycle) invokes AFTER the
1134
+ Memory-Ops phase has produced operations for this run_id.
1135
+
1136
+ Per-rule isolation: a rule raising or hitting its 15s timeout appends to
1137
+ `partial_failures` and lets the cycle continue with the other rules. The
1138
+ cycle envelope flips to `partial` if any failure surfaces (jobs.py).
1139
+ """
1140
+ started = datetime.now(timezone.utc)
1141
+ now = (now or started).astimezone(timezone.utc)
1142
+ snapshot = await build_snapshot(
1143
+ run_id=run_id, cycle_key=cycle_key, workspace_id=workspace_id, as_of=now,
1144
+ )
1145
+
1146
+ all_findings: list[Finding] = []
1147
+ partial_failures: list[dict[str, str]] = []
1148
+
1149
+ for rule_id, builder in REGISTERED_RULES:
1150
+ try:
1151
+ drafts = await _run_one_rule(
1152
+ rule_id,
1153
+ builder,
1154
+ snapshot=snapshot,
1155
+ run_id=run_id,
1156
+ now=now,
1157
+ timeout_s=rule_timeout_s,
1158
+ )
1159
+ except asyncio.TimeoutError:
1160
+ partial_failures.append(
1161
+ {
1162
+ "kind": "finding_rule_failed",
1163
+ "rule_id": rule_id,
1164
+ "error": "timeout",
1165
+ }
1166
+ )
1167
+ continue
1168
+ except Exception as exc: # noqa: BLE001 — per-rule isolation
1169
+ logger.exception("findings: rule %s raised", rule_id)
1170
+ partial_failures.append(
1171
+ {
1172
+ "kind": "finding_rule_failed",
1173
+ "rule_id": rule_id,
1174
+ "error": str(exc)[:500],
1175
+ }
1176
+ )
1177
+ continue
1178
+ for draft in drafts:
1179
+ finding = await finalize_finding(
1180
+ draft=draft, run_id=run_id, cycle_key=cycle_key, now=now,
1181
+ )
1182
+ all_findings.append(finding)
1183
+
1184
+ persisted, fingerprints = await _persist_findings(
1185
+ run_id=run_id, findings=all_findings
1186
+ )
1187
+ await _supersede_prior(run_id=run_id, fingerprints=fingerprints)
1188
+
1189
+ return FindingsReport(
1190
+ run_id=run_id,
1191
+ cycle_key=cycle_key,
1192
+ finding_count=persisted,
1193
+ partial_failures=partial_failures,
1194
+ )
1195
+
1196
+
1197
+ # ---------------------------------------------------------------------------
1198
+ # Brain v1.2 — Direction integration helpers
1199
+ # ---------------------------------------------------------------------------
1200
+
1201
+
1202
+ # Confidence numeric -> tier mapping (decisione 2026-05-18, Emilio).
1203
+ # Threshold to emit finding remains numeric (>= 0.85, high tier).
1204
+ _CONFIDENCE_TIER_LOW_UPPER = 0.5
1205
+ _CONFIDENCE_TIER_HIGH_LOWER = 0.85
1206
+
1207
+
1208
+ def map_confidence_to_tier(numeric: float) -> ConfidenceTier:
1209
+ """Map a numeric confidence in [0,1] to a categorical tier.
1210
+
1211
+ Mapping (decisione 2026-05-18):
1212
+ x < 0.5 -> 'low'
1213
+ 0.5 <= x < 0.85 -> 'medium'
1214
+ x >= 0.85 -> 'high'
1215
+
1216
+ Values outside [0,1] are clamped before mapping. NaN is rejected (raises
1217
+ ValueError) to avoid silently surfacing a 'low' tier when the upstream
1218
+ LLM returned a bogus value.
1219
+ """
1220
+ if numeric != numeric: # NaN guard
1221
+ raise ValueError("confidence must not be NaN")
1222
+ if numeric < 0.0:
1223
+ numeric = 0.0
1224
+ elif numeric > 1.0:
1225
+ numeric = 1.0
1226
+ if numeric < _CONFIDENCE_TIER_LOW_UPPER:
1227
+ return "low"
1228
+ if numeric < _CONFIDENCE_TIER_HIGH_LOWER:
1229
+ return "medium"
1230
+ return "high"
1231
+
1232
+
1233
+ async def emit_finding_dedup(
1234
+ *,
1235
+ finding_type: FindingType,
1236
+ entity_ref: str,
1237
+ payload: dict[str, Any],
1238
+ confidence_numeric: float,
1239
+ scope_type: ScopeTypeL4,
1240
+ scope_key: str,
1241
+ cycle_key: str,
1242
+ run_id: str,
1243
+ title: str,
1244
+ summary: str,
1245
+ why_now: str,
1246
+ severity: Severity = "medium",
1247
+ suggested_artifact: SuggestedArtifact = "none",
1248
+ evidence_hash_hex: str | None = None,
1249
+ proposal_fingerprint: str | None = None,
1250
+ program_key: str | None = None,
1251
+ approval_state_new: FindingApprovalState = "open",
1252
+ expires_at: datetime | None = None,
1253
+ ) -> tuple[str, bool]:
1254
+ """Check-then-boost dedup helper for direction_* findings.
1255
+
1256
+ Semantics (decisione brainstorm §7 / decisione 2026-05-18):
1257
+ * Lookup existing finding by (finding_type, entity_ref) where
1258
+ approval_state IN ('open', 'pending_bootstrap').
1259
+ * If found: UPDATE urgency_score += 1, recurrence_count += 1,
1260
+ last_seen_cycle_key = cycle_key, proposed_payload_json = new payload.
1261
+ * If not found: INSERT new row with urgency_score=1, recurrence_count=1,
1262
+ confidence = map_confidence_to_tier(confidence_numeric).
1263
+
1264
+ Returns (finding_id, was_created).
1265
+ """
1266
+ if not entity_ref:
1267
+ raise ValueError("entity_ref required for dedup emit")
1268
+
1269
+ confidence_tier: ConfidenceTier = map_confidence_to_tier(confidence_numeric)
1270
+ payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
1271
+
1272
+ async with write_db() as db:
1273
+ cur = await db.execute(
1274
+ "SELECT finding_id, urgency_score, recurrence_count, approval_state"
1275
+ " FROM brain_findings"
1276
+ " WHERE finding_type = ? AND entity_ref = ?"
1277
+ " AND approval_state IN ('open', 'pending_bootstrap')"
1278
+ " ORDER BY created_at DESC"
1279
+ " LIMIT 1",
1280
+ (finding_type, entity_ref),
1281
+ )
1282
+ existing = await cur.fetchone()
1283
+ await cur.close()
1284
+
1285
+ if existing is not None:
1286
+ finding_id = existing[0]
1287
+ await db.execute(
1288
+ "UPDATE brain_findings SET"
1289
+ " urgency_score = urgency_score + 1,"
1290
+ " recurrence_count = recurrence_count + 1,"
1291
+ " last_seen_cycle_key = ?,"
1292
+ " proposed_payload_json = ?,"
1293
+ " confidence = ?,"
1294
+ " summary = ?,"
1295
+ " why_now = ?"
1296
+ " WHERE finding_id = ?",
1297
+ (cycle_key, payload_json, confidence_tier, summary, why_now, finding_id),
1298
+ )
1299
+ return (finding_id, False)
1300
+
1301
+ # INSERT path
1302
+ finding_id = f"fnd_{uuid.uuid4().hex[:24]}"
1303
+ now_iso = _utc_iso(datetime.now(timezone.utc))
1304
+ if expires_at is None:
1305
+ expires_iso = _utc_iso(
1306
+ datetime.now(timezone.utc) + timedelta(days=DEFAULT_OPEN_TTL_DAYS)
1307
+ )
1308
+ else:
1309
+ expires_iso = _utc_iso(expires_at)
1310
+ ev_hash = evidence_hash_hex or hashlib.blake2b(
1311
+ entity_ref.encode("utf-8"), digest_size=32
1312
+ ).hexdigest()
1313
+ fp = proposal_fingerprint or hashlib.blake2b(
1314
+ f"{finding_type}:{entity_ref}:{cycle_key}".encode("utf-8"),
1315
+ digest_size=16,
1316
+ ).hexdigest()
1317
+ await db.execute(
1318
+ "INSERT INTO brain_findings ("
1319
+ " finding_id, run_id, cycle_key, detected_at, finding_type,"
1320
+ " scope_type, scope_key, program_key, title, summary, why_now,"
1321
+ " evidence_hash, suggested_artifact, closure_condition_kind,"
1322
+ " severity, confidence, approval_state,"
1323
+ " proposal_fingerprint, recurrence_count, first_seen_cycle_key,"
1324
+ " last_seen_cycle_key, expires_at,"
1325
+ " urgency_score, entity_ref, proposed_payload_json"
1326
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'manual_attest',"
1327
+ " ?, ?, ?, ?, 1, ?, ?, ?, 1, ?, ?)",
1328
+ (
1329
+ finding_id,
1330
+ run_id,
1331
+ cycle_key,
1332
+ now_iso,
1333
+ finding_type,
1334
+ scope_type,
1335
+ scope_key,
1336
+ program_key,
1337
+ title[:200],
1338
+ summary[:2000],
1339
+ why_now[:500],
1340
+ ev_hash,
1341
+ suggested_artifact,
1342
+ severity,
1343
+ confidence_tier,
1344
+ approval_state_new,
1345
+ fp,
1346
+ cycle_key,
1347
+ cycle_key,
1348
+ expires_iso,
1349
+ entity_ref,
1350
+ payload_json,
1351
+ ),
1352
+ )
1353
+ await db.execute(
1354
+ "INSERT INTO brain_finding_states ("
1355
+ " state_id, finding_id, from_state, to_state, actor_user_id, reason"
1356
+ ") VALUES (?, ?, NULL, ?, NULL, NULL)",
1357
+ (uuid.uuid4().hex, finding_id, approval_state_new),
1358
+ )
1359
+
1360
+ return (finding_id, True)
1361
+
1362
+
1363
+ __all__ = [
1364
+ "DEFAULT_OPEN_TTL_DAYS",
1365
+ "DEFAULT_RULE_TIMEOUT_S",
1366
+ "PER_TYPE_CAP_DEFAULT",
1367
+ "FindingDraft",
1368
+ "FindingSnapshot",
1369
+ "FindingsReport",
1370
+ "REGISTERED_RULES",
1371
+ "build_snapshot",
1372
+ "emit_finding_dedup",
1373
+ "evidence_hash",
1374
+ "finalize_finding",
1375
+ "make_finding_id",
1376
+ "make_proposal_fingerprint",
1377
+ "map_confidence_to_tier",
1378
+ "run_phase",
1379
+ ]