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/workflows.py ADDED
@@ -0,0 +1,927 @@
1
+ """
2
+ CLI commands for managing Gobby workflows.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import yaml
11
+
12
+ from gobby.cli.utils import resolve_session_id
13
+ from gobby.storage.database import LocalDatabase
14
+ from gobby.workflows.loader import WorkflowLoader
15
+ from gobby.workflows.state_manager import WorkflowStateManager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_workflow_loader() -> WorkflowLoader:
21
+ """Get workflow loader instance."""
22
+ return WorkflowLoader()
23
+
24
+
25
+ def get_state_manager() -> WorkflowStateManager:
26
+ """Get workflow state manager instance."""
27
+ db = LocalDatabase()
28
+ return WorkflowStateManager(db)
29
+
30
+
31
+ def get_project_path() -> Path | None:
32
+ """Get current project path if in a gobby project."""
33
+ cwd = Path.cwd()
34
+ if (cwd / ".gobby").exists():
35
+ return cwd
36
+ return None
37
+
38
+
39
+ @click.group()
40
+ def workflows() -> None:
41
+ """Manage Gobby workflows."""
42
+ pass
43
+
44
+
45
+ @workflows.command("list")
46
+ @click.option(
47
+ "--all", "-a", "show_all", is_flag=True, help="Show all workflows including step-based"
48
+ )
49
+ @click.option("--global", "-g", "global_only", is_flag=True, help="Show only global workflows")
50
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
51
+ @click.pass_context
52
+ def list_workflows(
53
+ ctx: click.Context, show_all: bool, global_only: bool, json_format: bool
54
+ ) -> None:
55
+ """List available workflows."""
56
+ loader = get_workflow_loader()
57
+ project_path = get_project_path() if not global_only else None
58
+
59
+ # Build search directories
60
+ search_dirs = list(loader.global_dirs)
61
+ if project_path:
62
+ project_dir = project_path / ".gobby" / "workflows"
63
+ search_dirs.insert(0, project_dir)
64
+
65
+ workflows = []
66
+ seen_names = set()
67
+
68
+ for search_dir in search_dirs:
69
+ if not search_dir.exists():
70
+ continue
71
+
72
+ is_project = (
73
+ search_dir == (project_path / ".gobby" / "workflows") if project_path else False
74
+ )
75
+
76
+ for yaml_path in search_dir.glob("*.yaml"):
77
+ name = yaml_path.stem
78
+ if name in seen_names:
79
+ continue # Project shadows global
80
+
81
+ try:
82
+ with open(yaml_path) as f:
83
+ data = yaml.safe_load(f)
84
+
85
+ if not data:
86
+ continue
87
+
88
+ wf_type = data.get("type", "step")
89
+ description = data.get("description", "")
90
+
91
+ # Filter by type unless --all
92
+ if not show_all and wf_type != "lifecycle":
93
+ pass # Show all by default now
94
+
95
+ workflows.append(
96
+ {
97
+ "name": name,
98
+ "type": wf_type,
99
+ "description": description,
100
+ "source": "project" if is_project else "global",
101
+ "path": str(yaml_path),
102
+ }
103
+ )
104
+ seen_names.add(name)
105
+
106
+ except Exception as e:
107
+ logger.warning(f"Failed to load workflow from {yaml_path}: {e}")
108
+
109
+ if json_format:
110
+ click.echo(json.dumps({"workflows": workflows, "count": len(workflows)}, indent=2))
111
+ return
112
+
113
+ if not workflows:
114
+ click.echo("No workflows found.")
115
+ click.echo(f"Search directories: {[str(d) for d in search_dirs]}")
116
+ return
117
+
118
+ click.echo(f"Found {len(workflows)} workflow(s):\n")
119
+ for wf in workflows:
120
+ source_tag = f"[{wf['source']}]" if wf["source"] == "project" else ""
121
+ type_tag = f"({wf['type']})"
122
+ click.echo(f" {wf['name']} {type_tag} {source_tag}")
123
+ if wf["description"]:
124
+ click.echo(f" {wf['description'][:80]}")
125
+
126
+
127
+ @workflows.command("show")
128
+ @click.argument("name")
129
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
130
+ @click.pass_context
131
+ def show_workflow(ctx: click.Context, name: str, json_format: bool) -> None:
132
+ """Show workflow details."""
133
+ loader = get_workflow_loader()
134
+ project_path = get_project_path()
135
+
136
+ definition = loader.load_workflow(name, project_path)
137
+ if not definition:
138
+ click.echo(f"Workflow '{name}' not found.", err=True)
139
+ raise SystemExit(1)
140
+
141
+ if json_format:
142
+ click.echo(json.dumps(definition.dict(), indent=2, default=str))
143
+ return
144
+
145
+ click.echo(f"Workflow: {definition.name}")
146
+ click.echo(f"Type: {definition.type}")
147
+ if definition.description:
148
+ click.echo(f"Description: {definition.description}")
149
+ if definition.version:
150
+ click.echo(f"Version: {definition.version}")
151
+
152
+ if definition.steps:
153
+ click.echo(f"\nSteps ({len(definition.steps)}):")
154
+ for step in definition.steps:
155
+ click.echo(f" - {step.name}")
156
+ if step.description:
157
+ click.echo(f" {step.description}")
158
+ if step.allowed_tools:
159
+ if step.allowed_tools == "all":
160
+ click.echo(" Allowed tools: all")
161
+ else:
162
+ tools = step.allowed_tools[:5]
163
+ more = (
164
+ f" (+{len(step.allowed_tools) - 5})" if len(step.allowed_tools) > 5 else ""
165
+ )
166
+ click.echo(f" Allowed tools: {', '.join(tools)}{more}")
167
+ if step.blocked_tools:
168
+ click.echo(f" Blocked tools: {', '.join(step.blocked_tools[:5])}")
169
+
170
+ if definition.triggers:
171
+ click.echo("\nTriggers:")
172
+ for trigger_name, actions in definition.triggers.items():
173
+ click.echo(f" {trigger_name}: {len(actions)} action(s)")
174
+
175
+
176
+ @workflows.command("status")
177
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
178
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
179
+ @click.pass_context
180
+ def workflow_status(ctx: click.Context, session_id: str | None, json_format: bool) -> None:
181
+ """Show current workflow state for a session."""
182
+ state_manager = get_state_manager()
183
+
184
+ if not session_id:
185
+ try:
186
+ session_id = resolve_session_id(None)
187
+ except click.ClickException as e:
188
+ # Re-raise to match expected behavior or exit
189
+ raise SystemExit(1) from e
190
+ else:
191
+ try:
192
+ session_id = resolve_session_id(session_id)
193
+ except click.ClickException as e:
194
+ raise SystemExit(1) from e
195
+
196
+ state = state_manager.get_state(session_id)
197
+
198
+ if not state:
199
+ if json_format:
200
+ click.echo(json.dumps({"session_id": session_id, "has_workflow": False}))
201
+ else:
202
+ click.echo(f"No workflow active for session: {session_id[:12]}...")
203
+ return
204
+
205
+ if json_format:
206
+ click.echo(
207
+ json.dumps(
208
+ {
209
+ "session_id": session_id,
210
+ "has_workflow": True,
211
+ "workflow_name": state.workflow_name,
212
+ "step": state.step,
213
+ "step_action_count": state.step_action_count,
214
+ "total_action_count": state.total_action_count,
215
+ "reflection_pending": state.reflection_pending,
216
+ "disabled": state.disabled,
217
+ "disabled_reason": state.disabled_reason,
218
+ "artifacts": list(state.artifacts.keys()) if state.artifacts else [],
219
+ "updated_at": state.updated_at.isoformat() if state.updated_at else None,
220
+ },
221
+ indent=2,
222
+ )
223
+ )
224
+ return
225
+
226
+ click.echo(f"Session: {session_id[:12]}...")
227
+ click.echo(f"Workflow: {state.workflow_name}")
228
+ click.echo(f"Step: {state.step}")
229
+ click.echo(f"Actions in step: {state.step_action_count}")
230
+ click.echo(f"Total actions: {state.total_action_count}")
231
+
232
+ if state.disabled:
233
+ click.echo(f"⚠️ DISABLED{f': {state.disabled_reason}' if state.disabled_reason else ''}")
234
+ click.echo(" Use 'gobby workflows enable' to re-enable enforcement.")
235
+
236
+ if state.reflection_pending:
237
+ click.echo("⚠️ Reflection pending")
238
+
239
+ if state.artifacts:
240
+ click.echo(f"Artifacts: {', '.join(state.artifacts.keys())}")
241
+
242
+ if state.task_list:
243
+ click.echo(f"Task progress: {state.current_task_index + 1}/{len(state.task_list)}")
244
+
245
+
246
+ @workflows.command("set")
247
+ @click.argument("name")
248
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
249
+ @click.option("--step", "-p", "initial_step", help="Initial step (defaults to first)")
250
+ @click.pass_context
251
+ def set_workflow(
252
+ ctx: click.Context, name: str, session_id: str | None, initial_step: str | None
253
+ ) -> None:
254
+ """Activate a workflow for a session."""
255
+ from datetime import UTC, datetime
256
+
257
+ from gobby.workflows.definitions import WorkflowState
258
+
259
+ loader = get_workflow_loader()
260
+ state_manager = get_state_manager()
261
+ project_path = get_project_path()
262
+
263
+ # Load workflow
264
+ definition = loader.load_workflow(name, project_path)
265
+ if not definition:
266
+ click.echo(f"Workflow '{name}' not found.", err=True)
267
+ raise SystemExit(1)
268
+
269
+ if definition.type == "lifecycle":
270
+ click.echo(f"Workflow '{name}' is a lifecycle workflow (auto-runs on events).", err=True)
271
+ click.echo("Use 'gobby workflows set' only for step-based workflows.", err=True)
272
+ raise SystemExit(1)
273
+
274
+ # Get session
275
+ try:
276
+ session_id = resolve_session_id(session_id)
277
+ except click.ClickException as e:
278
+ raise SystemExit(1) from e
279
+
280
+ # Check for existing workflow
281
+ existing = state_manager.get_state(session_id)
282
+ if existing:
283
+ click.echo(f"Session already has workflow '{existing.workflow_name}' active.")
284
+ click.echo("Use 'gobby workflows clear' first to remove it.")
285
+ raise SystemExit(1)
286
+
287
+ # Determine initial step
288
+ if initial_step:
289
+ if not any(s.name == initial_step for s in definition.steps):
290
+ click.echo(f"Step '{initial_step}' not found in workflow.", err=True)
291
+ raise SystemExit(1)
292
+ step = initial_step
293
+ else:
294
+ step = definition.steps[0].name if definition.steps else "default"
295
+
296
+ # Create state
297
+ state = WorkflowState(
298
+ session_id=session_id,
299
+ workflow_name=name,
300
+ step=step,
301
+ initial_step=step, # Track for reset functionality
302
+ step_entered_at=datetime.now(UTC),
303
+ step_action_count=0,
304
+ total_action_count=0,
305
+ artifacts={},
306
+ observations=[],
307
+ reflection_pending=False,
308
+ context_injected=False,
309
+ variables={},
310
+ task_list=None,
311
+ current_task_index=0,
312
+ files_modified_this_task=0,
313
+ )
314
+
315
+ state_manager.save_state(state)
316
+ click.echo(f"✓ Activated workflow '{name}' for session {session_id[:12]}...")
317
+ click.echo(f" Starting step: {step}")
318
+
319
+
320
+ @workflows.command("clear")
321
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
322
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
323
+ @click.pass_context
324
+ def clear_workflow(ctx: click.Context, session_id: str | None, force: bool) -> None:
325
+ """Clear/deactivate workflow for a session."""
326
+ state_manager = get_state_manager()
327
+
328
+ try:
329
+ session_id = resolve_session_id(session_id)
330
+ except click.ClickException as e:
331
+ raise SystemExit(1) from e
332
+
333
+ state = state_manager.get_state(session_id)
334
+ if not state:
335
+ click.echo(f"No workflow active for session: {session_id[:12]}...")
336
+ return
337
+
338
+ if not force:
339
+ click.confirm(
340
+ f"Clear workflow '{state.workflow_name}' from session?",
341
+ abort=True,
342
+ )
343
+
344
+ state_manager.delete_state(session_id)
345
+ click.echo(f"✓ Cleared workflow from session {session_id[:12]}...")
346
+
347
+
348
+ @workflows.command("step")
349
+ @click.argument("step_name")
350
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
351
+ @click.option("--force", "-f", is_flag=True, help="Skip exit condition checks")
352
+ @click.pass_context
353
+ def set_step(ctx: click.Context, step_name: str, session_id: str | None, force: bool) -> None:
354
+ """Manually transition to a step (escape hatch)."""
355
+ from datetime import UTC, datetime
356
+
357
+ loader = get_workflow_loader()
358
+ state_manager = get_state_manager()
359
+ project_path = get_project_path()
360
+
361
+ try:
362
+ session_id = resolve_session_id(session_id)
363
+ except click.ClickException as e:
364
+ raise SystemExit(1) from e
365
+
366
+ state = state_manager.get_state(session_id)
367
+ if not state:
368
+ click.echo(f"No workflow active for session: {session_id[:12]}...", err=True)
369
+ raise SystemExit(1)
370
+
371
+ # Load workflow to validate step
372
+ definition = loader.load_workflow(state.workflow_name, project_path)
373
+ if not definition:
374
+ click.echo(f"Workflow '{state.workflow_name}' not found.", err=True)
375
+ raise SystemExit(1)
376
+
377
+ if not any(s.name == step_name for s in definition.steps):
378
+ click.echo(f"Step '{step_name}' not found in workflow.", err=True)
379
+ click.echo(f"Available steps: {', '.join(s.name for s in definition.steps)}")
380
+ raise SystemExit(1)
381
+
382
+ if not force and state.step != step_name:
383
+ click.echo(f"⚠️ Manual step transition from '{state.step}' to '{step_name}'")
384
+ click.confirm("This skips normal exit conditions. Continue?", abort=True)
385
+
386
+ old_step = state.step
387
+ state.step = step_name
388
+ state.step_entered_at = datetime.now(UTC)
389
+ state.step_action_count = 0
390
+
391
+ state_manager.save_state(state)
392
+ click.echo(f"✓ Transitioned from '{old_step}' to '{step_name}'")
393
+
394
+
395
+ @workflows.command("reset")
396
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
397
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
398
+ @click.pass_context
399
+ def reset_workflow(ctx: click.Context, session_id: str | None, force: bool) -> None:
400
+ """Reset workflow to initial step (escape hatch)."""
401
+ from datetime import UTC, datetime
402
+
403
+ state_manager = get_state_manager()
404
+
405
+ try:
406
+ session_id = resolve_session_id(session_id)
407
+ except click.ClickException as e:
408
+ raise SystemExit(1) from e
409
+
410
+ state = state_manager.get_state(session_id)
411
+ if not state:
412
+ click.echo(f"No workflow active for session: {session_id[:12]}...", err=True)
413
+ raise SystemExit(1)
414
+
415
+ # Determine initial step
416
+ initial_step = state.initial_step or state.step
417
+ if state.step == initial_step:
418
+ click.echo(f"Workflow is already at initial step '{initial_step}'")
419
+ return
420
+
421
+ if not force:
422
+ click.echo(f"⚠️ Reset workflow from '{state.step}' to initial step '{initial_step}'")
423
+ click.confirm("This will clear all step state and variables. Continue?", abort=True)
424
+
425
+ # Reset state
426
+ state.step = initial_step
427
+ state.step_entered_at = datetime.now(UTC)
428
+ state.step_action_count = 0
429
+ state.variables = {}
430
+ state.approval_pending = False
431
+ state.approval_condition_id = None
432
+ state.approval_prompt = None
433
+ state.disabled = False
434
+ state.disabled_reason = None
435
+
436
+ state_manager.save_state(state)
437
+ click.echo(f"✓ Reset workflow to initial step '{initial_step}'")
438
+
439
+
440
+ @workflows.command("disable")
441
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
442
+ @click.option("--reason", "-r", help="Reason for disabling")
443
+ @click.pass_context
444
+ def disable_workflow(ctx: click.Context, session_id: str | None, reason: str | None) -> None:
445
+ """Temporarily disable workflow enforcement (escape hatch)."""
446
+ state_manager = get_state_manager()
447
+
448
+ try:
449
+ session_id = resolve_session_id(session_id)
450
+ except click.ClickException as e:
451
+ raise SystemExit(1) from e
452
+
453
+ state = state_manager.get_state(session_id)
454
+ if not state:
455
+ click.echo(f"No workflow active for session: {session_id[:12]}...", err=True)
456
+ raise SystemExit(1)
457
+
458
+ if state.disabled:
459
+ click.echo(f"Workflow '{state.workflow_name}' is already disabled.")
460
+ return
461
+
462
+ state.disabled = True
463
+ state.disabled_reason = reason
464
+
465
+ state_manager.save_state(state)
466
+ click.echo(f"✓ Disabled workflow '{state.workflow_name}'")
467
+ click.echo(" Tool restrictions and step enforcement are now suspended.")
468
+ click.echo(" Use 'gobby workflows enable' to re-enable.")
469
+
470
+
471
+ @workflows.command("enable")
472
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
473
+ @click.pass_context
474
+ def enable_workflow(ctx: click.Context, session_id: str | None) -> None:
475
+ """Re-enable a disabled workflow."""
476
+ state_manager = get_state_manager()
477
+
478
+ try:
479
+ session_id = resolve_session_id(session_id)
480
+ except click.ClickException as e:
481
+ raise SystemExit(1) from e
482
+
483
+ state = state_manager.get_state(session_id)
484
+ if not state:
485
+ click.echo(f"No workflow active for session: {session_id[:12]}...", err=True)
486
+ raise SystemExit(1)
487
+
488
+ if not state.disabled:
489
+ click.echo(f"Workflow '{state.workflow_name}' is not disabled.")
490
+ return
491
+
492
+ state.disabled = False
493
+ state.disabled_reason = None
494
+
495
+ state_manager.save_state(state)
496
+ click.echo(f"✓ Re-enabled workflow '{state.workflow_name}'")
497
+ click.echo(f" Current step: {state.step}")
498
+
499
+
500
+ @workflows.command("artifact")
501
+ @click.argument("artifact_type")
502
+ @click.argument("file_path")
503
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
504
+ @click.pass_context
505
+ def mark_artifact(
506
+ ctx: click.Context, artifact_type: str, file_path: str, session_id: str | None
507
+ ) -> None:
508
+ """Mark an artifact as complete (plan, spec, test, etc.)."""
509
+ state_manager = get_state_manager()
510
+
511
+ try:
512
+ session_id = resolve_session_id(session_id)
513
+ except click.ClickException as e:
514
+ raise SystemExit(1) from e
515
+
516
+ state = state_manager.get_state(session_id)
517
+ if not state:
518
+ click.echo(f"No workflow active for session: {session_id[:12]}...", err=True)
519
+ raise SystemExit(1)
520
+
521
+ # Update artifacts
522
+ state.artifacts[artifact_type] = file_path
523
+ state_manager.save_state(state)
524
+
525
+ click.echo(f"✓ Marked '{artifact_type}' artifact complete: {file_path}")
526
+ if len(state.artifacts) > 1:
527
+ click.echo(f" All artifacts: {', '.join(state.artifacts.keys())}")
528
+
529
+
530
+ @workflows.command("import")
531
+ @click.argument("source")
532
+ @click.option("--name", "-n", help="Override workflow name")
533
+ @click.option("--global", "-g", "is_global", is_flag=True, help="Install to global directory")
534
+ @click.pass_context
535
+ def import_workflow(ctx: click.Context, source: str, name: str | None, is_global: bool) -> None:
536
+ """Import a workflow from a file or URL."""
537
+ import shutil
538
+ from urllib.parse import urlparse
539
+
540
+ # Determine if URL or file
541
+ parsed = urlparse(source)
542
+ is_url = parsed.scheme in ("http", "https")
543
+
544
+ if is_url:
545
+ click.echo("URL import not yet implemented. Download the file and import locally.")
546
+ raise SystemExit(1)
547
+
548
+ # File import
549
+ source_path = Path(source)
550
+ if not source_path.exists():
551
+ click.echo(f"File not found: {source}", err=True)
552
+ raise SystemExit(1)
553
+
554
+ if not source_path.suffix == ".yaml":
555
+ click.echo("Workflow file must have .yaml extension.", err=True)
556
+ raise SystemExit(1)
557
+
558
+ # Validate it's a valid workflow
559
+ try:
560
+ with open(source_path) as f:
561
+ data = yaml.safe_load(f)
562
+
563
+ if not data or "name" not in data:
564
+ click.echo("Invalid workflow: missing 'name' field.", err=True)
565
+ raise SystemExit(1)
566
+
567
+ except yaml.YAMLError as e:
568
+ click.echo(f"Invalid YAML: {e}", err=True)
569
+ raise SystemExit(1) from None
570
+
571
+ # Determine destination
572
+ workflow_name = name or data.get("name", source_path.stem)
573
+ filename = f"{workflow_name}.yaml"
574
+
575
+ if is_global:
576
+ dest_dir = Path.home() / ".gobby" / "workflows"
577
+ else:
578
+ project_path = get_project_path()
579
+ if not project_path:
580
+ click.echo("Not in a gobby project. Use --global to install globally.", err=True)
581
+ raise SystemExit(1)
582
+ dest_dir = project_path / ".gobby" / "workflows"
583
+
584
+ dest_dir.mkdir(parents=True, exist_ok=True)
585
+ dest_path = dest_dir / filename
586
+
587
+ if dest_path.exists():
588
+ click.confirm(f"Workflow '{workflow_name}' already exists. Overwrite?", abort=True)
589
+
590
+ shutil.copy(source_path, dest_path)
591
+ click.echo(f"✓ Imported workflow '{workflow_name}' to {dest_path}")
592
+
593
+
594
+ @workflows.command("reload")
595
+ @click.pass_context
596
+ def reload_workflows(ctx: click.Context) -> None:
597
+ """Reload workflow definitions from disk."""
598
+ import httpx
599
+ import psutil
600
+
601
+ from gobby.config.app import load_config
602
+
603
+ # Try to tell daemon to reload
604
+ try:
605
+ config = load_config()
606
+ port = config.daemon_port
607
+
608
+ # Check if running
609
+ is_running = False
610
+ try:
611
+ for proc in psutil.process_iter(["pid", "name", "cmdline"]):
612
+ try:
613
+ cmdline = proc.cmdline()
614
+ if "gobby" in cmdline and "start" in cmdline:
615
+ is_running = True
616
+ break
617
+ # Also check for "python -m gobby start" or similar
618
+ if len(cmdline) >= 2 and cmdline[1].endswith("gobby") and "start" in cmdline:
619
+ is_running = True
620
+ break
621
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
622
+ continue
623
+ except Exception:
624
+ # Fallback to connection attempt
625
+ is_running = True
626
+
627
+ if is_running:
628
+ try:
629
+ response = httpx.post(
630
+ f"http://localhost:{port}/admin/workflows/reload", timeout=2.0
631
+ )
632
+ if response.status_code == 200:
633
+ data = response.json()
634
+ if data.get("status") == "success":
635
+ click.echo("✓ Triggered daemon workflow reload")
636
+ return
637
+ else:
638
+ click.echo(f"Daemon reload failed: {data.get('message')}", err=True)
639
+ else:
640
+ click.echo(f"Daemon returned status {response.status_code}", err=True)
641
+ except httpx.ConnectError:
642
+ # Daemon not actually running or listening
643
+ pass
644
+ except Exception as e:
645
+ click.echo(f"Failed to communicate with daemon: {e}", err=True)
646
+ except Exception as e:
647
+ logger.debug(f"Error checking daemon status: {e}")
648
+
649
+ # Fallback: Clear local cache (useful if running in same process or just validating)
650
+ # This also helps if the user just wants to verify the command runs
651
+ loader = get_workflow_loader()
652
+ loader.clear_cache()
653
+ click.echo("✓ Cleared local workflow cache")
654
+
655
+
656
+ @workflows.command("audit")
657
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
658
+ @click.option(
659
+ "--type",
660
+ "-t",
661
+ "event_type",
662
+ help="Filter by event type (tool_call, rule_eval, transition, approval)",
663
+ )
664
+ @click.option("--result", "-r", help="Filter by result (allow, block, transition)")
665
+ @click.option("--limit", "-n", default=50, help="Maximum entries to show (default: 50)")
666
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
667
+ @click.pass_context
668
+ def audit_workflow(
669
+ ctx: click.Context,
670
+ session_id: str | None,
671
+ event_type: str | None,
672
+ result: str | None,
673
+ limit: int,
674
+ json_format: bool,
675
+ ) -> None:
676
+ """View workflow audit log (explainability/debugging)."""
677
+ from gobby.storage.workflow_audit import WorkflowAuditManager
678
+
679
+ audit_manager = WorkflowAuditManager()
680
+
681
+ try:
682
+ session_id = resolve_session_id(session_id)
683
+ except click.ClickException as e:
684
+ raise SystemExit(1) from e
685
+
686
+ entries = audit_manager.get_entries(
687
+ session_id=session_id,
688
+ event_type=event_type,
689
+ result=result,
690
+ limit=limit,
691
+ )
692
+
693
+ if not entries:
694
+ click.echo(f"No audit entries found for session {session_id[:12]}...")
695
+ return
696
+
697
+ if json_format:
698
+ output = []
699
+ for entry in entries:
700
+ output.append(
701
+ {
702
+ "id": entry.id,
703
+ "timestamp": entry.timestamp.isoformat(),
704
+ "step": entry.step,
705
+ "event_type": entry.event_type,
706
+ "tool_name": entry.tool_name,
707
+ "rule_id": entry.rule_id,
708
+ "condition": entry.condition,
709
+ "result": entry.result,
710
+ "reason": entry.reason,
711
+ "context": entry.context,
712
+ }
713
+ )
714
+ click.echo(json.dumps(output, indent=2))
715
+ return
716
+
717
+ # Human-readable output
718
+ click.echo(f"Audit log for session {session_id[:12]}... ({len(entries)} entries)\n")
719
+
720
+ for entry in entries:
721
+ # Format: [timestamp] RESULT event_type
722
+ timestamp_str = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S")
723
+ result_color = {
724
+ "allow": "green",
725
+ "block": "red",
726
+ "transition": "yellow",
727
+ "approved": "green",
728
+ "rejected": "red",
729
+ "pending": "yellow",
730
+ }.get(entry.result, "white")
731
+
732
+ click.echo(f"[{timestamp_str}] ", nl=False)
733
+ click.secho(entry.result.upper(), fg=result_color, nl=False)
734
+ click.echo(f" {entry.event_type}")
735
+
736
+ click.echo(f" Step: {entry.step}")
737
+
738
+ if entry.tool_name:
739
+ click.echo(f" Tool: {entry.tool_name}")
740
+ if entry.rule_id:
741
+ click.echo(f" Rule: {entry.rule_id}")
742
+ if entry.condition:
743
+ click.echo(f" Condition: {entry.condition}")
744
+ if entry.reason:
745
+ click.echo(f" Reason: {entry.reason}")
746
+
747
+ click.echo() # Blank line between entries
748
+
749
+
750
+ @workflows.command("set-var")
751
+ @click.argument("name")
752
+ @click.argument("value")
753
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
754
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
755
+ @click.pass_context
756
+ def set_variable(
757
+ ctx: click.Context, name: str, value: str, session_id: str | None, json_format: bool
758
+ ) -> None:
759
+ """Set a workflow variable for the current session.
760
+
761
+ Variables are session-scoped (not persisted to YAML files).
762
+
763
+ Examples:
764
+
765
+ gobby workflows set-var session_epic #47
766
+
767
+ gobby workflows set-var is_worktree true
768
+
769
+ gobby workflows set-var max_retries 5
770
+ """
771
+ from datetime import UTC, datetime
772
+
773
+ from gobby.workflows.definitions import WorkflowState
774
+
775
+ state_manager = get_state_manager()
776
+
777
+ if not session_id:
778
+ db = LocalDatabase()
779
+ row = db.fetchone(
780
+ "SELECT id FROM sessions WHERE status = 'active' ORDER BY updated_at DESC LIMIT 1"
781
+ )
782
+ if row:
783
+ session_id = row["id"]
784
+ else:
785
+ click.echo("No active session found. Specify --session ID.", err=True)
786
+ raise SystemExit(1)
787
+
788
+ if session_id is None:
789
+ raise click.ClickException("Session ID is required")
790
+
791
+ # Parse value type
792
+ parsed_value: str | int | float | bool | None
793
+ if value.lower() == "null" or value.lower() == "none":
794
+ parsed_value = None
795
+ elif value.lower() == "true":
796
+ parsed_value = True
797
+ elif value.lower() == "false":
798
+ parsed_value = False
799
+ else:
800
+ # Try int, then float, then string
801
+ try:
802
+ parsed_value = int(value)
803
+ except ValueError:
804
+ try:
805
+ parsed_value = float(value)
806
+ except ValueError:
807
+ parsed_value = value
808
+
809
+ # Get or create state
810
+ state = state_manager.get_state(session_id)
811
+ if not state:
812
+ state = WorkflowState(
813
+ session_id=session_id,
814
+ workflow_name="__lifecycle__",
815
+ step="",
816
+ step_entered_at=datetime.now(UTC),
817
+ variables={},
818
+ )
819
+
820
+ # Set the variable
821
+ state.variables[name] = parsed_value
822
+ state_manager.save_state(state)
823
+
824
+ if json_format:
825
+ click.echo(
826
+ json.dumps(
827
+ {
828
+ "success": True,
829
+ "session_id": session_id,
830
+ "variable": name,
831
+ "value": parsed_value,
832
+ "all_variables": state.variables,
833
+ },
834
+ indent=2,
835
+ )
836
+ )
837
+ else:
838
+ value_display = repr(parsed_value) if isinstance(parsed_value, str) else str(parsed_value)
839
+ click.echo(f"✓ Set {name} = {value_display}")
840
+ click.echo(f" Session: {session_id[:12]}...")
841
+
842
+
843
+ @workflows.command("get-var")
844
+ @click.argument("name", required=False)
845
+ @click.option("--session", "-s", "session_id", help="Session ID (defaults to current)")
846
+ @click.option("--json", "json_format", is_flag=True, help="Output as JSON")
847
+ @click.pass_context
848
+ def get_variable(
849
+ ctx: click.Context, name: str | None, session_id: str | None, json_format: bool
850
+ ) -> None:
851
+ """Get workflow variable(s) for the current session.
852
+
853
+ If NAME is provided, shows that specific variable.
854
+ If NAME is omitted, shows all variables.
855
+
856
+ Examples:
857
+
858
+ gobby workflows get-var session_epic
859
+
860
+ gobby workflows get-var
861
+ """
862
+ state_manager = get_state_manager()
863
+
864
+ if not session_id:
865
+ db = LocalDatabase()
866
+ row = db.fetchone(
867
+ "SELECT id FROM sessions WHERE status = 'active' ORDER BY updated_at DESC LIMIT 1"
868
+ )
869
+ if row:
870
+ session_id = row["id"]
871
+ else:
872
+ click.echo("No active session found. Specify --session ID.", err=True)
873
+ raise SystemExit(1)
874
+
875
+ if session_id is None:
876
+ raise click.ClickException("Session ID is required")
877
+
878
+ state = state_manager.get_state(session_id)
879
+ variables = state.variables if state else {}
880
+
881
+ if name:
882
+ # Get specific variable
883
+ exists = name in variables
884
+ value = variables.get(name)
885
+
886
+ if json_format:
887
+ click.echo(
888
+ json.dumps(
889
+ {
890
+ "success": True,
891
+ "session_id": session_id,
892
+ "variable": name,
893
+ "value": value,
894
+ "exists": exists,
895
+ },
896
+ indent=2,
897
+ )
898
+ )
899
+ else:
900
+ if exists:
901
+ value_display = repr(value) if isinstance(value, str) else str(value)
902
+ click.echo(f"{name} = {value_display}")
903
+ else:
904
+ click.echo(f"{name}: not set")
905
+ else:
906
+ # Get all variables
907
+ if json_format:
908
+ click.echo(
909
+ json.dumps(
910
+ {
911
+ "success": True,
912
+ "session_id": session_id,
913
+ "variables": variables,
914
+ },
915
+ indent=2,
916
+ )
917
+ )
918
+ else:
919
+ if variables:
920
+ click.echo(f"Variables for session {session_id[:12]}...:\n")
921
+ for var_name, var_value in sorted(variables.items()):
922
+ value_display = (
923
+ repr(var_value) if isinstance(var_value, str) else str(var_value)
924
+ )
925
+ click.echo(f" {var_name} = {value_display}")
926
+ else:
927
+ click.echo(f"No variables set for session {session_id[:12]}...")