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,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time as _t
5
+ from pathlib import Path
6
+
7
+ # Workspace root: override via MARVIS_WORKSPACE_ROOT, else default to ~/workspace
8
+ # (prod runs as the service user whose HOME holds the workspace, so the default
9
+ # preserves the historical hardcoded path behavior).
10
+ WORKSPACE_ROOT = os.environ.get("MARVIS_WORKSPACE_ROOT", str(Path.home() / "workspace"))
11
+
12
+
13
+ def resolve_project_path(project_slug: str | None) -> str:
14
+ """Resolve a project slug to its repo or metadata path.
15
+
16
+ Falls back to the MarvisX workspace when the slug is missing or unknown.
17
+ Always returns an absolute, expanded path suitable for Claude JSONL lookup.
18
+ """
19
+ if not project_slug:
20
+ return WORKSPACE_ROOT
21
+
22
+ from core.api.routers.projects import _INDEX_TTL, _build_project_index, _index_built_at, _project_index
23
+
24
+ if _t.monotonic() - _index_built_at > _INDEX_TTL:
25
+ _build_project_index()
26
+
27
+ entry = _project_index.get(project_slug)
28
+ if entry and entry.repo_path:
29
+ return os.path.abspath(str(entry.repo_path))
30
+ if entry:
31
+ return os.path.abspath(str(entry.metadata_path.resolve()))
32
+ return WORKSPACE_ROOT
33
+
34
+
35
+ def resolve_project_access_paths(project_slug: str | None) -> tuple[str, ...]:
36
+ """Return repo and metadata paths for a project slug.
37
+
38
+ Session launch now happens from the shared workspace for every provider, but
39
+ selected projects still need to be reachable via provider-specific extra
40
+ directory flags where supported.
41
+ """
42
+ if not project_slug:
43
+ return ()
44
+
45
+ from core.api.routers.projects import _INDEX_TTL, _build_project_index, _index_built_at, _project_index
46
+
47
+ if _t.monotonic() - _index_built_at > _INDEX_TTL:
48
+ _build_project_index()
49
+
50
+ entry = _project_index.get(project_slug)
51
+ if not entry:
52
+ return ()
53
+
54
+ paths: list[str] = []
55
+ if entry.repo_path:
56
+ paths.append(os.path.abspath(str(entry.repo_path)))
57
+
58
+ metadata_path = os.path.abspath(str(entry.metadata_path.resolve()))
59
+ if metadata_path not in paths:
60
+ paths.append(metadata_path)
61
+
62
+ return tuple(paths)
63
+
64
+
65
+ def candidate_project_paths(project_slug: str | None) -> tuple[str, ...]:
66
+ """Return the preferred lookup path plus the legacy workspace fallback."""
67
+ primary = resolve_project_path(project_slug)
68
+ if primary == WORKSPACE_ROOT:
69
+ return (primary,)
70
+ return (primary, WORKSPACE_ROOT)
@@ -0,0 +1,265 @@
1
+ # v1.0.0 - 2026-04-22 - Feed-style status updates for /projects/detail single-pager v2
2
+ """Project status updates feed (PR #9).
3
+
4
+ Builds a chronological feed of mixed entries for a project:
5
+ - Persisted rows from `project_status_updates` (kind = manual | auto_* | ai_*)
6
+ - Derived on-the-fly entries from recent handoffs + git commits (non-persisted)
7
+
8
+ The goal is to give /projects/detail a "what happened lately" stream without
9
+ forcing every handoff/commit to be materialized in the DB. Rows are returned
10
+ newest-first, capped at `limit`.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import re
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ import aiosqlite
21
+
22
+ from core.api.models import StatusUpdateFeedItem
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ MAX_RECENT_HANDOFFS = 5
27
+ MAX_RECENT_COMMITS = 5
28
+ # Frontmatter/title regex for handoff summaries.
29
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
30
+ _SUMMARY_KEY_RE = re.compile(r"^summary:\s*(.+?)(?:\n|$)", re.MULTILINE)
31
+ _TITLE_H1_RE = re.compile(r"^#\s+(.+?)$", re.MULTILINE)
32
+
33
+
34
+ def _parse_handoff_meta(path: Path) -> tuple[str | None, str]:
35
+ """Extract (summary, session) from a handoff .md file.
36
+
37
+ Returns a brief summary (from `summary:` frontmatter field, first H1, or
38
+ first non-empty body line) and a session id if any. All I/O is bounded
39
+ to ~8 KB to stay snappy.
40
+ """
41
+ try:
42
+ content = path.read_text(encoding="utf-8", errors="ignore")[:8192]
43
+ except Exception as exc: # noqa: BLE001 — don't blow up the feed on a broken file
44
+ logger.debug("Failed to read handoff %s: %s", path, exc)
45
+ return None, ""
46
+
47
+ session = ""
48
+ summary: str | None = None
49
+
50
+ fm_match = _FRONTMATTER_RE.match(content)
51
+ if fm_match:
52
+ fm_body = fm_match.group(1)
53
+ sum_match = _SUMMARY_KEY_RE.search(fm_body)
54
+ if sum_match:
55
+ summary = sum_match.group(1).strip().strip('"').strip("'")
56
+ sess_match = re.search(r"^session:\s*(.+?)$", fm_body, re.MULTILINE)
57
+ if sess_match:
58
+ session = sess_match.group(1).strip().strip('"').strip("'")
59
+ after_fm = content[fm_match.end():]
60
+ else:
61
+ after_fm = content
62
+
63
+ if not summary:
64
+ h1 = _TITLE_H1_RE.search(after_fm)
65
+ if h1:
66
+ summary = h1.group(1).strip()
67
+
68
+ if not summary:
69
+ # Fallback: first non-empty, non-heading line
70
+ for line in after_fm.splitlines():
71
+ line = line.strip()
72
+ if line and not line.startswith("#") and not line.startswith("```"):
73
+ summary = line[:280]
74
+ break
75
+
76
+ return summary, session
77
+
78
+
79
+ def _derive_from_handoffs(slug: str, metadata_path: Path | None) -> list[StatusUpdateFeedItem]:
80
+ """Scan `{metadata_path}/memory/handoff-*.md` for the 5 most recent files."""
81
+ if metadata_path is None:
82
+ return []
83
+ memory_dir = metadata_path / "memory"
84
+ if not memory_dir.exists() or not memory_dir.is_dir():
85
+ return []
86
+ try:
87
+ candidates = sorted(
88
+ memory_dir.glob("handoff-*.md"),
89
+ key=lambda p: p.stat().st_mtime,
90
+ reverse=True,
91
+ )[:MAX_RECENT_HANDOFFS]
92
+ except OSError as exc:
93
+ logger.debug("handoff scan failed for %s: %s", slug, exc)
94
+ return []
95
+
96
+ items: list[StatusUpdateFeedItem] = []
97
+ for path in candidates:
98
+ summary, session = _parse_handoff_meta(path)
99
+ try:
100
+ mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
101
+ except OSError:
102
+ continue
103
+ content_md = summary or f"Handoff: {path.name}"
104
+ if session:
105
+ content_md = f"**Sessione {session}** · {content_md}"
106
+ items.append(
107
+ StatusUpdateFeedItem(
108
+ id=f"handoff:{path.name}",
109
+ kind="auto_handoff",
110
+ author="handoff",
111
+ author_display=path.name,
112
+ content_md=content_md,
113
+ ref_id=str(path.relative_to(metadata_path) if metadata_path in path.parents else path.name),
114
+ created_at=mtime.isoformat(),
115
+ derived=True,
116
+ )
117
+ )
118
+ return items
119
+
120
+
121
+ async def _derive_from_commits(slug: str, repo_path: Path | None) -> list[StatusUpdateFeedItem]:
122
+ """Run `git log -n 5 --format=...` against `repo_path`. Returns empty list when
123
+ repo_path is None, not a git dir, or git is unavailable."""
124
+ if repo_path is None or not (repo_path / ".git").exists():
125
+ return []
126
+ try:
127
+ proc = await asyncio.create_subprocess_exec(
128
+ "git",
129
+ "log",
130
+ f"-n{MAX_RECENT_COMMITS}",
131
+ "--format=%H%x1f%s%x1f%an%x1f%aI",
132
+ "--no-merges",
133
+ cwd=str(repo_path),
134
+ stdout=asyncio.subprocess.PIPE,
135
+ stderr=asyncio.subprocess.PIPE,
136
+ )
137
+ try:
138
+ stdout_b, _stderr_b = await asyncio.wait_for(proc.communicate(), timeout=3.0)
139
+ except asyncio.TimeoutError:
140
+ proc.kill()
141
+ await proc.wait()
142
+ return []
143
+ except FileNotFoundError:
144
+ return []
145
+ except Exception as exc: # noqa: BLE001
146
+ logger.debug("git log failed for %s at %s: %s", slug, repo_path, exc)
147
+ return []
148
+
149
+ out = stdout_b.decode("utf-8", errors="replace").strip()
150
+ if not out:
151
+ return []
152
+
153
+ items: list[StatusUpdateFeedItem] = []
154
+ for line in out.splitlines():
155
+ parts = line.split("\x1f")
156
+ if len(parts) != 4:
157
+ continue
158
+ sha, subject, author, iso_date = parts
159
+ items.append(
160
+ StatusUpdateFeedItem(
161
+ id=f"commit:{sha[:7]}",
162
+ kind="auto_commit",
163
+ author="git",
164
+ author_display=author or "unknown",
165
+ content_md=subject.strip() or "(no commit message)",
166
+ ref_id=sha,
167
+ created_at=iso_date,
168
+ derived=True,
169
+ )
170
+ )
171
+ return items
172
+
173
+
174
+ def _row_to_feed_item(row: aiosqlite.Row) -> StatusUpdateFeedItem:
175
+ kind = row["kind"] if "kind" in row.keys() and row["kind"] else "manual"
176
+ author = row["created_by"]
177
+ # Prefer stored content_md when present, fall back to composing from the
178
+ # legacy structured fields (what_done / blockers / next_steps) so rows
179
+ # created via /api/v1/status-updates still show up in the feed.
180
+ raw_content = row["content_md"] if "content_md" in row.keys() else None
181
+ if raw_content:
182
+ content_md = raw_content
183
+ else:
184
+ chunks: list[str] = []
185
+ if row["status"]:
186
+ chunks.append(f"**Status:** {row['status']}")
187
+ if row["what_done"]:
188
+ chunks.append(f"**Done:** {row['what_done']}")
189
+ if row["blockers"]:
190
+ chunks.append(f"**Blockers:** {row['blockers']}")
191
+ if row["next_steps"]:
192
+ chunks.append(f"**Next:** {row['next_steps']}")
193
+ content_md = "\n\n".join(chunks) if chunks else "(empty update)"
194
+ author_display = row["author_display"] if "author_display" in row.keys() else None
195
+ ref_id = row["ref_id"] if "ref_id" in row.keys() else None
196
+ return StatusUpdateFeedItem(
197
+ id=str(row["id"]),
198
+ kind=kind,
199
+ author=author,
200
+ author_display=author_display,
201
+ content_md=content_md,
202
+ ref_id=ref_id,
203
+ created_at=row["created_at"],
204
+ derived=False,
205
+ )
206
+
207
+
208
+ async def list_feed(
209
+ db: aiosqlite.Connection,
210
+ slug: str,
211
+ metadata_path: Path | None,
212
+ repo_path: Path | None,
213
+ limit: int = 20,
214
+ ) -> tuple[list[StatusUpdateFeedItem], int]:
215
+ """Combine stored updates + derived entries into a chronological feed."""
216
+ # Persisted rows (cap at limit just in case — typical project has <50)
217
+ cursor = await db.execute(
218
+ "SELECT id, project, status, what_done, blockers, next_steps, "
219
+ "created_by, created_at, updated_at, kind, content_md, ref_id, author_display "
220
+ "FROM project_status_updates WHERE project = ? ORDER BY created_at DESC LIMIT ?",
221
+ (slug, limit),
222
+ )
223
+ db_rows = [_row_to_feed_item(r) async for r in cursor]
224
+
225
+ # Derived entries, concurrent
226
+ derived_handoffs = _derive_from_handoffs(slug, metadata_path)
227
+ derived_commits = await _derive_from_commits(slug, repo_path)
228
+
229
+ all_items = db_rows + derived_handoffs + derived_commits
230
+ # Sort newest-first by ISO timestamp (lexicographic sort works for ISO 8601)
231
+ all_items.sort(key=lambda item: item.created_at, reverse=True)
232
+
233
+ total_derived = len(derived_handoffs) + len(derived_commits)
234
+ return all_items[:limit], len(db_rows) + total_derived
235
+
236
+
237
+ async def create_manual_update(
238
+ db: aiosqlite.Connection,
239
+ slug: str,
240
+ content_md: str,
241
+ author: str,
242
+ author_display: str | None,
243
+ ) -> StatusUpdateFeedItem:
244
+ """Insert a manual feed entry. Callers must have already enforced RBAC."""
245
+ now = datetime.now(timezone.utc).isoformat()
246
+ # Legacy `status` column is NOT NULL, pick 'active' as a neutral default for
247
+ # feed-mode entries (UI surfaces the kind/author, not the status badge).
248
+ cursor = await db.execute(
249
+ "INSERT INTO project_status_updates "
250
+ "(project, status, kind, content_md, created_by, author_display, created_at) "
251
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
252
+ (slug, "active", "manual", content_md, author, author_display, now),
253
+ )
254
+ await db.commit()
255
+ new_id = cursor.lastrowid
256
+ return StatusUpdateFeedItem(
257
+ id=str(new_id),
258
+ kind="manual",
259
+ author=author,
260
+ author_display=author_display,
261
+ content_md=content_md,
262
+ ref_id=None,
263
+ created_at=now,
264
+ derived=False,
265
+ )
@@ -0,0 +1,276 @@
1
+ # v1.4.0 - 2026-04-02 - Add model-aware launch commands and OpenCode runtime overrides
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from core.api.config import settings
13
+
14
+ ProviderName = Literal["claude", "gemini", "codex", "opencode"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class KeystrokeStep:
19
+ key: str
20
+ delay_after: float = 0.3
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ProviderConfig:
25
+ name: str
26
+ binary: str
27
+ cli_flags: str
28
+ process_names: tuple[str, ...]
29
+ exit_sequence: tuple[KeystrokeStep, ...]
30
+ submit_with_double_enter: bool = False
31
+ source_bashrc: bool = False
32
+ launcher_path: str | None = None
33
+
34
+
35
+ _API_ROOT = Path(__file__).resolve().parents[1]
36
+ _OPENCODE_LAUNCHER = str(_API_ROOT / "bin" / "opencode-launch.sh")
37
+ _OPENCODE_TUI_CONFIGS = {
38
+ "dark": str(_API_ROOT / "opencode-runtime" / "tui.dark.json"),
39
+ "light": str(_API_ROOT / "opencode-runtime" / "tui.light.json"),
40
+ }
41
+
42
+
43
+ PROVIDERS: dict[str, ProviderConfig] = {
44
+ "claude": ProviderConfig(
45
+ name="Claude Code",
46
+ binary="claude",
47
+ cli_flags="--dangerously-skip-permissions",
48
+ process_names=("claude", "node"),
49
+ exit_sequence=(
50
+ KeystrokeStep("C-c", 1.0),
51
+ KeystrokeStep("C-u", 0.3),
52
+ KeystrokeStep("/exit", 0.5),
53
+ KeystrokeStep("Escape", 0.3),
54
+ KeystrokeStep("Enter", 2.0),
55
+ ),
56
+ submit_with_double_enter=True,
57
+ ),
58
+ "gemini": ProviderConfig(
59
+ name="Gemini CLI",
60
+ binary="gemini",
61
+ cli_flags="--yolo",
62
+ process_names=("gemini",),
63
+ exit_sequence=(
64
+ KeystrokeStep("C-c", 0.5),
65
+ KeystrokeStep("/exit", 1.0),
66
+ KeystrokeStep("Enter", 1.0),
67
+ ),
68
+ ),
69
+ "codex": ProviderConfig(
70
+ name="Codex CLI",
71
+ binary="codex",
72
+ cli_flags="--dangerously-bypass-approvals-and-sandbox",
73
+ process_names=("codex", "node"),
74
+ exit_sequence=(
75
+ KeystrokeStep("C-c", 0.5),
76
+ KeystrokeStep("/exit", 1.0),
77
+ KeystrokeStep("Enter", 1.0),
78
+ ),
79
+ ),
80
+ "opencode": ProviderConfig(
81
+ name="OpenCode",
82
+ binary="opencode",
83
+ cli_flags="",
84
+ process_names=("opencode", "node"),
85
+ exit_sequence=(
86
+ KeystrokeStep("C-c", 0.5),
87
+ KeystrokeStep("/exit", 0.5),
88
+ KeystrokeStep("Enter", 1.0),
89
+ ),
90
+ submit_with_double_enter=True,
91
+ launcher_path=_OPENCODE_LAUNCHER,
92
+ ),
93
+ }
94
+
95
+ # All known process names across all providers (for quick status checks in list endpoints)
96
+ ALL_KNOWN_PROCESS_NAMES = tuple(
97
+ sorted({pn for cfg in PROVIDERS.values() for pn in cfg.process_names})
98
+ )
99
+
100
+ def _runtime_home() -> str:
101
+ return settings.effective_runtime_home
102
+
103
+
104
+ def _runtime_bashrc() -> str:
105
+ return os.path.join(_runtime_home(), ".bashrc")
106
+
107
+
108
+ def _runtime_path_prefix() -> str:
109
+ home = _runtime_home()
110
+ return ":".join(
111
+ (
112
+ os.path.join(home, ".local", "bin"),
113
+ os.path.join(home, "bin"),
114
+ os.path.join(home, ".npm-global", "bin"),
115
+ os.path.join(home, ".opencode", "bin"),
116
+ )
117
+ )
118
+
119
+
120
+ def _opencode_state_root() -> str:
121
+ return os.path.join(
122
+ _runtime_home(), ".local", "state", "opencode-marvisx-console"
123
+ )
124
+
125
+
126
+ def _provider_invocation(config: ProviderConfig) -> str:
127
+ return " ".join(part for part in (config.binary, config.cli_flags) if part)
128
+
129
+
130
+ def get_provider(name: str | None) -> ProviderConfig:
131
+ """Get provider config. None defaults to Claude (backward compat).
132
+ Raises ValueError for unknown provider names."""
133
+ if name is None:
134
+ return PROVIDERS["claude"]
135
+ try:
136
+ return PROVIDERS[name]
137
+ except KeyError:
138
+ raise ValueError(
139
+ f"Unknown provider: {name!r}. Available: {', '.join(PROVIDERS)}"
140
+ )
141
+
142
+
143
+ def _model_flag(config: ProviderConfig, model: str | None) -> str:
144
+ if not model:
145
+ return ""
146
+ if config.binary == "codex":
147
+ return f"-m {shlex.quote(model)}"
148
+ return f"--model {shlex.quote(model)}"
149
+
150
+
151
+ def _opencode_invocation(
152
+ config: ProviderConfig,
153
+ safe_dir: str,
154
+ model: str | None,
155
+ opencode_config: dict[str, object] | None,
156
+ opencode_tui_config: str | None = None,
157
+ opencode_state_dir: str | None = None,
158
+ extra_cli_args: tuple[str, ...] | None = None,
159
+ ) -> str:
160
+ parts: list[str] = []
161
+ if opencode_config:
162
+ config_json = json.dumps(opencode_config, separators=(",", ":"))
163
+ parts.append(f"OPENCODE_CONFIG_CONTENT={shlex.quote(config_json)}")
164
+ if opencode_tui_config:
165
+ parts.append(f"OPENCODE_TUI_CONFIG={shlex.quote(opencode_tui_config)}")
166
+ if opencode_state_dir:
167
+ parts.append(f"OPENCODE_STATE_DIR={shlex.quote(opencode_state_dir)}")
168
+ parts.append(shlex.quote(config.launcher_path or config.binary))
169
+ parts.append(safe_dir)
170
+ model_flag = _model_flag(config, model)
171
+ if model_flag:
172
+ parts.append(model_flag)
173
+ if extra_cli_args:
174
+ parts.extend(extra_cli_args)
175
+ return " ".join(parts)
176
+
177
+
178
+ def _codex_invocation(
179
+ config: ProviderConfig,
180
+ safe_dir: str,
181
+ model: str | None,
182
+ extra_cli_args: tuple[str, ...] | None = None,
183
+ ) -> str:
184
+ parts = [config.binary]
185
+ if config.cli_flags:
186
+ parts.append(config.cli_flags)
187
+ model_flag = _model_flag(config, model)
188
+ if model_flag:
189
+ parts.append(model_flag)
190
+ if extra_cli_args:
191
+ parts.extend(extra_cli_args)
192
+ parts.append(f"-C {safe_dir}")
193
+ return " ".join(parts)
194
+
195
+
196
+ def _direct_invocation(
197
+ config: ProviderConfig,
198
+ model: str | None,
199
+ extra_cli_args: tuple[str, ...] | None = None,
200
+ ) -> str:
201
+ parts = [config.binary]
202
+ if config.cli_flags:
203
+ parts.append(config.cli_flags)
204
+ model_flag = _model_flag(config, model)
205
+ if model_flag:
206
+ parts.append(model_flag)
207
+ if extra_cli_args:
208
+ parts.extend(extra_cli_args)
209
+ return " ".join(parts)
210
+
211
+
212
+ def build_start_command(
213
+ config: ProviderConfig,
214
+ directory: str,
215
+ *,
216
+ model: str | None = None,
217
+ opencode_config: dict[str, object] | None = None,
218
+ opencode_theme_mode: Literal["light", "dark"] | None = None,
219
+ session_name: str | None = None,
220
+ extra_cli_args: tuple[str, ...] | None = None,
221
+ ) -> str:
222
+ """Build shell command to start CLI in given directory.
223
+
224
+ The systemd API service and tmux sessions do not always preserve the interactive
225
+ shell environment. Force HOME/PATH so provider CLIs can find their auth/config.
226
+ """
227
+ expanded = os.path.expanduser(directory)
228
+ safe_dir = shlex.quote(expanded)
229
+ env_prefix = (
230
+ f"export HOME={shlex.quote(_runtime_home())} "
231
+ f"XDG_CONFIG_HOME={shlex.quote(os.path.join(_runtime_home(), '.config'))} "
232
+ f"PATH={shlex.quote(_runtime_path_prefix())}:$PATH"
233
+ )
234
+ if config.launcher_path:
235
+ invocation = _opencode_invocation(
236
+ config,
237
+ safe_dir,
238
+ model,
239
+ opencode_config,
240
+ opencode_tui_config=_OPENCODE_TUI_CONFIGS.get(opencode_theme_mode)
241
+ if opencode_theme_mode
242
+ else None,
243
+ opencode_state_dir=os.path.join(_opencode_state_root(), session_name)
244
+ if session_name
245
+ else None,
246
+ extra_cli_args=extra_cli_args,
247
+ )
248
+ return f"{env_prefix} && {invocation}"
249
+ if config.binary == "codex":
250
+ invocation = _codex_invocation(
251
+ config, safe_dir, model, extra_cli_args=extra_cli_args
252
+ )
253
+ return f"{env_prefix} && {invocation}"
254
+ invocation = _direct_invocation(config, model, extra_cli_args=extra_cli_args)
255
+ if config.source_bashrc:
256
+ shell_bootstrap = shlex.quote(
257
+ f"source {shlex.quote(_runtime_bashrc())} >/dev/null 2>&1; "
258
+ f"cd {safe_dir} && {invocation}"
259
+ )
260
+ return f"{env_prefix} && bash -ic {shell_bootstrap}"
261
+ return f"{env_prefix} && cd {safe_dir} && {invocation}"
262
+
263
+
264
+ async def is_binary_available(config: ProviderConfig) -> bool:
265
+ """Check if the provider's CLI binary is installed and in PATH."""
266
+ env = os.environ.copy()
267
+ env["PATH"] = f"{_runtime_path_prefix()}:{env.get('PATH', '')}"
268
+ proc = await asyncio.create_subprocess_exec(
269
+ "which",
270
+ config.binary,
271
+ env=env,
272
+ stdout=asyncio.subprocess.DEVNULL,
273
+ stderr=asyncio.subprocess.DEVNULL,
274
+ )
275
+ await proc.communicate()
276
+ return proc.returncode == 0