gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/cli/projects.py ADDED
@@ -0,0 +1,79 @@
1
+ """
2
+ Project management CLI commands.
3
+ """
4
+
5
+ import json
6
+
7
+ import click
8
+
9
+ from gobby.storage.database import LocalDatabase
10
+ from gobby.storage.projects import LocalProjectManager
11
+
12
+
13
+ def get_project_manager() -> LocalProjectManager:
14
+ """Get initialized project manager."""
15
+ db = LocalDatabase()
16
+ return LocalProjectManager(db)
17
+
18
+
19
+ @click.group()
20
+ def projects() -> None:
21
+ """Manage Gobby projects."""
22
+ pass
23
+
24
+
25
+ @projects.command("list")
26
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
27
+ def list_projects(json_format: bool) -> None:
28
+ """List all known projects."""
29
+ manager = get_project_manager()
30
+ projects_list = manager.list()
31
+
32
+ if json_format:
33
+ click.echo(json.dumps([p.to_dict() for p in projects_list], indent=2, default=str))
34
+ return
35
+
36
+ if not projects_list:
37
+ click.echo("No projects found.")
38
+ click.echo("Use 'gobby init' in a project directory to register it.")
39
+ return
40
+
41
+ click.echo(f"Found {len(projects_list)} project(s):\n")
42
+ for project in projects_list:
43
+ path_info = f" {project.repo_path}" if project.repo_path else ""
44
+ click.echo(f" {project.name:<20} {project.id[:12]}{path_info}")
45
+
46
+
47
+ @projects.command("show")
48
+ @click.argument("project_ref")
49
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
50
+ def show_project(project_ref: str, json_format: bool) -> None:
51
+ """Show details for a project.
52
+
53
+ PROJECT_REF can be a project name or UUID.
54
+ """
55
+ manager = get_project_manager()
56
+
57
+ # Try as UUID first, then as name
58
+ project = manager.get(project_ref)
59
+ if not project:
60
+ project = manager.get_by_name(project_ref)
61
+
62
+ if not project:
63
+ click.echo(f"Project not found: {project_ref}", err=True)
64
+ raise SystemExit(1)
65
+
66
+ if json_format:
67
+ click.echo(json.dumps(project.to_dict(), indent=2, default=str))
68
+ return
69
+
70
+ click.echo(f"Project: {project.name}")
71
+ click.echo(f" ID: {project.id}")
72
+ if project.repo_path:
73
+ click.echo(f" Path: {project.repo_path}")
74
+ if project.github_url:
75
+ click.echo(f" GitHub: {project.github_url}")
76
+ if project.github_repo:
77
+ click.echo(f" Repo: {project.github_repo}")
78
+ click.echo(f" Created: {project.created_at}")
79
+ click.echo(f" Updated: {project.updated_at}")
gobby/cli/sessions.py ADDED
@@ -0,0 +1,622 @@
1
+ """
2
+ Session management CLI commands.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from gobby.cli.utils import resolve_project_ref, resolve_session_id
12
+ from gobby.storage.database import LocalDatabase
13
+ from gobby.storage.session_messages import LocalSessionMessageManager
14
+ from gobby.storage.sessions import LocalSessionManager
15
+
16
+
17
+ def get_session_manager() -> LocalSessionManager:
18
+ """Get initialized session manager."""
19
+ db = LocalDatabase()
20
+ return LocalSessionManager(db)
21
+
22
+
23
+ def get_message_manager() -> LocalSessionMessageManager:
24
+ """Get initialized message manager."""
25
+ db = LocalDatabase()
26
+ return LocalSessionMessageManager(db)
27
+
28
+
29
+ def _format_turns_for_llm(turns: list[dict[str, Any]]) -> str:
30
+ """Format transcript turns for LLM analysis."""
31
+ formatted: list[str] = []
32
+ for i, turn in enumerate(turns):
33
+ message = turn.get("message", {})
34
+ role = message.get("role", "unknown")
35
+ content = message.get("content", "")
36
+
37
+ if isinstance(content, list):
38
+ text_parts: list[str] = []
39
+ for block in content:
40
+ if isinstance(block, dict):
41
+ if block.get("type") == "text":
42
+ text_parts.append(str(block.get("text", "")))
43
+ elif block.get("type") == "tool_use":
44
+ text_parts.append(f"[Tool: {block.get('name', 'unknown')}]")
45
+ content = " ".join(text_parts)
46
+
47
+ formatted.append(f"[Turn {i + 1} - {role}]: {content}")
48
+
49
+ return "\n\n".join(formatted)
50
+
51
+
52
+ @click.group()
53
+ def sessions() -> None:
54
+ """Manage Gobby sessions."""
55
+ pass
56
+
57
+
58
+ @sessions.command("list")
59
+ @click.option("--project", "-p", "project_ref", help="Filter by project (name or UUID)")
60
+ @click.option("--status", "-s", help="Filter by status (active, completed, handoff_ready)")
61
+ @click.option("--source", help="Filter by source (claude_code, gemini, codex)")
62
+ @click.option("--limit", "-n", default=20, help="Max sessions to show")
63
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
64
+ def list_sessions(
65
+ project_ref: str | None,
66
+ status: str | None,
67
+ source: str | None,
68
+ limit: int,
69
+ json_format: bool,
70
+ ) -> None:
71
+ """List sessions with optional filtering."""
72
+ project_id = resolve_project_ref(project_ref) if project_ref else None
73
+ manager = get_session_manager()
74
+ sessions_list = manager.list(
75
+ project_id=project_id,
76
+ status=status,
77
+ source=source,
78
+ limit=limit,
79
+ )
80
+
81
+ if json_format:
82
+ click.echo(json.dumps([s.to_dict() for s in sessions_list], indent=2, default=str))
83
+ return
84
+
85
+ if not sessions_list:
86
+ click.echo("No sessions found.")
87
+ return
88
+
89
+ click.echo(f"Found {len(sessions_list)} sessions:\n")
90
+ for session in sessions_list:
91
+ status_icon = {
92
+ "active": "●",
93
+ "completed": "✓",
94
+ "handoff_ready": "→",
95
+ "expired": "○",
96
+ }.get(session.status, "?")
97
+
98
+ title = session.title or "(no title)"
99
+ if len(title) > 50:
100
+ title = title[:47] + "..."
101
+
102
+ cost_str = ""
103
+ if session.usage_total_cost_usd > 0:
104
+ cost_str = f"${session.usage_total_cost_usd:.2f}"
105
+
106
+ seq_str = f"#{session.seq_num}" if session.seq_num else ""
107
+ click.echo(
108
+ f"{status_icon} {seq_str:<5} {session.id[:8]} {session.source:<12} {title:<40} {cost_str}"
109
+ )
110
+
111
+
112
+ @sessions.command("show")
113
+ @click.argument("session_id")
114
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
115
+ def show_session(session_id: str, json_format: bool) -> None:
116
+ """Show details for a session."""
117
+ try:
118
+ session_id = resolve_session_id(session_id)
119
+ except click.ClickException as e:
120
+ raise SystemExit(1) from e
121
+
122
+ manager = get_session_manager()
123
+ session = manager.get(session_id)
124
+
125
+ if not session:
126
+ click.echo(f"Session not found: {session_id}", err=True)
127
+ return
128
+
129
+ if json_format:
130
+ click.echo(json.dumps(session.to_dict(), indent=2, default=str))
131
+ return
132
+
133
+ click.echo(f"Session: {session.id}")
134
+ click.echo(f"Status: {session.status}")
135
+ click.echo(f"Source: {session.source}")
136
+ click.echo(f"Project: {session.project_id}")
137
+ if session.title:
138
+ click.echo(f"Title: {session.title}")
139
+ if session.git_branch:
140
+ click.echo(f"Branch: {session.git_branch}")
141
+ click.echo(f"Created: {session.created_at}")
142
+ click.echo(f"Updated: {session.updated_at}")
143
+ if session.parent_session_id:
144
+ click.echo(f"Parent: {session.parent_session_id}")
145
+ if session.usage_input_tokens > 0 or session.usage_output_tokens > 0:
146
+ click.echo("\nUsage Stats:")
147
+ click.echo(f" Input Tokens: {session.usage_input_tokens}")
148
+ click.echo(f" Output Tokens: {session.usage_output_tokens}")
149
+ click.echo(f" Cache Write: {session.usage_cache_creation_tokens}")
150
+ click.echo(f" Cache Read: {session.usage_cache_read_tokens}")
151
+ click.echo(f" Total Cost: ${session.usage_total_cost_usd:.4f}")
152
+
153
+ if session.summary_markdown:
154
+ click.echo(f"\nSummary:\n{session.summary_markdown[:500]}")
155
+ if len(session.summary_markdown) > 500:
156
+ click.echo("...")
157
+
158
+
159
+ @sessions.command("messages")
160
+ @click.argument("session_id")
161
+ @click.option("--limit", "-n", default=50, help="Max messages to show")
162
+ @click.option("--role", "-r", help="Filter by role (user, assistant, tool)")
163
+ @click.option("--offset", "-o", default=0, help="Skip first N messages")
164
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
165
+ def show_messages(
166
+ session_id: str,
167
+ limit: int,
168
+ role: str | None,
169
+ offset: int,
170
+ json_format: bool,
171
+ ) -> None:
172
+ """Show messages for a session."""
173
+ try:
174
+ session_id = resolve_session_id(session_id)
175
+ except click.ClickException as e:
176
+ raise SystemExit(1) from e
177
+
178
+ session_manager = get_session_manager()
179
+ message_manager = get_message_manager()
180
+
181
+ # Resolve session ID
182
+ session = session_manager.get(session_id)
183
+ if not session:
184
+ click.echo(f"Session not found: {session_id}", err=True)
185
+ return
186
+
187
+ # Fetch messages
188
+ messages = asyncio.run(
189
+ message_manager.get_messages(
190
+ session_id=session.id,
191
+ limit=limit,
192
+ offset=offset,
193
+ role=role,
194
+ )
195
+ )
196
+
197
+ if json_format:
198
+ click.echo(json.dumps(messages, indent=2, default=str))
199
+ return
200
+
201
+ if not messages:
202
+ click.echo("No messages found.")
203
+ return
204
+
205
+ total = asyncio.run(message_manager.count_messages(session.id))
206
+ click.echo(f"Messages for session {session.id[:12]} ({len(messages)}/{total}):\n")
207
+
208
+ for msg in messages:
209
+ role_icon = {"user": "👤", "assistant": "🤖", "tool": "🔧"}.get(msg["role"], "?")
210
+ content = msg.get("content") or ""
211
+
212
+ if msg.get("tool_name"):
213
+ click.echo(f"{role_icon} [{msg['message_index']}] {msg['role']}: {msg['tool_name']}")
214
+ else:
215
+ # Truncate long content
216
+ if len(content) > 200:
217
+ content = content[:197] + "..."
218
+ click.echo(f"{role_icon} [{msg['message_index']}] {msg['role']}: {content}")
219
+
220
+
221
+ @sessions.command("search")
222
+ @click.argument("query")
223
+ @click.option("--session", "-s", "session_id", help="Search within specific session")
224
+ @click.option("--project", "-p", "project_ref", help="Search within project (name or UUID)")
225
+ @click.option("--limit", "-n", default=20, help="Max results")
226
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
227
+ def search_messages(
228
+ query: str,
229
+ session_id: str | None,
230
+ project_ref: str | None,
231
+ limit: int,
232
+ json_format: bool,
233
+ ) -> None:
234
+ """Search messages across sessions."""
235
+ if session_id:
236
+ try:
237
+ session_id = resolve_session_id(session_id)
238
+ except click.ClickException as e:
239
+ raise SystemExit(1) from e
240
+
241
+ project_id = resolve_project_ref(project_ref) if project_ref else None
242
+ message_manager = get_message_manager()
243
+
244
+ results = asyncio.run(
245
+ message_manager.search_messages(
246
+ query_text=query,
247
+ limit=limit,
248
+ session_id=session_id,
249
+ project_id=project_id,
250
+ )
251
+ )
252
+
253
+ if json_format:
254
+ click.echo(json.dumps(results, indent=2, default=str))
255
+ return
256
+
257
+ if not results:
258
+ click.echo(f"No messages found matching '{query}'")
259
+ return
260
+
261
+ click.echo(f"Found {len(results)} messages matching '{query}':\n")
262
+
263
+ for msg in results:
264
+ content = msg.get("content") or ""
265
+ if len(content) > 100:
266
+ content = content[:97] + "..."
267
+
268
+ session_short = msg["session_id"][:8]
269
+ role_icon = {"user": "👤", "assistant": "🤖", "tool": "🔧"}.get(msg["role"], "?")
270
+ click.echo(f"{role_icon} [{session_short}] {content}")
271
+
272
+
273
+ @sessions.command("delete")
274
+ @click.argument("session_id")
275
+ @click.confirmation_option(prompt="Are you sure you want to delete this session?")
276
+ def delete_session(session_id: str) -> None:
277
+ """Delete a session."""
278
+ try:
279
+ session_id = resolve_session_id(session_id)
280
+ except click.ClickException as e:
281
+ raise SystemExit(1) from e
282
+
283
+ manager = get_session_manager()
284
+ session = manager.get(session_id)
285
+ if not session:
286
+ click.echo(f"Session not found: {session_id}", err=True)
287
+ return
288
+
289
+ success = manager.delete(session.id)
290
+ if success:
291
+ click.echo(f"Deleted session: {session.id}")
292
+ else:
293
+ click.echo(f"Failed to delete session: {session.id}", err=True)
294
+
295
+
296
+ @sessions.command("stats")
297
+ @click.option("--project", "-p", "project_ref", help="Filter by project (name or UUID)")
298
+ def session_stats(project_ref: str | None) -> None:
299
+ """Show session statistics."""
300
+ project_id = resolve_project_ref(project_ref) if project_ref else None
301
+ manager = get_session_manager()
302
+ message_manager = get_message_manager()
303
+
304
+ sessions_list = manager.list(project_id=project_id, limit=10000)
305
+
306
+ if not sessions_list:
307
+ click.echo("No sessions found.")
308
+ return
309
+
310
+ # Count by status
311
+ by_status: dict[str, int] = {}
312
+ by_source: dict[str, int] = {}
313
+
314
+ for session in sessions_list:
315
+ by_status[session.status] = by_status.get(session.status, 0) + 1
316
+ by_source[session.source] = by_source.get(session.source, 0) + 1
317
+
318
+ # Get message counts
319
+ message_counts = asyncio.run(message_manager.get_all_counts())
320
+ total_messages = sum(message_counts.values())
321
+
322
+ click.echo("Session Statistics:")
323
+ click.echo(f" Total Sessions: {len(sessions_list)}")
324
+ click.echo(f" Total Messages: {total_messages}")
325
+
326
+ click.echo("\n By Status:")
327
+ for status, count in sorted(by_status.items()):
328
+ click.echo(f" {status}: {count}")
329
+
330
+ click.echo("\n By Source:")
331
+ for source, count in sorted(by_source.items()):
332
+ click.echo(f" {source}: {count}")
333
+
334
+
335
+ @sessions.command("create-handoff")
336
+ @click.option("--session-id", "-s", help="Session ID (defaults to current active session)")
337
+ @click.option("--compact", "-c", is_flag=True, default=False, help="Generate compact summary only")
338
+ @click.option(
339
+ "--full", "full_summary", is_flag=True, default=False, help="Generate full LLM summary only"
340
+ )
341
+ @click.option(
342
+ "--output",
343
+ type=click.Choice(["db", "file", "all"]),
344
+ default="all",
345
+ help="Where to save: db only, file only, or all (both)",
346
+ )
347
+ @click.option(
348
+ "--path",
349
+ "output_path",
350
+ default="~/.gobby/session_summaries/",
351
+ help="Directory path for file output",
352
+ )
353
+ @click.argument("notes", required=False)
354
+ def create_handoff(
355
+ session_id: str | None,
356
+ compact: bool,
357
+ full_summary: bool,
358
+ output: str,
359
+ output_path: str,
360
+ notes: str | None,
361
+ ) -> None:
362
+ """Create handoff context for a session.
363
+
364
+ Extracts structured context from the session transcript:
365
+ - Active gobby-task
366
+ - TodoWrite state
367
+ - Files modified
368
+ - Git commits and status
369
+ - Initial goal
370
+ - Recent activity
371
+
372
+ Summary types:
373
+ - --compact: Fast structured extraction using TranscriptAnalyzer
374
+ - --full: LLM-powered comprehensive summary
375
+ - Neither flag: Generate both (default)
376
+
377
+ Output destinations:
378
+ - db: Save to database only
379
+ - file: Write to file only (in --path directory)
380
+ - all: Save to both database and file
381
+
382
+ File output: full summary saved as session_*.md, compact as session_compact_*.md.
383
+
384
+ If no session ID is provided, uses the current project's most recent active session.
385
+ """
386
+ import subprocess # nosec B404 - subprocess needed for git commands
387
+ import time
388
+ from pathlib import Path
389
+
390
+ from gobby.mcp_proxy.tools.session_messages import _format_handoff_markdown
391
+ from gobby.sessions.analyzer import TranscriptAnalyzer
392
+
393
+ manager = get_session_manager()
394
+
395
+ # Find session
396
+ if session_id:
397
+ try:
398
+ session_id = resolve_session_id(session_id)
399
+ except click.ClickException as e:
400
+ raise SystemExit(1) from e
401
+ session = manager.get(session_id)
402
+ if not session:
403
+ click.echo(f"Session not found: {session_id}", err=True)
404
+ return
405
+ else:
406
+ # Get most recent active session
407
+ try:
408
+ session_id = resolve_session_id(None) # uses get_active_session_id internally
409
+ except click.ClickException as e:
410
+ raise SystemExit(1) from e
411
+ session = manager.get(session_id)
412
+ if not session:
413
+ click.echo(f"Session not found: {session_id}", err=True)
414
+ return
415
+
416
+ # Check for transcript
417
+ if not session.jsonl_path:
418
+ click.echo(f"Session {session.id[:12]} has no transcript path.", err=True)
419
+ return
420
+
421
+ path = Path(session.jsonl_path)
422
+ if not path.exists():
423
+ click.echo(f"Transcript file not found: {path}", err=True)
424
+ return
425
+
426
+ # Read and parse transcript
427
+ turns = []
428
+ with open(path) as f:
429
+ for line_num, line in enumerate(f, start=1):
430
+ if line.strip():
431
+ try:
432
+ turns.append(json.loads(line))
433
+ except json.JSONDecodeError as e:
434
+ snippet = line[:50] + "..." if len(line) > 50 else line.strip()
435
+ click.echo(
436
+ f"Warning: Skipping malformed JSON at line {line_num}: {e} ({snippet})",
437
+ err=True,
438
+ )
439
+ continue
440
+
441
+ if not turns:
442
+ click.echo("Transcript is empty.", err=True)
443
+ return
444
+
445
+ # Analyze transcript
446
+ analyzer = TranscriptAnalyzer()
447
+ handoff_ctx = analyzer.extract_handoff_context(turns)
448
+
449
+ # Determine the git working directory - prefer project repo_path, fall back to transcript parent
450
+ git_cwd = path.parent
451
+ if session.project_id:
452
+ from gobby.storage.projects import LocalProjectManager
453
+
454
+ project_manager = LocalProjectManager(LocalDatabase())
455
+ project = project_manager.get(session.project_id)
456
+ if project and project.repo_path:
457
+ project_repo = Path(project.repo_path)
458
+ if project_repo.exists():
459
+ git_cwd = project_repo
460
+
461
+ # Enrich with real-time git status
462
+ if not handoff_ctx.git_status:
463
+ try:
464
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
465
+ ["git", "status", "--short"],
466
+ capture_output=True,
467
+ text=True,
468
+ timeout=5,
469
+ cwd=git_cwd,
470
+ )
471
+ handoff_ctx.git_status = result.stdout.strip() if result.returncode == 0 else ""
472
+ except Exception:
473
+ pass # nosec B110 - git status is optional
474
+
475
+ # Get recent git commits
476
+ try:
477
+ result = subprocess.run( # nosec B603 B607 - hardcoded git command
478
+ ["git", "log", "--oneline", "-10", "--format=%H|%s"],
479
+ capture_output=True,
480
+ text=True,
481
+ timeout=5,
482
+ cwd=git_cwd,
483
+ )
484
+ if result.returncode == 0:
485
+ commits = []
486
+ for line in result.stdout.strip().split("\n"):
487
+ if "|" in line:
488
+ hash_val, message = line.split("|", 1)
489
+ commits.append({"hash": hash_val, "message": message})
490
+ if commits:
491
+ handoff_ctx.git_commits = commits
492
+ except Exception:
493
+ pass # nosec B110 - git log is optional
494
+
495
+ # Determine what to generate (neither flag = both)
496
+ generate_compact = not full_summary or compact # generate if --compact or neither flag
497
+ generate_full = not compact or full_summary # generate if --full or neither flag
498
+
499
+ # Generate content
500
+ compact_markdown = None
501
+ full_markdown = None
502
+
503
+ if generate_compact:
504
+ compact_markdown = _format_handoff_markdown(handoff_ctx, notes)
505
+
506
+ if generate_full:
507
+ # Generate LLM-powered full summary
508
+ try:
509
+ from gobby.config.app import load_config
510
+ from gobby.llm.claude import ClaudeLLMProvider
511
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
512
+
513
+ config = load_config()
514
+ provider = ClaudeLLMProvider(config)
515
+ transcript_parser = ClaudeTranscriptParser()
516
+
517
+ # Get prompt template from config
518
+ prompt_template = None
519
+ if hasattr(config, "session_summary") and config.session_summary:
520
+ prompt_template = getattr(config.session_summary, "prompt", None)
521
+
522
+ if not prompt_template:
523
+ click.echo(
524
+ "Warning: No prompt template configured. "
525
+ "Set 'session_summary.prompt' in ~/.gobby/config.yaml",
526
+ err=True,
527
+ )
528
+ # Only fail if --full was explicitly requested without --compact
529
+ if full_summary and not compact:
530
+ return
531
+ # Otherwise, skip full generation but continue with compact
532
+ else:
533
+ # Prepare context for LLM
534
+ last_turns = transcript_parser.extract_turns_since_clear(turns, max_turns=50)
535
+ last_messages = transcript_parser.extract_last_messages(turns, num_pairs=2)
536
+
537
+ context = {
538
+ "transcript_summary": _format_turns_for_llm(last_turns),
539
+ "last_messages": last_messages,
540
+ "git_status": handoff_ctx.git_status or "",
541
+ "file_changes": "",
542
+ "external_id": session.id[:12],
543
+ "session_id": session.id,
544
+ "session_source": session.source,
545
+ }
546
+
547
+ import anyio
548
+
549
+ async def _generate() -> str:
550
+ return await provider.generate_summary(context, prompt_template=prompt_template)
551
+
552
+ full_markdown = anyio.run(_generate)
553
+
554
+ except Exception as e:
555
+ click.echo(f"Warning: Failed to generate full summary: {e}", err=True)
556
+ if full_summary and not compact:
557
+ # Only --full was requested and it failed
558
+ return
559
+
560
+ # Determine what to save
561
+ save_to_db = output in ("db", "all")
562
+ save_to_file = output in ("file", "all")
563
+
564
+ # Save to database - always save both compact and full when available
565
+ if save_to_db:
566
+ if compact_markdown:
567
+ manager.update_compact_markdown(session.id, compact_markdown)
568
+ click.echo(f"Saved compact to database: {len(compact_markdown)} chars")
569
+ if full_markdown:
570
+ manager.update_summary(session.id, summary_markdown=full_markdown)
571
+ click.echo(f"Saved full to database: {len(full_markdown)} chars")
572
+
573
+ # Save to file
574
+ files_written = []
575
+ if save_to_file:
576
+ try:
577
+ summary_dir = Path(output_path).expanduser()
578
+ summary_dir.mkdir(parents=True, exist_ok=True)
579
+ timestamp = int(time.time())
580
+
581
+ # Write full summary as session_*.md
582
+ if full_markdown:
583
+ full_file = summary_dir / f"session_{timestamp}_{session.id[:12]}.md"
584
+ full_file.write_text(full_markdown, encoding="utf-8")
585
+ files_written.append(str(full_file))
586
+ click.echo(f"Saved full to file: {full_file}")
587
+
588
+ # Write compact summary as session_compact_*.md
589
+ if compact_markdown:
590
+ compact_file = summary_dir / f"session_compact_{timestamp}_{session.id[:12]}.md"
591
+ compact_file.write_text(compact_markdown, encoding="utf-8")
592
+ files_written.append(str(compact_file))
593
+ click.echo(f"Saved compact to file: {compact_file}")
594
+
595
+ except Exception as e:
596
+ click.echo(f"Error writing file: {e}", err=True)
597
+
598
+ # Output summary
599
+ summary_type = "none"
600
+ if compact_markdown and full_markdown:
601
+ summary_type = "both"
602
+ elif compact_markdown:
603
+ summary_type = "compact"
604
+ elif full_markdown:
605
+ summary_type = "full"
606
+ click.echo(f"\nCreated handoff context for session {session.id[:12]}")
607
+ click.echo(f" Type: {summary_type}")
608
+ click.echo(f" Output: {output}")
609
+ if compact_markdown:
610
+ click.echo(f" Compact length: {len(compact_markdown)} chars")
611
+ if full_markdown:
612
+ click.echo(f" Full length: {len(full_markdown)} chars")
613
+ click.echo(f" Active task: {'Yes' if handoff_ctx.active_gobby_task else 'No'}")
614
+ click.echo(f" Todo items: {len(handoff_ctx.todo_state)}")
615
+ click.echo(f" Files modified: {len(handoff_ctx.files_modified)}")
616
+ click.echo(f" Git commits: {len(handoff_ctx.git_commits)}")
617
+ click.echo(f" Initial goal: {'Yes' if handoff_ctx.initial_goal else 'No'}")
618
+
619
+ if notes:
620
+ click.echo(f" Notes: {notes[:50]}{'...' if len(notes) > 50 else ''}")
621
+ for file_path in files_written:
622
+ click.echo(f" File: {file_path}")