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,1061 @@
1
+ # v4.0.0 - 2026-05-27 - S1 F1.5: thin adapter over use_cases.projects (filesystem/git/index helpers retained + re-exported)
2
+ # v3.7.0 - 2026-04-14 - git_push/git_pull use get_write_db (refactor batch 4/6)
3
+ """HTTP adapter for the projects domain (S1 collapse-runtime, follows the learnings TEMPLATE).
4
+
5
+ This router is a thin transport adapter for the CRUD/query/visibility logic, which
6
+ lives in :mod:`core.api.use_cases.projects` (pure, fastapi-free). Each handler:
7
+
8
+ 1. resolves identity into a :class:`CallerContext` (``from_user_info``);
9
+ 2. for slug-scoped reads, resolves visibility (``get_visible_projects``) at the
10
+ boundary and passes it in — the use_case ENFORCES it (DECISION 1: 404, does not
11
+ reveal existence — parity with ``check_project_access``);
12
+ 3. calls the use_case inside ``try/except ServiceError`` -> ``to_http``;
13
+ 4. for ``get_project``, attaches ``deep`` KG context (rate-limit + log + lens) at
14
+ the boundary (DECISION 2 — a per-surface concern).
15
+
16
+ CENTRAL-router note: the filesystem/discovery/git helpers + the project-index
17
+ globals (``PROJECT_DIRS``, ``_set_project_dirs``, ``_build_project_index``,
18
+ ``_project_index``, ``_index_built_at``, ``_INDEX_TTL``, ``_find_project_entry``,
19
+ ``_find_project_path``, ``_find_git_path``, ``_read_project_yaml``,
20
+ ``_get_programs``, ``_parse_handoffs``, …) are INFRASTRUCTURE imported by ~12 other
21
+ modules directly from this path. They STAY here unchanged (mutable globals +
22
+ run-as-aware git command) and remain importable. The use_case reaches for them
23
+ function-locally so it stays fastapi-free.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import os
31
+ import re
32
+ import shutil
33
+ import time
34
+ from collections import defaultdict
35
+ from dataclasses import dataclass
36
+ from datetime import date
37
+ from pathlib import Path
38
+ from typing import Literal
39
+
40
+ import aiosqlite
41
+ import yaml
42
+ from fastapi import APIRouter, Depends, HTTPException, Query
43
+ from fastapi import Path as PathParam
44
+
45
+ from core.api.config import settings
46
+ from core.api.db import get_db, get_write_db
47
+ from core.api.models import (
48
+ DocEntry,
49
+ HandoffEntry,
50
+ ProgramInfo,
51
+ ProjectCreateRequest,
52
+ ProjectCreateResponse,
53
+ ProjectDetail,
54
+ ProjectInfo,
55
+ StatusCounts,
56
+ StatusUpdateFeedCreateRequest,
57
+ StatusUpdateFeedItem,
58
+ StatusUpdateFeedResponse,
59
+ StatusUpdateResponse,
60
+ UserInfo,
61
+ )
62
+ from core.api.rbac import require_role
63
+ from core.api.routers._adapter import to_http
64
+ from core.api.security import get_current_user, get_current_user_or_agent
65
+ from core.api.services import project_status_updates as status_feed_service # noqa: F401 (re-export / used by use_case)
66
+ from core.api.visibility import check_project_access, get_visible_projects # noqa: F401 (check_project_access kept as re-export seam)
67
+ from core.api.services.kg.audit import check_deep_rate_limit, log_kg_deep_access
68
+ from core.api.services.kg.lens import build_kg_context_for_project
69
+ from core.api.use_cases import projects as uc
70
+ from core.api.use_cases._context import CallerContext
71
+ from core.api.use_cases._errors import ServiceError
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+ router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
76
+
77
+ # Project base directories (default, overridden by DB settings)
78
+ PROJECT_DIRS: list[Path] = [
79
+ Path.home() / "workspace" / "projects",
80
+ ]
81
+
82
+
83
+ def _set_project_dirs(dirs: list[Path]) -> None:
84
+ """Update project directories (called from settings router or startup)."""
85
+ global PROJECT_DIRS
86
+ PROJECT_DIRS = dirs
87
+
88
+ MAX_FILE_SIZE = 500_000 # 500KB per file
89
+
90
+ # --- ProjectIndexEntry: rich index with type awareness ---
91
+
92
+ ProjectType = Literal["work", "code", "system"]
93
+
94
+
95
+ @dataclass
96
+ class ProjectIndexEntry:
97
+ slug: str
98
+ metadata_path: Path
99
+ repo_path: Path | None
100
+ project_type: ProjectType
101
+
102
+
103
+ from core.api.config import ALLOWED_REPO_PARENTS # centralized in config.py
104
+ from core.api.services.runas import GIT_CMD as _RUNAS_GIT_CMD
105
+
106
+ _project_index: dict[str, ProjectIndexEntry] = {}
107
+ _index_built_at: float = 0
108
+ _INDEX_TTL = 300 # 5 minutes
109
+
110
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$")
111
+
112
+
113
+ def _build_project_index() -> dict[str, ProjectIndexEntry]:
114
+ """Build slug -> ProjectIndexEntry mapping from .task files + project.yaml."""
115
+ global _project_index, _index_built_at
116
+ index: dict[str, ProjectIndexEntry] = {}
117
+ for base in PROJECT_DIRS:
118
+ if not base.exists():
119
+ continue
120
+ for d in sorted(base.iterdir()):
121
+ if not d.is_dir() or d.is_symlink():
122
+ continue
123
+ task_file = d / ".task"
124
+ if task_file.exists():
125
+ try:
126
+ slug = task_file.read_text().strip()[:64]
127
+ except OSError:
128
+ continue
129
+ if not slug or not _SLUG_RE.match(slug):
130
+ continue
131
+ else:
132
+ if _SLUG_RE.match(d.name):
133
+ slug = d.name
134
+ else:
135
+ continue
136
+
137
+ # Read project.yaml for type and repo_path
138
+ yaml_data = _read_project_yaml(d)
139
+ project_type: ProjectType = "work"
140
+ repo_path: Path | None = None
141
+
142
+ if yaml_data:
143
+ raw_type = yaml_data.get("type")
144
+ if raw_type in ("work", "code", "system"):
145
+ project_type = raw_type
146
+ else:
147
+ project_type = "code" if (d / ".git").exists() else "work"
148
+
149
+ repo_path_str = yaml_data.get("repo_path")
150
+ if repo_path_str:
151
+ rp = Path(repo_path_str).resolve()
152
+ if any(rp.is_relative_to(p) for p in ALLOWED_REPO_PARENTS):
153
+ repo_path = rp
154
+ else:
155
+ logger.warning("Invalid repo_path for %s: %s", slug, repo_path_str)
156
+ elif (d / ".git").exists():
157
+ repo_path = d.resolve()
158
+ else:
159
+ project_type = "code" if (d / ".git").exists() else "work"
160
+ if (d / ".git").exists():
161
+ repo_path = d.resolve()
162
+
163
+ if slug in index:
164
+ logger.warning("Duplicate slug '%s': %s vs %s", slug, index[slug].metadata_path, d)
165
+
166
+ index[slug] = ProjectIndexEntry(
167
+ slug=slug,
168
+ metadata_path=d,
169
+ repo_path=repo_path,
170
+ project_type=project_type,
171
+ )
172
+ _project_index = index
173
+ _index_built_at = time.monotonic()
174
+ return index
175
+
176
+
177
+ def _find_project_path(slug: str) -> Path | None:
178
+ """O(1) lookup returning metadata_path with containment check."""
179
+ global _project_index, _index_built_at
180
+ if not _SLUG_RE.match(slug):
181
+ return None
182
+ if time.monotonic() - _index_built_at > _INDEX_TTL:
183
+ _build_project_index()
184
+ entry = _project_index.get(slug)
185
+ if entry is None:
186
+ return None
187
+ resolved = entry.metadata_path.resolve()
188
+ for base in PROJECT_DIRS:
189
+ if resolved.is_relative_to(base.resolve()):
190
+ return resolved
191
+ return None
192
+
193
+
194
+ def _find_project_entry(slug: str) -> ProjectIndexEntry | None:
195
+ """O(1) lookup returning full ProjectIndexEntry."""
196
+ if not _SLUG_RE.match(slug):
197
+ return None
198
+ if time.monotonic() - _index_built_at > _INDEX_TTL:
199
+ _build_project_index()
200
+ return _project_index.get(slug)
201
+
202
+
203
+ def _find_git_path(slug: str) -> Path | None:
204
+ """Resolve slug to git repo path for git operations."""
205
+ entry = _find_project_entry(slug)
206
+ if not entry or not entry.repo_path:
207
+ return None
208
+ repo = entry.repo_path.resolve()
209
+ if not any(repo.is_relative_to(p) for p in ALLOWED_REPO_PARENTS):
210
+ logger.warning("repo_path containment violation for %s: %s", slug, repo)
211
+ return None
212
+ if not (repo / ".git").exists():
213
+ return None
214
+ return repo
215
+
216
+
217
+ # --- programs.yaml cache ---
218
+
219
+ _programs_cache: dict | None = None
220
+ _programs_mtime: float = 0
221
+
222
+
223
+ def _get_programs() -> dict:
224
+ """Load programs.yaml with mtime-based cache."""
225
+ global _programs_cache, _programs_mtime
226
+ yaml_path = Path.home() / "workspace" / "programs.yaml"
227
+ if not yaml_path.exists():
228
+ return {}
229
+ try:
230
+ current_mtime = yaml_path.stat().st_mtime
231
+ if _programs_cache is not None and current_mtime == _programs_mtime:
232
+ return _programs_cache
233
+ _programs_cache = yaml.safe_load(yaml_path.read_text()) or {}
234
+ _programs_mtime = current_mtime
235
+ return _programs_cache
236
+ except Exception:
237
+ logger.exception("Failed to load programs.yaml")
238
+ return _programs_cache or {}
239
+
240
+
241
+ # --- project.yaml mtime cache ---
242
+
243
+ _project_yaml_cache: dict[str, dict | None] = {}
244
+ _project_yaml_mtime: dict[str, float] = {}
245
+
246
+
247
+ def _read_project_yaml(project_path: Path) -> dict | None:
248
+ """Read project.yaml with mtime-based cache. Returns dict or None."""
249
+ yaml_path = project_path / "project.yaml"
250
+ if not yaml_path.exists():
251
+ return None
252
+ slug = project_path.name
253
+ try:
254
+ current_mtime = yaml_path.stat().st_mtime
255
+ if slug in _project_yaml_cache and _project_yaml_mtime.get(slug) == current_mtime:
256
+ return _project_yaml_cache[slug]
257
+ content = _safe_read_file(project_path, "project.yaml")
258
+ if not content:
259
+ return None
260
+ data = yaml.safe_load(content)
261
+ if not isinstance(data, dict):
262
+ return None
263
+ _project_yaml_cache[slug] = data
264
+ _project_yaml_mtime[slug] = current_mtime
265
+ return data
266
+ except Exception:
267
+ logger.warning("Failed to parse project.yaml for %s", slug, exc_info=True)
268
+ return _project_yaml_cache.get(slug)
269
+
270
+
271
+ def _get_project_metadata(project_path: Path) -> dict:
272
+ """Get project metadata from project.yaml, fallback to context.md Config."""
273
+ yaml_data = _read_project_yaml(project_path)
274
+ if yaml_data:
275
+ return yaml_data
276
+ ctx = _safe_read_file(project_path, "context.md")
277
+ if ctx:
278
+ return _parse_context_config(ctx)
279
+ return {}
280
+
281
+
282
+ # --- Safe file reading ---
283
+
284
+
285
+ def _safe_read_file(project_path: Path, relative_path: str) -> str | None:
286
+ """Read file within project dir with containment + size limit."""
287
+ target = (project_path / relative_path).resolve()
288
+ if not target.is_relative_to(project_path.resolve()):
289
+ return None
290
+ if (project_path / relative_path).is_symlink():
291
+ return None
292
+ if not target.is_file():
293
+ return None
294
+ try:
295
+ if target.stat().st_size > MAX_FILE_SIZE:
296
+ return None
297
+ return target.read_text(encoding="utf-8", errors="replace")
298
+ except OSError:
299
+ return None
300
+
301
+
302
+ # --- Task counts batch query ---
303
+
304
+
305
+ async def _get_all_task_counts(db: aiosqlite.Connection) -> dict[str, dict[str, int]]:
306
+ """Single GROUP BY query for all projects."""
307
+ cursor = await db.execute(
308
+ "SELECT project, status, COUNT(*) as cnt "
309
+ "FROM tasks WHERE deleted_at IS NULL "
310
+ "GROUP BY project, status"
311
+ )
312
+ result: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
313
+ async for row in cursor:
314
+ result[row["project"]][row["status"]] = row["cnt"]
315
+ return dict(result)
316
+
317
+
318
+ # --- Latest status update per project ---
319
+
320
+
321
+ async def _get_latest_status_updates(db: aiosqlite.Connection) -> dict[str, tuple[str, str]]:
322
+ """Latest status update (status, date) per project."""
323
+ cursor = await db.execute(
324
+ "SELECT project, status, created_at FROM project_status_updates "
325
+ "WHERE id IN (SELECT MAX(id) FROM project_status_updates GROUP BY project)"
326
+ )
327
+ result: dict[str, tuple[str, str]] = {}
328
+ async for row in cursor:
329
+ result[row["project"]] = (row["status"], row["created_at"])
330
+ return dict(result)
331
+
332
+
333
+ # --- Helpers ---
334
+
335
+
336
+ def _parse_context_config(content: str) -> dict:
337
+ """Extract key-value pairs from ## Config section of context.md."""
338
+ config: dict[str, str] = {}
339
+ in_config = False
340
+ for line in content.splitlines():
341
+ if line.strip().startswith("## Config"):
342
+ in_config = True
343
+ continue
344
+ if in_config and line.strip().startswith("## "):
345
+ break
346
+ if in_config and line.strip().startswith("- "):
347
+ parts = line.strip()[2:].split(":", 1)
348
+ if len(parts) == 2:
349
+ key = parts[0].strip().strip("*")
350
+ config[key.lower()] = parts[1].strip()
351
+ return config
352
+
353
+
354
+ def _parse_handoffs(project_path: Path) -> list[HandoffEntry]:
355
+ """Parse memory/handoff-*.md files. Reads YAML frontmatter if present, fallback to filename."""
356
+ memory_dir = project_path / "memory"
357
+ if not memory_dir.is_dir():
358
+ return []
359
+ entries: list[HandoffEntry] = []
360
+ for f in sorted(memory_dir.glob("handoff-*.md"), reverse=True):
361
+ if f.is_symlink():
362
+ continue
363
+ content = _safe_read_file(project_path, f"memory/{f.name}")
364
+ if not content:
365
+ continue
366
+
367
+ date = ""
368
+ session = None
369
+ branch = None
370
+ tags: list[str] = []
371
+
372
+ # Try YAML frontmatter first
373
+ if content.startswith("---"):
374
+ end = content.find("---", 3)
375
+ if end > 0:
376
+ try:
377
+ fm = yaml.safe_load(content[3:end])
378
+ if isinstance(fm, dict):
379
+ date = str(fm["date"]) if "date" in fm else ""
380
+ raw_session = fm.get("session")
381
+ if raw_session not in (None, ""):
382
+ session = str(raw_session)
383
+ branch = fm.get("branch")
384
+ tags = fm.get("tags", [])
385
+ if isinstance(tags, str):
386
+ tags = [tags]
387
+ except Exception:
388
+ pass
389
+
390
+ # Fallback: extract date from filename
391
+ if not date:
392
+ date_match = re.search(r"handoff-(\d{4}-\d{2}-\d{2})", f.name)
393
+ date = date_match.group(1) if date_match else ""
394
+
395
+ # Extract summary from ## Summary section (always, regardless of frontmatter)
396
+ summary = ""
397
+ in_summary = False
398
+ for line in content.splitlines():
399
+ if line.strip().startswith("## Summary"):
400
+ in_summary = True
401
+ continue
402
+ if in_summary and line.strip().startswith("## "):
403
+ break
404
+ if in_summary and line.strip():
405
+ summary += line.strip() + " "
406
+ if len(summary) > 200:
407
+ break
408
+
409
+ entries.append(HandoffEntry(
410
+ filename=f"memory/{f.name}",
411
+ date=date,
412
+ summary=summary.strip()[:200],
413
+ session=session,
414
+ branch=branch,
415
+ tags=tags,
416
+ ))
417
+ return entries
418
+
419
+
420
+ def _parse_docs(project_path: Path, subdir: str) -> list[DocEntry]:
421
+ """Parse docs/plans/ or docs/solutions/ files."""
422
+ docs_dir = project_path / "docs" / subdir
423
+ if not docs_dir.is_dir():
424
+ return []
425
+ entries: list[DocEntry] = []
426
+ for f in sorted(docs_dir.glob("*.md"), reverse=True):
427
+ if f.is_symlink():
428
+ continue
429
+ content = _safe_read_file(project_path, f"docs/{subdir}/{f.name}")
430
+ title = None
431
+ date = None
432
+ category = None
433
+ if content:
434
+ # Parse YAML frontmatter
435
+ if content.startswith("---"):
436
+ end = content.find("---", 3)
437
+ if end > 0:
438
+ try:
439
+ fm = yaml.safe_load(content[3:end])
440
+ if isinstance(fm, dict):
441
+ title = fm.get("title")
442
+ date = str(fm["date"]) if "date" in fm else None
443
+ category = fm.get("category") or fm.get("type")
444
+ except Exception:
445
+ pass
446
+ if not date:
447
+ date_match = re.search(r"(\d{4}-\d{2}-\d{2})", f.name)
448
+ date = date_match.group(1) if date_match else None
449
+ if not title:
450
+ title = f.stem
451
+ entries.append(DocEntry(filename=f"docs/{subdir}/{f.name}", date=date, title=title, category=category))
452
+ return entries
453
+
454
+
455
+ # --- Git operations ---
456
+
457
+ # Run-as-aware git (see services/runas.py): `sudo -u <user> git` when a run-as
458
+ # user is configured, else plain `git`. Tuple so it unpacks into
459
+ # create_subprocess_exec(*_GIT_CMD, "log", ...).
460
+ _GIT_CMD: tuple[str, ...] = tuple(_RUNAS_GIT_CMD)
461
+
462
+ _GIT_REMOTE_RE = re.compile(r"^(https://github\.com/|git@github\.com:)")
463
+
464
+
465
+ async def _validate_git_remote(project_path: Path) -> None:
466
+ """SSRF prevention: only allow operations to known remotes."""
467
+ proc = await asyncio.create_subprocess_exec(
468
+ *_GIT_CMD, "remote", "get-url", "origin",
469
+ cwd=str(project_path),
470
+ stdout=asyncio.subprocess.PIPE,
471
+ stderr=asyncio.subprocess.PIPE,
472
+ )
473
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
474
+ remote_url = stdout.decode().strip()
475
+ if not _GIT_REMOTE_RE.match(remote_url):
476
+ raise HTTPException(403, "Git remote not allowed (only github.com)")
477
+
478
+
479
+ async def _git_log(project_path: Path, limit: int = 20) -> list[dict]:
480
+ """Get git log as list of commit dicts."""
481
+ limit = max(1, min(limit, 100))
482
+ proc = await asyncio.create_subprocess_exec(
483
+ *_GIT_CMD, "log", f"--max-count={limit}",
484
+ "--format=%H%x09%h%x09%s%x09%an%x09%aI",
485
+ cwd=str(project_path),
486
+ stdout=asyncio.subprocess.PIPE,
487
+ stderr=asyncio.subprocess.PIPE,
488
+ )
489
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
490
+ if proc.returncode != 0:
491
+ return []
492
+ commits = []
493
+ for line in stdout.decode().strip().splitlines():
494
+ parts = line.split("\t")
495
+ if len(parts) >= 5:
496
+ commits.append({
497
+ "hash": parts[0],
498
+ "hash_short": parts[1],
499
+ "message": parts[2],
500
+ "author": parts[3],
501
+ "date": parts[4],
502
+ })
503
+ return commits
504
+
505
+
506
+ async def _git_diff(project_path: Path) -> str:
507
+ """Get git diff (staged + unstaged), truncated to 100KB."""
508
+ proc = await asyncio.create_subprocess_exec(
509
+ *_GIT_CMD, "diff", "--stat",
510
+ cwd=str(project_path),
511
+ stdout=asyncio.subprocess.PIPE,
512
+ stderr=asyncio.subprocess.PIPE,
513
+ )
514
+ stat_out, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
515
+
516
+ proc2 = await asyncio.create_subprocess_exec(
517
+ *_GIT_CMD, "diff",
518
+ cwd=str(project_path),
519
+ stdout=asyncio.subprocess.PIPE,
520
+ stderr=asyncio.subprocess.PIPE,
521
+ )
522
+ diff_out, _ = await asyncio.wait_for(proc2.communicate(), timeout=30)
523
+
524
+ stat_str = stat_out.decode(errors="replace")
525
+ diff_str = diff_out.decode(errors="replace")
526
+ # Truncate to 100KB total
527
+ combined = f"{stat_str}\n---\n{diff_str}"
528
+ return combined[:100_000]
529
+
530
+
531
+ async def _git_branches(project_path: Path) -> list[dict]:
532
+ """Get list of branches with current indicator."""
533
+ proc = await asyncio.create_subprocess_exec(
534
+ *_GIT_CMD, "branch", "--format=%(refname:short)%09%(HEAD)",
535
+ cwd=str(project_path),
536
+ stdout=asyncio.subprocess.PIPE,
537
+ stderr=asyncio.subprocess.PIPE,
538
+ )
539
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
540
+ if proc.returncode != 0:
541
+ return []
542
+ branches = []
543
+ for line in stdout.decode().strip().splitlines():
544
+ parts = line.split("\t")
545
+ if len(parts) >= 2:
546
+ branches.append({
547
+ "name": parts[0],
548
+ "is_current": parts[1].strip() == "*",
549
+ })
550
+ return branches
551
+
552
+
553
+ async def _git_graph_log(
554
+ project_path: Path, limit: int = 50, skip: int = 0, all_branches: bool = True,
555
+ ) -> list[dict]:
556
+ """Get git log with parent hashes and decorations for graph rendering."""
557
+ limit = max(1, min(limit, 200))
558
+ cmd = [
559
+ *_GIT_CMD, "log", "--topo-order",
560
+ f"--max-count={limit}", f"--skip={skip}",
561
+ "--format=%H%x00%P%x00%D%x00%s%x00%an%x00%aI",
562
+ ]
563
+ if all_branches:
564
+ cmd.insert(len(_GIT_CMD) + 1, "--all")
565
+ proc = await asyncio.create_subprocess_exec(
566
+ *cmd, cwd=str(project_path),
567
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
568
+ )
569
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
570
+ if proc.returncode != 0:
571
+ return []
572
+ commits = []
573
+ for line in stdout.decode().strip().splitlines():
574
+ if not line:
575
+ continue
576
+ parts = line.split("\x00")
577
+ if len(parts) >= 6:
578
+ commits.append({
579
+ "hash": parts[0],
580
+ "hash_short": parts[0][:7],
581
+ "parents": parts[1].split() if parts[1] else [],
582
+ "refs": [r.strip() for r in parts[2].split(", ")] if parts[2] else [],
583
+ "message": parts[3],
584
+ "author": parts[4],
585
+ "date": parts[5],
586
+ })
587
+ return commits
588
+
589
+
590
+ async def _git_refs(project_path: Path) -> list[dict]:
591
+ """Get all refs (branches + tags) with their target commits."""
592
+ proc = await asyncio.create_subprocess_exec(
593
+ *_GIT_CMD, "for-each-ref",
594
+ "--format=%(refname:short)%x00%(objectname:short)%x00%(objecttype)",
595
+ "refs/heads", "refs/tags",
596
+ cwd=str(project_path),
597
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
598
+ )
599
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
600
+ if proc.returncode != 0:
601
+ return []
602
+ refs = []
603
+ for line in stdout.decode().strip().splitlines():
604
+ if not line:
605
+ continue
606
+ parts = line.split("\x00")
607
+ if len(parts) >= 3:
608
+ refs.append({"name": parts[0], "hash_short": parts[1], "type": parts[2]})
609
+ return refs
610
+
611
+
612
+ async def _git_commit_detail(project_path: Path, commit_hash: str) -> dict | None:
613
+ """Get detailed info for a single commit."""
614
+ proc = await asyncio.create_subprocess_exec(
615
+ *_GIT_CMD, "show", "--stat", "--format=%H%x00%B%x00%an%x00%ae%x00%aI",
616
+ commit_hash,
617
+ cwd=str(project_path),
618
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
619
+ )
620
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
621
+ if proc.returncode != 0:
622
+ return None
623
+ output = stdout.decode(errors="replace")
624
+ # Format output: first line is header fields separated by \x00, then a blank line,
625
+ # then the body (which may contain \x00 from the %B placeholder),
626
+ # then --stat output.
627
+ # We use a simpler approach: split on first \x00 occurrences for header fields.
628
+ first_null = output.find("\x00")
629
+ if first_null < 0:
630
+ return None
631
+ full_hash = output[:first_null]
632
+ rest = output[first_null + 1:]
633
+ # Find remaining fields: body\x00author\x00email\x00date
634
+ # Body can be multiline so we find from the end
635
+ parts = rest.split("\x00")
636
+ if len(parts) < 4:
637
+ return None
638
+ date_str = parts[-1].split("\n")[0].strip()
639
+ email = parts[-2].strip()
640
+ author = parts[-3].strip()
641
+ body = "\x00".join(parts[:-3]).strip()
642
+ # Extract stat lines (after body, typically after an empty line + stat output)
643
+ stat_lines = []
644
+ in_stats = False
645
+ for line in output.splitlines():
646
+ if line.strip().startswith("|") or (line.strip() and "changed" in line and ("insertion" in line or "deletion" in line)):
647
+ in_stats = True
648
+ if in_stats and line.strip():
649
+ stat_lines.append(line.strip())
650
+ return {
651
+ "hash": full_hash,
652
+ "body": body,
653
+ "author": author,
654
+ "email": email,
655
+ "date": date_str,
656
+ "stats": stat_lines,
657
+ }
658
+
659
+
660
+ # --- Endpoints ---
661
+
662
+
663
+ async def _resolve_git_repo(
664
+ slug: str, user: UserInfo, db: aiosqlite.Connection
665
+ ) -> Path:
666
+ """Adapter helper for the ``git/*`` endpoints: resolve slug -> repo Path.
667
+
668
+ Resolves visibility at the boundary (DECISION 1) and delegates to
669
+ ``uc.resolve_git_repo`` which enforces it (404), then maps the domain error to
670
+ HTTP: missing project -> 404, present-but-non-git -> 400 (via
671
+ ``NoGitRepoError.http_status``). Same 404-vs-400 split as the pre-refactor
672
+ router, just routed through ``to_http``.
673
+ """
674
+ visible_projects = await get_visible_projects(db, user)
675
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
676
+ try:
677
+ return await uc.resolve_git_repo(ctx, db, slug=slug, visible_projects=visible_projects)
678
+ except ServiceError as e:
679
+ raise to_http(e)
680
+
681
+
682
+ @router.get("")
683
+ async def list_programs(
684
+ user: UserInfo = Depends(get_current_user_or_agent),
685
+ db: aiosqlite.Connection = Depends(get_db),
686
+ ) -> list[ProgramInfo]:
687
+ """List all programs with their projects and task counts."""
688
+ # DECISION 1 (visibility template): resolve at the boundary (needs
689
+ # UserInfo.teams/user_id, not carried by CallerContext) and pass it in; the
690
+ # use_case applies it as an inline filter (aggregate listing, no raise).
691
+ visible_projects = await get_visible_projects(db, user)
692
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
693
+ try:
694
+ return await uc.list_programs(ctx, db, visible_projects=visible_projects)
695
+ except ServiceError as e:
696
+ raise to_http(e)
697
+
698
+
699
+ # Default data dir for new work projects
700
+ _DATA_PROJECT_DIR = Path("/data/projects")
701
+
702
+
703
+ def _create_project_on_disk(
704
+ *,
705
+ slug: str,
706
+ display_name: str,
707
+ program: str | None,
708
+ scope: str | None,
709
+ description: str | None,
710
+ lifecycle: str,
711
+ language: str | None,
712
+ type: str,
713
+ ) -> str:
714
+ """Create the project directory tree on disk and return its path.
715
+
716
+ Filesystem side-effect kept in the router (out of the pure use_case): builds
717
+ ``project.yaml`` + ``context.md`` + ``.task`` + subdirs under
718
+ ``/data/projects/{slug}/``, invalidates the project index, and returns the
719
+ metadata path. Raises a :class:`ServiceError` (``http_status = 500``) on
720
+ ``OSError`` with cleanup — parity with the original ``HTTPException(500)``.
721
+ Callers (the use_case) must have already validated non-existence + RBAC.
722
+ """
723
+ project_dir = _DATA_PROJECT_DIR / slug
724
+ today = date.today().isoformat()
725
+
726
+ yaml_data = {
727
+ "project": slug,
728
+ "program": program,
729
+ "scope": scope or "work",
730
+ "description": description or "",
731
+ "lifecycle": lifecycle,
732
+ "phase": "",
733
+ "language": language or "none",
734
+ "stack": [],
735
+ "type": type,
736
+ "repo_path": None,
737
+ "last_session": 0,
738
+ "last_work": None,
739
+ }
740
+
741
+ context_md = (
742
+ f"# {display_name}\n"
743
+ f"\n"
744
+ f"> Metadati strutturati in `project.yaml`\n"
745
+ f"\n"
746
+ f"## Obiettivo\n"
747
+ f"\n"
748
+ f"{description or ''}\n"
749
+ f"\n"
750
+ f"## Status\n"
751
+ f"- Ultimo lavoro: {today}\n"
752
+ f"\n"
753
+ f"## Task Attivi\n"
754
+ f"> Tracciati su Task API: `GET /api/v1/tasks?project={slug}&status=pending`\n"
755
+ )
756
+
757
+ try:
758
+ project_dir.mkdir(parents=False, exist_ok=False)
759
+ for subdir in ["memory", "docs/brainstorms", "docs/plans", "docs/solutions",
760
+ "input", "output", "scripts"]:
761
+ (project_dir / subdir).mkdir(parents=True, exist_ok=True)
762
+ for keep_dir in ["input", "output", "scripts"]:
763
+ (project_dir / keep_dir / ".gitkeep").touch()
764
+ (project_dir / ".task").write_text(slug + "\n")
765
+ (project_dir / "project.yaml").write_text(
766
+ yaml.dump(yaml_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
767
+ )
768
+ (project_dir / "context.md").write_text(context_md)
769
+ except OSError as exc:
770
+ if project_dir.exists():
771
+ shutil.rmtree(project_dir, ignore_errors=True)
772
+ raise ServiceError(
773
+ code="project_create_failed",
774
+ message=f"Failed to create project directory: {exc}",
775
+ ) from exc
776
+
777
+ # Invalidate project index so the new project is immediately visible
778
+ global _index_built_at
779
+ _index_built_at = 0
780
+
781
+ return str(project_dir)
782
+
783
+
784
+ @router.post("", status_code=201)
785
+ async def create_project(
786
+ body: ProjectCreateRequest,
787
+ user: UserInfo = Depends(require_role("operator")),
788
+ db: aiosqlite.Connection = Depends(get_db),
789
+ ) -> ProjectCreateResponse:
790
+ """Create a new work project on the server.
791
+
792
+ Creates the directory structure, project.yaml, context.md, and .task file
793
+ under /data/projects/{slug}/.
794
+ """
795
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
796
+ try:
797
+ return await uc.create_project(
798
+ ctx,
799
+ db,
800
+ slug=body.slug,
801
+ name=body.name,
802
+ program=body.program,
803
+ scope=body.scope,
804
+ description=body.description,
805
+ lifecycle=body.lifecycle,
806
+ language=body.language,
807
+ type=body.type,
808
+ )
809
+ except ServiceError as e:
810
+ raise to_http(e)
811
+
812
+
813
+ @router.get("/{slug}")
814
+ async def get_project(
815
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
816
+ deep_param: bool | None = Query(None, alias="deep"),
817
+ user: UserInfo = Depends(get_current_user_or_agent),
818
+ db: aiosqlite.Connection = Depends(get_db),
819
+ ) -> ProjectDetail:
820
+ """Get project detail with context.md, config, handoffs, plans, solutions."""
821
+ visible_projects = await get_visible_projects(db, user)
822
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
823
+ try:
824
+ detail = await uc.get_project(ctx, db, slug=slug, visible_projects=visible_projects)
825
+ except ServiceError as e:
826
+ raise to_http(e)
827
+
828
+ # DECISION 2: deep KG enrichment is a per-surface (transport) concern — the
829
+ # rate-limit + access-log + lens stay in the adapter, attached after the
830
+ # core ProjectDetail is built. Behavior identical to the pre-refactor router.
831
+ deep = deep_param if deep_param is not None else settings.kg_http_deep_default
832
+ deep_source = "client" if deep_param is not None else "env"
833
+ if deep:
834
+ check_deep_rate_limit(user.username)
835
+ log_kg_deep_access(user.username, "get_project", slug)
836
+ kg_ctx = await build_kg_context_for_project(db, slug, deep=True)
837
+ if kg_ctx is not None:
838
+ kg_ctx.setdefault("meta", {})
839
+ kg_ctx["meta"]["deep_effective"] = deep
840
+ kg_ctx["meta"]["deep_default_source"] = deep_source
841
+ detail.kg_context = kg_ctx
842
+
843
+ return detail
844
+
845
+
846
+ @router.get("/{slug}/brief")
847
+ async def get_session_brief(
848
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
849
+ deep: bool = Query(
850
+ False,
851
+ description="False (default): 5 items per KG bucket. True: 10-15 items — heavier, for cold-start or deep debug.",
852
+ ),
853
+ user: UserInfo = Depends(get_current_user_or_agent),
854
+ db: aiosqlite.Connection = Depends(get_db),
855
+ ) -> dict:
856
+ """Pre-assembled context bundle for agent cold-start.
857
+
858
+ Collapses 5+ sequential MCP calls into a single round-trip. Phase 6.5 B
859
+ adds a ``kg_context`` section (hotspots, recent active nodes, cross-project
860
+ mentions, applicable learnings) gated by ``deep`` for effort budget.
861
+
862
+ Target latency: <500ms standard, <1000ms with ``deep=true``.
863
+ """
864
+ visible_projects = await get_visible_projects(db, user)
865
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
866
+ try:
867
+ return await uc.get_session_brief(
868
+ ctx, db, slug=slug, deep=deep, visible_projects=visible_projects
869
+ )
870
+ except ServiceError as e:
871
+ raise to_http(e)
872
+
873
+
874
+ @router.get("/{slug}/handoffs")
875
+ async def get_handoffs(
876
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
877
+ user: UserInfo = Depends(get_current_user_or_agent),
878
+ db: aiosqlite.Connection = Depends(get_db),
879
+ ) -> list[HandoffEntry]:
880
+ """List handoff files for a project."""
881
+ visible_projects = await get_visible_projects(db, user)
882
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
883
+ try:
884
+ return await uc.get_handoffs(ctx, db, slug=slug, visible_projects=visible_projects)
885
+ except ServiceError as e:
886
+ raise to_http(e)
887
+
888
+
889
+ @router.get("/{slug}/status-updates")
890
+ async def list_project_status_updates_feed(
891
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
892
+ limit: int = Query(20, ge=1, le=50),
893
+ user: UserInfo = Depends(get_current_user_or_agent),
894
+ db: aiosqlite.Connection = Depends(get_db),
895
+ ) -> StatusUpdateFeedResponse:
896
+ """Feed-style status updates for /projects/detail single-pager v2.
897
+
898
+ Merges persisted rows from `project_status_updates` with on-the-fly
899
+ derived entries from recent handoffs (memory/handoff-*.md) and git
900
+ commits (repo_path). Read access is gated by project visibility.
901
+ """
902
+ visible_projects = await get_visible_projects(db, user)
903
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
904
+ try:
905
+ return await uc.list_status_updates_feed(
906
+ ctx, db, slug=slug, limit=limit, visible_projects=visible_projects
907
+ )
908
+ except ServiceError as e:
909
+ raise to_http(e)
910
+
911
+
912
+ @router.post("/{slug}/status-updates", status_code=201)
913
+ async def create_project_status_update_feed(
914
+ body: StatusUpdateFeedCreateRequest,
915
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
916
+ user: UserInfo = Depends(require_role("operator")),
917
+ db: aiosqlite.Connection = Depends(get_write_db),
918
+ ) -> StatusUpdateFeedItem:
919
+ """Create a manual feed entry for a project (operator+)."""
920
+ visible_projects = await get_visible_projects(db, user)
921
+ author_display = getattr(user, "display_name", None) or user.username
922
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
923
+ try:
924
+ return await uc.create_status_update_feed(
925
+ ctx,
926
+ db,
927
+ slug=slug,
928
+ content_md=body.content_md,
929
+ author_display=author_display,
930
+ visible_projects=visible_projects,
931
+ )
932
+ except ServiceError as e:
933
+ raise to_http(e)
934
+
935
+
936
+ @router.get("/{slug}/plans")
937
+ async def get_plans(
938
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
939
+ user: UserInfo = Depends(get_current_user_or_agent),
940
+ db: aiosqlite.Connection = Depends(get_db),
941
+ ) -> list[DocEntry]:
942
+ """List all docs for a project (iterates all subdirs of docs/ dynamically)."""
943
+ visible_projects = await get_visible_projects(db, user)
944
+ ctx = CallerContext.from_user_info(user, is_human_session=False)
945
+ try:
946
+ return await uc.get_plans(ctx, db, slug=slug, visible_projects=visible_projects)
947
+ except ServiceError as e:
948
+ raise to_http(e)
949
+
950
+
951
+ @router.get("/{slug}/git/log")
952
+ async def get_git_log(
953
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
954
+ limit: int = Query(20, ge=1, le=100),
955
+ user: UserInfo = Depends(get_current_user_or_agent),
956
+ db: aiosqlite.Connection = Depends(get_db),
957
+ ) -> list[dict]:
958
+ """Get git log for a project."""
959
+ repo = await _resolve_git_repo(slug, user, db)
960
+ return await _git_log(repo, limit)
961
+
962
+
963
+ @router.get("/{slug}/git/diff")
964
+ async def get_git_diff(
965
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
966
+ user: UserInfo = Depends(get_current_user_or_agent),
967
+ db: aiosqlite.Connection = Depends(get_db),
968
+ ) -> dict:
969
+ """Get git diff for a project."""
970
+ repo = await _resolve_git_repo(slug, user, db)
971
+ return {"diff": await _git_diff(repo)}
972
+
973
+
974
+ @router.get("/{slug}/git/branches")
975
+ async def get_git_branches(
976
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
977
+ user: UserInfo = Depends(get_current_user_or_agent),
978
+ db: aiosqlite.Connection = Depends(get_db),
979
+ ) -> list[dict]:
980
+ """Get git branches for a project."""
981
+ repo = await _resolve_git_repo(slug, user, db)
982
+ return await _git_branches(repo)
983
+
984
+
985
+ @router.post("/{slug}/git/push")
986
+ async def git_push(
987
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
988
+ user: UserInfo = Depends(get_current_user),
989
+ db: aiosqlite.Connection = Depends(get_write_db),
990
+ ) -> dict:
991
+ """Git push (user only, requires confirmation)."""
992
+ repo = await _resolve_git_repo(slug, user, db)
993
+ await _validate_git_remote(repo)
994
+ proc = await asyncio.create_subprocess_exec(
995
+ *_GIT_CMD, "push",
996
+ cwd=str(repo),
997
+ stdout=asyncio.subprocess.PIPE,
998
+ stderr=asyncio.subprocess.PIPE,
999
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
1000
+ )
1001
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
1002
+ if proc.returncode != 0:
1003
+ error_msg = re.sub(r"https?://[^@]*@", "https://***@", stderr.decode().strip())
1004
+ return {"success": False, "error": error_msg}
1005
+ return {"success": True, "output": stdout.decode().strip()}
1006
+
1007
+
1008
+ @router.post("/{slug}/git/pull")
1009
+ async def git_pull(
1010
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
1011
+ user: UserInfo = Depends(get_current_user),
1012
+ db: aiosqlite.Connection = Depends(get_write_db),
1013
+ ) -> dict:
1014
+ """Git pull (user only)."""
1015
+ repo = await _resolve_git_repo(slug, user, db)
1016
+ await _validate_git_remote(repo)
1017
+ proc = await asyncio.create_subprocess_exec(
1018
+ *_GIT_CMD, "pull",
1019
+ cwd=str(repo),
1020
+ stdout=asyncio.subprocess.PIPE,
1021
+ stderr=asyncio.subprocess.PIPE,
1022
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
1023
+ )
1024
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
1025
+ if proc.returncode != 0:
1026
+ error_msg = re.sub(r"https?://[^@]*@", "https://***@", stderr.decode().strip())
1027
+ return {"success": False, "error": error_msg}
1028
+ return {"success": True, "output": stdout.decode().strip()}
1029
+
1030
+
1031
+ @router.get("/{slug}/git/graph")
1032
+ async def get_git_graph(
1033
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
1034
+ limit: int = Query(50, ge=1, le=200),
1035
+ skip: int = Query(0, ge=0),
1036
+ all_branches: bool = Query(True),
1037
+ user: UserInfo = Depends(get_current_user_or_agent),
1038
+ db: aiosqlite.Connection = Depends(get_db),
1039
+ ) -> dict:
1040
+ """Get git graph data: commits with parent hashes + refs for visualization."""
1041
+ repo = await _resolve_git_repo(slug, user, db)
1042
+ commits, refs = await asyncio.gather(
1043
+ _git_graph_log(repo, limit, skip, all_branches),
1044
+ _git_refs(repo),
1045
+ )
1046
+ return {"commits": commits, "refs": refs, "has_more": len(commits) == limit}
1047
+
1048
+
1049
+ @router.get("/{slug}/git/commit/{commit_hash}")
1050
+ async def get_commit_detail(
1051
+ slug: str = PathParam(..., pattern=r"^[a-z0-9][a-z0-9&+_.\-]{0,62}$"),
1052
+ commit_hash: str = PathParam(..., pattern=r"^[a-f0-9]{7,40}$"),
1053
+ user: UserInfo = Depends(get_current_user_or_agent),
1054
+ db: aiosqlite.Connection = Depends(get_db),
1055
+ ) -> dict:
1056
+ """Get detailed info for a single commit (full message, files changed)."""
1057
+ repo = await _resolve_git_repo(slug, user, db)
1058
+ result = await _git_commit_detail(repo, commit_hash)
1059
+ if result is None:
1060
+ raise HTTPException(404, "Commit not found")
1061
+ return result