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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- 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]}...")
|