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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Task management CLI commands.
|
|
3
|
+
|
|
4
|
+
This package contains the task management commands, split into logical modules:
|
|
5
|
+
- _utils: Shared utilities (formatting, task resolution)
|
|
6
|
+
- ai: AI-powered commands (validate, expand, suggest, complexity)
|
|
7
|
+
- crud: CRUD operations (list, create, show, update, close, delete)
|
|
8
|
+
- deps: Dependency management subgroup
|
|
9
|
+
- hooks: Git hooks management subgroup
|
|
10
|
+
- labels: Label management subgroup
|
|
11
|
+
- main: Entry point and misc commands (sync, compact, import, doctor, clean)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from gobby.cli.tasks._utils import (
|
|
15
|
+
cascade_progress,
|
|
16
|
+
check_tasks_enabled,
|
|
17
|
+
get_sync_manager,
|
|
18
|
+
get_task_manager,
|
|
19
|
+
parse_task_refs,
|
|
20
|
+
)
|
|
21
|
+
from gobby.cli.tasks.main import tasks
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"cascade_progress",
|
|
25
|
+
"check_tasks_enabled",
|
|
26
|
+
"get_task_manager",
|
|
27
|
+
"get_sync_manager",
|
|
28
|
+
"parse_task_refs",
|
|
29
|
+
"tasks",
|
|
30
|
+
]
|
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for task CLI commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable, Generator, Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from wcwidth import wcswidth
|
|
14
|
+
|
|
15
|
+
from gobby.config.app import load_config
|
|
16
|
+
from gobby.storage.database import LocalDatabase
|
|
17
|
+
from gobby.storage.migrations import run_migrations
|
|
18
|
+
from gobby.storage.tasks import LocalTaskManager, Task
|
|
19
|
+
from gobby.sync.tasks import TaskSyncManager
|
|
20
|
+
from gobby.utils.project_context import get_project_context
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
pass # LocalTaskManager already imported above
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_tasks_enabled() -> None:
|
|
29
|
+
"""Check if gobby-tasks is enabled, exit if not."""
|
|
30
|
+
try:
|
|
31
|
+
config = load_config()
|
|
32
|
+
if not config.gobby_tasks.enabled:
|
|
33
|
+
click.echo("Error: gobby-tasks is disabled in config.yaml", err=True)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
except (FileNotFoundError, AttributeError, ImportError):
|
|
36
|
+
# Expected errors if config missing or invalid
|
|
37
|
+
# Fail open to allow CLI to work even if config is borked
|
|
38
|
+
pass
|
|
39
|
+
except Exception as e:
|
|
40
|
+
# Unexpected errors handling config
|
|
41
|
+
logger.warning(f"Error checking tasks config: {e}")
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_task_manager() -> LocalTaskManager:
|
|
46
|
+
"""Get initialized task manager."""
|
|
47
|
+
db = LocalDatabase()
|
|
48
|
+
run_migrations(db)
|
|
49
|
+
return LocalTaskManager(db)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_sync_manager() -> TaskSyncManager:
|
|
53
|
+
"""Get initialized sync manager."""
|
|
54
|
+
manager = get_task_manager()
|
|
55
|
+
return TaskSyncManager(manager, export_path=".gobby/tasks.jsonl")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def normalize_status(status: str) -> str:
|
|
59
|
+
"""Normalize status values for user-friendly CLI input.
|
|
60
|
+
|
|
61
|
+
Converts hyphen-separated status names to underscore format:
|
|
62
|
+
in-progress -> in_progress
|
|
63
|
+
needs-decomposition -> needs_decomposition
|
|
64
|
+
|
|
65
|
+
Also handles common variations.
|
|
66
|
+
"""
|
|
67
|
+
# Replace hyphens with underscores for user convenience
|
|
68
|
+
return status.replace("-", "_")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_claimed_task_ids() -> set[str]:
|
|
72
|
+
"""Get task IDs that are claimed by active sessions via session_task variable.
|
|
73
|
+
|
|
74
|
+
Queries workflow_states for active sessions that have a session_task variable set,
|
|
75
|
+
indicating the task is being actively worked on by that session.
|
|
76
|
+
|
|
77
|
+
Supports session_task in multiple formats:
|
|
78
|
+
- #N: Resolved to UUID via seq_num lookup
|
|
79
|
+
- UUID: Used directly
|
|
80
|
+
- Partial UUID prefix: Used for prefix matching
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Set of task UUIDs claimed by active sessions
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
db = LocalDatabase()
|
|
87
|
+
try:
|
|
88
|
+
# Join workflow_states with sessions to find active sessions with session_task
|
|
89
|
+
rows = db.fetchall(
|
|
90
|
+
"""
|
|
91
|
+
SELECT ws.variables, s.project_id
|
|
92
|
+
FROM workflow_states ws
|
|
93
|
+
JOIN sessions s ON ws.session_id = s.id
|
|
94
|
+
WHERE s.status = 'active'
|
|
95
|
+
AND ws.variables IS NOT NULL
|
|
96
|
+
AND ws.variables != '{}'
|
|
97
|
+
"""
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
claimed_ids: set[str] = set()
|
|
101
|
+
|
|
102
|
+
def resolve_task_ref(ref: str, project_id: str | None) -> str | None:
|
|
103
|
+
"""Resolve a task reference to UUID."""
|
|
104
|
+
if not ref or ref == "*":
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# #N format - resolve via seq_num
|
|
108
|
+
if ref.startswith("#"):
|
|
109
|
+
try:
|
|
110
|
+
seq_num = int(ref[1:])
|
|
111
|
+
row = db.fetchone(
|
|
112
|
+
"SELECT id FROM tasks WHERE project_id = ? AND seq_num = ?",
|
|
113
|
+
(project_id, seq_num),
|
|
114
|
+
)
|
|
115
|
+
return row["id"] if row else None
|
|
116
|
+
except (ValueError, TypeError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# Check if it looks like a UUID (36 chars with dashes)
|
|
120
|
+
if len(ref) == 36 and ref.count("-") == 4:
|
|
121
|
+
return ref
|
|
122
|
+
|
|
123
|
+
# Partial UUID prefix - find matching task
|
|
124
|
+
row = db.fetchone(
|
|
125
|
+
"SELECT id FROM tasks WHERE id LIKE ? AND project_id = ?",
|
|
126
|
+
(f"%{ref}%", project_id),
|
|
127
|
+
)
|
|
128
|
+
return row["id"] if row else None
|
|
129
|
+
|
|
130
|
+
for row in rows:
|
|
131
|
+
try:
|
|
132
|
+
variables = json.loads(row["variables"]) if row["variables"] else {}
|
|
133
|
+
project_id = row["project_id"]
|
|
134
|
+
if session_task := variables.get("session_task"):
|
|
135
|
+
# session_task can be: string, list of strings, or "*" (wildcard)
|
|
136
|
+
if isinstance(session_task, list):
|
|
137
|
+
for task_ref in session_task:
|
|
138
|
+
if resolved := resolve_task_ref(task_ref, project_id):
|
|
139
|
+
claimed_ids.add(resolved)
|
|
140
|
+
elif session_task != "*":
|
|
141
|
+
if resolved := resolve_task_ref(session_task, project_id):
|
|
142
|
+
claimed_ids.add(resolved)
|
|
143
|
+
except (json.JSONDecodeError, TypeError):
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
return claimed_ids
|
|
147
|
+
finally:
|
|
148
|
+
db.close()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
logger.debug(f"Failed to get claimed task IDs: {e}")
|
|
151
|
+
return set()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def pad_to_width(text: str, width: int) -> str:
|
|
155
|
+
"""Pad a string to a visual width, accounting for wide characters like emoji."""
|
|
156
|
+
visual_width: int = wcswidth(text)
|
|
157
|
+
if visual_width < 0:
|
|
158
|
+
visual_width = len(text) # Fallback if wcswidth fails
|
|
159
|
+
padding: int = width - visual_width
|
|
160
|
+
return text + " " * max(0, padding)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def collect_ancestors(
|
|
164
|
+
tasks: list[Task], task_manager: "LocalTaskManager"
|
|
165
|
+
) -> tuple[list[Task], set[str]]:
|
|
166
|
+
"""Collect ancestor tasks to maintain tree hierarchy.
|
|
167
|
+
|
|
168
|
+
When filtering tasks (e.g., --ready), we may have tasks whose parents
|
|
169
|
+
are not in the filtered list. This function fetches those ancestors
|
|
170
|
+
so the tree structure is preserved.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
tasks: The filtered list of tasks
|
|
174
|
+
task_manager: Task manager for fetching ancestors
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Tuple of (combined task list with ancestors, set of original task IDs)
|
|
178
|
+
"""
|
|
179
|
+
task_by_id = {t.id: t for t in tasks}
|
|
180
|
+
original_ids = set(task_by_id.keys())
|
|
181
|
+
ancestors_to_fetch: set[str] = set()
|
|
182
|
+
|
|
183
|
+
# Find all ancestors that are missing from the list
|
|
184
|
+
for task in tasks:
|
|
185
|
+
parent_id = task.parent_task_id
|
|
186
|
+
while parent_id and parent_id not in task_by_id:
|
|
187
|
+
ancestors_to_fetch.add(parent_id)
|
|
188
|
+
# We need to fetch the parent to check its parent
|
|
189
|
+
try:
|
|
190
|
+
parent = task_manager.get_task(parent_id)
|
|
191
|
+
task_by_id[parent_id] = parent
|
|
192
|
+
parent_id = parent.parent_task_id
|
|
193
|
+
except (ValueError, Exception):
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
# Combine original tasks with ancestors
|
|
197
|
+
combined = list(tasks)
|
|
198
|
+
for ancestor_id in ancestors_to_fetch:
|
|
199
|
+
if ancestor_id in task_by_id:
|
|
200
|
+
combined.append(task_by_id[ancestor_id])
|
|
201
|
+
|
|
202
|
+
return combined, original_ids
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def sort_tasks_for_tree(tasks: list[Task]) -> list[Task]:
|
|
206
|
+
"""Sort tasks for tree display (parent before children, depth-first).
|
|
207
|
+
|
|
208
|
+
Returns a new list with tasks sorted in tree traversal order.
|
|
209
|
+
Preserves the input order within each parent group (respecting
|
|
210
|
+
topological sort from storage layer).
|
|
211
|
+
"""
|
|
212
|
+
task_by_id = {t.id: t for t in tasks}
|
|
213
|
+
# Preserve input order via index lookup
|
|
214
|
+
input_order = {t.id: i for i, t in enumerate(tasks)}
|
|
215
|
+
|
|
216
|
+
# Group children by parent
|
|
217
|
+
children_by_parent: dict[str | None, list[Task]] = {}
|
|
218
|
+
for task in tasks:
|
|
219
|
+
parent_id = task.parent_task_id
|
|
220
|
+
if parent_id and parent_id not in task_by_id:
|
|
221
|
+
parent_id = None
|
|
222
|
+
if parent_id not in children_by_parent:
|
|
223
|
+
children_by_parent[parent_id] = []
|
|
224
|
+
children_by_parent[parent_id].append(task)
|
|
225
|
+
|
|
226
|
+
# Sort children within each parent by input order (preserves topological sort)
|
|
227
|
+
for children in children_by_parent.values():
|
|
228
|
+
children.sort(key=lambda t: input_order.get(t.id, float("inf")))
|
|
229
|
+
|
|
230
|
+
# Build sorted list via depth-first traversal
|
|
231
|
+
sorted_tasks: list[Task] = []
|
|
232
|
+
|
|
233
|
+
def traverse(task: Task) -> None:
|
|
234
|
+
sorted_tasks.append(task)
|
|
235
|
+
for child in children_by_parent.get(task.id, []):
|
|
236
|
+
traverse(child)
|
|
237
|
+
|
|
238
|
+
for root_task in children_by_parent.get(None, []):
|
|
239
|
+
traverse(root_task)
|
|
240
|
+
|
|
241
|
+
return sorted_tasks
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def compute_tree_prefixes(
|
|
245
|
+
tasks: list[Task], primary_ids: set[str] | None = None
|
|
246
|
+
) -> dict[str, tuple[str, bool]]:
|
|
247
|
+
"""Compute tree-style prefixes for each task in the hierarchy.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
tasks: List of tasks to compute prefixes for
|
|
251
|
+
primary_ids: Optional set of "primary" task IDs. Tasks not in this set
|
|
252
|
+
are considered ancestors (shown muted). If None, all tasks
|
|
253
|
+
are considered primary.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Dict mapping task_id -> (prefix string, is_primary).
|
|
257
|
+
prefix is e.g., "├── ", "│ └── "
|
|
258
|
+
is_primary is True if task is in primary_ids (or primary_ids is None)
|
|
259
|
+
"""
|
|
260
|
+
task_by_id = {t.id: t for t in tasks}
|
|
261
|
+
# Preserve input order via index lookup
|
|
262
|
+
input_order = {t.id: i for i, t in enumerate(tasks)}
|
|
263
|
+
if primary_ids is None:
|
|
264
|
+
primary_ids = set(task_by_id.keys())
|
|
265
|
+
|
|
266
|
+
# Group children by parent
|
|
267
|
+
children_by_parent: dict[str | None, list[Task]] = {}
|
|
268
|
+
for task in tasks:
|
|
269
|
+
parent_id = task.parent_task_id
|
|
270
|
+
if parent_id and parent_id not in task_by_id:
|
|
271
|
+
parent_id = None
|
|
272
|
+
if parent_id not in children_by_parent:
|
|
273
|
+
children_by_parent[parent_id] = []
|
|
274
|
+
children_by_parent[parent_id].append(task)
|
|
275
|
+
|
|
276
|
+
# Sort children within each parent by input order (preserves topological sort)
|
|
277
|
+
for children in children_by_parent.values():
|
|
278
|
+
children.sort(key=lambda t: input_order.get(t.id, float("inf")))
|
|
279
|
+
|
|
280
|
+
prefixes: dict[str, tuple[str, bool]] = {}
|
|
281
|
+
|
|
282
|
+
def compute_prefix(task: Task, ancestor_continues: list[bool]) -> None:
|
|
283
|
+
"""Recursively compute prefix for task and its children."""
|
|
284
|
+
is_primary = task.id in primary_ids
|
|
285
|
+
|
|
286
|
+
if not task.parent_task_id or task.parent_task_id not in task_by_id:
|
|
287
|
+
# Root task - no prefix
|
|
288
|
+
prefixes[task.id] = ("", is_primary)
|
|
289
|
+
else:
|
|
290
|
+
# Build prefix from ancestor continuation markers
|
|
291
|
+
prefix_parts = []
|
|
292
|
+
for continues in ancestor_continues[:-1]:
|
|
293
|
+
prefix_parts.append("│ " if continues else " ")
|
|
294
|
+
# Add the branch for this task
|
|
295
|
+
if ancestor_continues:
|
|
296
|
+
is_last = not ancestor_continues[-1]
|
|
297
|
+
prefix_parts.append("└── " if is_last else "├── ")
|
|
298
|
+
prefixes[task.id] = ("".join(prefix_parts), is_primary)
|
|
299
|
+
|
|
300
|
+
# Process children
|
|
301
|
+
children = children_by_parent.get(task.id, [])
|
|
302
|
+
for i, child in enumerate(children):
|
|
303
|
+
is_last_child = i == len(children) - 1
|
|
304
|
+
compute_prefix(child, ancestor_continues + [not is_last_child])
|
|
305
|
+
|
|
306
|
+
# Start with root tasks
|
|
307
|
+
for root_task in children_by_parent.get(None, []):
|
|
308
|
+
compute_prefix(root_task, [])
|
|
309
|
+
|
|
310
|
+
return prefixes
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# Column widths for task table
|
|
314
|
+
COL_STATUS = 1 # Status icon
|
|
315
|
+
COL_PRIORITY = 2 # Priority emoji (2 visual chars)
|
|
316
|
+
COL_ID = 6 # #N format (e.g., #1234)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def format_task_row(
|
|
320
|
+
task: Task,
|
|
321
|
+
tree_prefix: str = "",
|
|
322
|
+
is_primary: bool = True,
|
|
323
|
+
muted: bool = False,
|
|
324
|
+
claimed_task_ids: set[str] | None = None,
|
|
325
|
+
) -> str:
|
|
326
|
+
"""Format a task for list output.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
task: The task to format
|
|
330
|
+
tree_prefix: Tree-style prefix (e.g., "├── ", "│ └── ")
|
|
331
|
+
is_primary: If False, task is an ancestor shown for context (muted style)
|
|
332
|
+
muted: Explicit muted flag (overrides is_primary)
|
|
333
|
+
claimed_task_ids: Set of task IDs claimed by active sessions
|
|
334
|
+
"""
|
|
335
|
+
show_muted = muted or not is_primary
|
|
336
|
+
is_claimed = claimed_task_ids is not None and task.id in claimed_task_ids
|
|
337
|
+
|
|
338
|
+
# Status icons:
|
|
339
|
+
# ○ = open, unclaimed
|
|
340
|
+
# ◐ = open, claimed by active session
|
|
341
|
+
# ● = in_progress
|
|
342
|
+
# ✓ = completed/closed
|
|
343
|
+
# ⊗ = blocked
|
|
344
|
+
# ⚠ = escalated
|
|
345
|
+
if task.status == "open" and is_claimed:
|
|
346
|
+
status_icon = "◐" # Open but claimed by active session
|
|
347
|
+
else:
|
|
348
|
+
status_icon = {
|
|
349
|
+
"open": "○",
|
|
350
|
+
"in_progress": "●",
|
|
351
|
+
"completed": "✓",
|
|
352
|
+
"closed": "✓",
|
|
353
|
+
"blocked": "⊗",
|
|
354
|
+
"escalated": "⚠",
|
|
355
|
+
}.get(task.status, "?")
|
|
356
|
+
|
|
357
|
+
priority_icon = {
|
|
358
|
+
0: "🟣", # Critical
|
|
359
|
+
1: "🔴", # High
|
|
360
|
+
2: "🟡", # Medium
|
|
361
|
+
3: "🔵", # Low
|
|
362
|
+
4: "⚪", # Backlog
|
|
363
|
+
}.get(task.priority, "⚪")
|
|
364
|
+
|
|
365
|
+
# Build row with proper visual width padding
|
|
366
|
+
status_col = pad_to_width(status_icon, COL_STATUS)
|
|
367
|
+
priority_col = pad_to_width(priority_icon, COL_PRIORITY)
|
|
368
|
+
# Use #N format for display (seq_num), fallback to short UUID prefix
|
|
369
|
+
task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
|
|
370
|
+
id_col = pad_to_width(task_ref, COL_ID)
|
|
371
|
+
|
|
372
|
+
title = task.title
|
|
373
|
+
if show_muted:
|
|
374
|
+
# Use dim ANSI escape for muted ancestors
|
|
375
|
+
# \033[2m = dim, \033[0m = reset
|
|
376
|
+
title = f"\033[2m{task.title}\033[0m"
|
|
377
|
+
|
|
378
|
+
return f"{status_col} {priority_col} {id_col} {tree_prefix}{title}"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def format_task_header() -> str:
|
|
382
|
+
"""Return header row for task list."""
|
|
383
|
+
status_col = pad_to_width("", COL_STATUS)
|
|
384
|
+
priority_col = pad_to_width("", COL_PRIORITY)
|
|
385
|
+
id_col = pad_to_width("#", COL_ID)
|
|
386
|
+
|
|
387
|
+
return f"{status_col} {priority_col} {id_col} TITLE"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def resolve_task_id(
|
|
391
|
+
manager: LocalTaskManager, task_id: str, project_id: str | None = None
|
|
392
|
+
) -> Task | None:
|
|
393
|
+
"""Resolve a task ID to a Task with user-friendly errors.
|
|
394
|
+
|
|
395
|
+
Supports multiple reference formats:
|
|
396
|
+
- #N: Project-scoped seq_num (e.g., #1, #47) - requires project_id
|
|
397
|
+
- 1.2.3: Path cache format - requires project_id
|
|
398
|
+
- UUID: Direct UUID lookup
|
|
399
|
+
- Prefix: ID prefix matching for partial UUIDs
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
manager: The task manager
|
|
403
|
+
task_id: Task reference in any supported format
|
|
404
|
+
project_id: Project ID for scoped lookups (#N and path formats).
|
|
405
|
+
If not provided, will try to get from project context.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
The resolved Task, or None if not found (with error message printed)
|
|
409
|
+
"""
|
|
410
|
+
from gobby.storage.tasks import TaskNotFoundError
|
|
411
|
+
|
|
412
|
+
# Get project_id from context if not provided
|
|
413
|
+
if project_id is None:
|
|
414
|
+
ctx = get_project_context()
|
|
415
|
+
project_id = ctx.get("id") if ctx else None
|
|
416
|
+
|
|
417
|
+
# Try #N format, numeric format (treated as #N), or path format (requires project_id)
|
|
418
|
+
if project_id and (task_id.startswith("#") or task_id.isdigit() or _is_path_format(task_id)):
|
|
419
|
+
# Auto-prefix numeric IDs with #
|
|
420
|
+
if task_id.isdigit():
|
|
421
|
+
task_id = f"#{task_id}"
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
resolved_uuid = manager.resolve_task_reference(task_id, project_id)
|
|
425
|
+
return manager.get_task(resolved_uuid)
|
|
426
|
+
except TaskNotFoundError as e:
|
|
427
|
+
click.echo(f"Task '{task_id}' not found: {e}", err=True)
|
|
428
|
+
return None
|
|
429
|
+
except ValueError as e:
|
|
430
|
+
# Deprecation or format errors
|
|
431
|
+
click.echo(f"Error: {e}", err=True)
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
# Try exact UUID match
|
|
435
|
+
try:
|
|
436
|
+
return manager.get_task(task_id)
|
|
437
|
+
except ValueError:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
# Try prefix matching for partial UUIDs
|
|
441
|
+
matches = manager.find_tasks_by_prefix(task_id)
|
|
442
|
+
|
|
443
|
+
if len(matches) == 0:
|
|
444
|
+
click.echo(f"Task '{task_id}' not found", err=True)
|
|
445
|
+
return None
|
|
446
|
+
elif len(matches) == 1:
|
|
447
|
+
return matches[0]
|
|
448
|
+
else:
|
|
449
|
+
click.echo(f"Ambiguous task ID '{task_id}' matches {len(matches)} tasks:", err=True)
|
|
450
|
+
for task in matches[:5]:
|
|
451
|
+
click.echo(f" {task.id}: {task.title}", err=True)
|
|
452
|
+
if len(matches) > 5:
|
|
453
|
+
click.echo(f" ... and {len(matches) - 5} more", err=True)
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _is_path_format(ref: str) -> bool:
|
|
458
|
+
"""Check if a reference is in path format (e.g., 1.2.3)."""
|
|
459
|
+
if "." not in ref:
|
|
460
|
+
return False
|
|
461
|
+
parts = ref.split(".")
|
|
462
|
+
return all(part.isdigit() for part in parts)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _CascadeIterator:
|
|
466
|
+
"""Iterator wrapper that handles errors via callback."""
|
|
467
|
+
|
|
468
|
+
def __init__(
|
|
469
|
+
self,
|
|
470
|
+
tasks: list[Task],
|
|
471
|
+
label: str,
|
|
472
|
+
on_error: Callable[[Task, Exception], bool] | None,
|
|
473
|
+
):
|
|
474
|
+
self._tasks = tasks
|
|
475
|
+
self._label = label
|
|
476
|
+
self._on_error = on_error
|
|
477
|
+
self._index = 0
|
|
478
|
+
self._total = len(tasks)
|
|
479
|
+
self._stop = False
|
|
480
|
+
self._current_task: Task | None = None
|
|
481
|
+
self._pending_error: Exception | None = None
|
|
482
|
+
self._completed_count = 0
|
|
483
|
+
|
|
484
|
+
def __iter__(self) -> "_CascadeIterator":
|
|
485
|
+
return self
|
|
486
|
+
|
|
487
|
+
def __next__(self) -> tuple[Task, Callable[[], None]]:
|
|
488
|
+
# Handle any pending error from previous iteration
|
|
489
|
+
if self._pending_error is not None:
|
|
490
|
+
error = self._pending_error
|
|
491
|
+
self._pending_error = None
|
|
492
|
+
task = self._current_task
|
|
493
|
+
|
|
494
|
+
if self._on_error is not None and task is not None:
|
|
495
|
+
should_continue = self._on_error(task, error)
|
|
496
|
+
if not should_continue:
|
|
497
|
+
self._stop = True
|
|
498
|
+
raise StopIteration
|
|
499
|
+
else:
|
|
500
|
+
raise error
|
|
501
|
+
|
|
502
|
+
if self._stop or self._index >= self._total:
|
|
503
|
+
raise StopIteration
|
|
504
|
+
|
|
505
|
+
task = self._tasks[self._index]
|
|
506
|
+
self._current_task = task
|
|
507
|
+
self._index += 1
|
|
508
|
+
|
|
509
|
+
task_ref = f"#{task.seq_num}" if task.seq_num else task.id[:8]
|
|
510
|
+
|
|
511
|
+
# Truncate long titles
|
|
512
|
+
max_title_len = 40
|
|
513
|
+
title = task.title
|
|
514
|
+
if len(title) > max_title_len:
|
|
515
|
+
title = title[: max_title_len - 3] + "..."
|
|
516
|
+
|
|
517
|
+
# Print progress line with label
|
|
518
|
+
progress_str = f"{self._label} [{self._index}/{self._total}] {task_ref}: {title}"
|
|
519
|
+
click.echo(progress_str)
|
|
520
|
+
|
|
521
|
+
def update() -> None:
|
|
522
|
+
"""Mark the current task as completed."""
|
|
523
|
+
self._completed_count += 1
|
|
524
|
+
|
|
525
|
+
return task, update
|
|
526
|
+
|
|
527
|
+
def report_error(self, error: Exception) -> None:
|
|
528
|
+
"""Report an error for the current task."""
|
|
529
|
+
self._pending_error = error
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@contextmanager
|
|
533
|
+
def cascade_progress(
|
|
534
|
+
tasks: list[Task],
|
|
535
|
+
label: str = "Processing",
|
|
536
|
+
on_error: Callable[[Task, Exception], bool] | None = None,
|
|
537
|
+
) -> Generator[Iterator[tuple[Task, Callable[[], None]]]]:
|
|
538
|
+
"""Context manager for cascade operations with progress display.
|
|
539
|
+
|
|
540
|
+
Yields (task, update) pairs for each task. Call update() after
|
|
541
|
+
processing each task to advance the progress bar.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
tasks: List of tasks to process
|
|
545
|
+
label: Label to show before progress bar (e.g., "Expanding")
|
|
546
|
+
on_error: Optional callback for errors. Receives (task, error).
|
|
547
|
+
Return True to continue, False to stop processing.
|
|
548
|
+
|
|
549
|
+
Yields:
|
|
550
|
+
Iterator of (task, update_fn) tuples
|
|
551
|
+
|
|
552
|
+
Example:
|
|
553
|
+
with cascade_progress(tasks, label="Expanding") as progress:
|
|
554
|
+
for task, update in progress:
|
|
555
|
+
await expand_task(task)
|
|
556
|
+
update() # Mark complete
|
|
557
|
+
"""
|
|
558
|
+
if not tasks:
|
|
559
|
+
yield iter([])
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
iterator = _CascadeIterator(tasks, label, on_error)
|
|
563
|
+
try:
|
|
564
|
+
yield iterator
|
|
565
|
+
except KeyboardInterrupt:
|
|
566
|
+
click.echo("\nOperation interrupted by user.")
|
|
567
|
+
raise
|
|
568
|
+
except Exception as e:
|
|
569
|
+
# Handle error on current task
|
|
570
|
+
if on_error is not None and iterator._current_task is not None:
|
|
571
|
+
# Call on_error callback for logging, but always re-raise
|
|
572
|
+
# The on_error return value is only used in next-iteration logic
|
|
573
|
+
on_error(iterator._current_task, e)
|
|
574
|
+
raise
|
|
575
|
+
finally:
|
|
576
|
+
# Handle any pending error from final iteration (report_error called on last task)
|
|
577
|
+
if iterator._pending_error is not None:
|
|
578
|
+
error = iterator._pending_error
|
|
579
|
+
iterator._pending_error = None
|
|
580
|
+
task = iterator._current_task
|
|
581
|
+
# Capture any exception from the iterator body to preserve as __cause__
|
|
582
|
+
body_exception = sys.exc_info()[1]
|
|
583
|
+
if on_error is not None and task is not None:
|
|
584
|
+
# Call on_error callback for pending error, preserving both exceptions if callback fails
|
|
585
|
+
try:
|
|
586
|
+
on_error(task, error)
|
|
587
|
+
# Error handled via callback, don't re-raise
|
|
588
|
+
except Exception as callback_exc:
|
|
589
|
+
# Chain exceptions: pending error from body exception (if any) or callback failure
|
|
590
|
+
if body_exception is not None:
|
|
591
|
+
raise error from body_exception
|
|
592
|
+
raise error from callback_exc
|
|
593
|
+
else:
|
|
594
|
+
# No on_error callback - re-raise the pending error chained to body exception if present
|
|
595
|
+
if body_exception is not None:
|
|
596
|
+
raise error from body_exception
|
|
597
|
+
raise error from None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def get_all_descendants(manager: LocalTaskManager, task_id: str) -> list[Task]:
|
|
601
|
+
"""Recursively get all descendants of a task (children, grandchildren, etc.).
|
|
602
|
+
|
|
603
|
+
Returns tasks in depth-first order (parent before children).
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
manager: The task manager
|
|
607
|
+
task_id: UUID of the parent task
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
List of all descendant tasks
|
|
611
|
+
"""
|
|
612
|
+
descendants: list[Task] = []
|
|
613
|
+
|
|
614
|
+
def collect_children(parent_id: str) -> None:
|
|
615
|
+
children = manager.list_tasks(parent_task_id=parent_id)
|
|
616
|
+
for child in children:
|
|
617
|
+
descendants.append(child)
|
|
618
|
+
collect_children(child.id) # Recurse into grandchildren
|
|
619
|
+
|
|
620
|
+
collect_children(task_id)
|
|
621
|
+
return descendants
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def parse_task_refs(refs: tuple[str, ...]) -> list[str]:
|
|
625
|
+
"""Parse task references from various CLI input formats.
|
|
626
|
+
|
|
627
|
+
Handles multiple input formats commonly used in CLI:
|
|
628
|
+
- Single reference: "42", "#42", "abc123-def"
|
|
629
|
+
- Comma-separated: "#42,#43,#44" or "42,43,44"
|
|
630
|
+
- Space-separated: passed as tuple from Click variadic args
|
|
631
|
+
- Mixed: "#42,#43 #44" with both separators
|
|
632
|
+
|
|
633
|
+
Numeric references are normalized to #N format.
|
|
634
|
+
UUID-like references are passed through unchanged.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
refs: Tuple of reference strings from Click variadic argument
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
List of normalized task references
|
|
641
|
+
"""
|
|
642
|
+
result: list[str] = []
|
|
643
|
+
|
|
644
|
+
for arg in refs:
|
|
645
|
+
# Split on commas first
|
|
646
|
+
parts = arg.split(",")
|
|
647
|
+
for part in parts:
|
|
648
|
+
ref = part.strip()
|
|
649
|
+
if not ref:
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
# Normalize pure numeric to #N format
|
|
653
|
+
if ref.isdigit():
|
|
654
|
+
ref = f"#{ref}"
|
|
655
|
+
|
|
656
|
+
result.append(ref)
|
|
657
|
+
|
|
658
|
+
return result
|