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/tasks/ai.py
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI-powered task commands (expand, validate, suggest, etc.)
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from gobby.cli.tasks._utils import get_task_manager, resolve_task_id
|
|
11
|
+
from gobby.storage.tasks import LocalTaskManager, Task
|
|
12
|
+
from gobby.utils.project_context import get_project_context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("validate")
|
|
16
|
+
@click.argument("task_id", metavar="TASK")
|
|
17
|
+
@click.option(
|
|
18
|
+
"--summary", "-s", default=None, help="Changes summary text (required for leaf tasks)"
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--file",
|
|
22
|
+
"-f",
|
|
23
|
+
"summary_file",
|
|
24
|
+
type=click.Path(exists=True),
|
|
25
|
+
help="File containing changes summary",
|
|
26
|
+
)
|
|
27
|
+
@click.option("--max-iterations", "-i", type=int, default=1, help="Max validation retry attempts")
|
|
28
|
+
@click.option("--external", is_flag=True, help="Use external validator agent")
|
|
29
|
+
@click.option("--skip-build", is_flag=True, help="Skip build verification before validation")
|
|
30
|
+
@click.option("--history", is_flag=True, help="Show validation history instead of validating")
|
|
31
|
+
@click.option("--recurring", is_flag=True, help="Show recurring issues instead of validating")
|
|
32
|
+
def validate_task_cmd(
|
|
33
|
+
task_id: str,
|
|
34
|
+
summary: str | None,
|
|
35
|
+
summary_file: str | None,
|
|
36
|
+
max_iterations: int,
|
|
37
|
+
external: bool,
|
|
38
|
+
skip_build: bool,
|
|
39
|
+
history: bool,
|
|
40
|
+
recurring: bool,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Validate a task.
|
|
43
|
+
|
|
44
|
+
TASK can be: #N (e.g., #1, #47), path (e.g., 1.2.3), or UUID.
|
|
45
|
+
|
|
46
|
+
For parent tasks (with children), validates that all children are closed.
|
|
47
|
+
For leaf tasks, uses LLM-based validation against criteria.
|
|
48
|
+
|
|
49
|
+
Use --history to view past validation iterations.
|
|
50
|
+
Use --recurring to see issues that keep appearing.
|
|
51
|
+
"""
|
|
52
|
+
import asyncio
|
|
53
|
+
|
|
54
|
+
from gobby.config.app import load_config
|
|
55
|
+
from gobby.llm import LLMService
|
|
56
|
+
from gobby.tasks.validation import TaskValidator, ValidationResult
|
|
57
|
+
from gobby.tasks.validation_history import ValidationHistoryManager
|
|
58
|
+
|
|
59
|
+
manager = get_task_manager()
|
|
60
|
+
resolved = resolve_task_id(manager, task_id)
|
|
61
|
+
if not resolved:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Handle --history flag: show validation history
|
|
65
|
+
if history:
|
|
66
|
+
history_manager = ValidationHistoryManager(manager.db)
|
|
67
|
+
iterations = history_manager.get_iteration_history(resolved.id)
|
|
68
|
+
if not iterations:
|
|
69
|
+
click.echo(f"No validation history for task {resolved.id}")
|
|
70
|
+
return
|
|
71
|
+
click.echo(f"Validation history for {resolved.id}:")
|
|
72
|
+
for it in iterations:
|
|
73
|
+
click.echo(f"\n Iteration {it.iteration}: {it.status}")
|
|
74
|
+
if it.feedback:
|
|
75
|
+
click.echo(f" Feedback: {it.feedback[:100]}...")
|
|
76
|
+
if it.issues:
|
|
77
|
+
click.echo(f" Issues: {len(it.issues)}")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Handle --recurring flag: show recurring issues
|
|
81
|
+
if recurring:
|
|
82
|
+
history_manager = ValidationHistoryManager(manager.db)
|
|
83
|
+
summary_data = history_manager.get_recurring_issue_summary(resolved.id)
|
|
84
|
+
has_recurring = history_manager.has_recurring_issues(resolved.id)
|
|
85
|
+
click.echo(f"Recurring issues for {resolved.id}:")
|
|
86
|
+
click.echo(f" Has recurring issues: {has_recurring}")
|
|
87
|
+
click.echo(f" Total iterations: {summary_data['total_iterations']}")
|
|
88
|
+
if summary_data["recurring_issues"]:
|
|
89
|
+
for issue in summary_data["recurring_issues"]:
|
|
90
|
+
click.echo(f" - {issue['title']} (count: {issue['count']})")
|
|
91
|
+
else:
|
|
92
|
+
click.echo(" No recurring issues found.")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Check if task has children (is a parent task)
|
|
96
|
+
children = manager.list_tasks(parent_task_id=resolved.id, limit=1000)
|
|
97
|
+
|
|
98
|
+
if children:
|
|
99
|
+
# Parent task: validate based on child completion
|
|
100
|
+
open_children = [c for c in children if c.status != "closed"]
|
|
101
|
+
all_closed = len(open_children) == 0
|
|
102
|
+
|
|
103
|
+
if all_closed:
|
|
104
|
+
result = ValidationResult(
|
|
105
|
+
status="valid",
|
|
106
|
+
feedback=f"All {len(children)} child tasks are completed.",
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
open_titles = [f"- {c.id}: {c.title}" for c in open_children[:5]]
|
|
110
|
+
remaining = len(open_children) - 5 if len(open_children) > 5 else 0
|
|
111
|
+
feedback = f"{len(open_children)} of {len(children)} child tasks still open:\n"
|
|
112
|
+
feedback += "\n".join(open_titles)
|
|
113
|
+
if remaining > 0:
|
|
114
|
+
feedback += f"\n... and {remaining} more"
|
|
115
|
+
result = ValidationResult(status="invalid", feedback=feedback)
|
|
116
|
+
|
|
117
|
+
click.echo(f"Validation Status: {result.status.upper()}")
|
|
118
|
+
if result.feedback:
|
|
119
|
+
click.echo(f"Feedback:\n{result.feedback}")
|
|
120
|
+
|
|
121
|
+
# Update validation status
|
|
122
|
+
updates: dict[str, Any] = {
|
|
123
|
+
"validation_status": result.status,
|
|
124
|
+
"validation_feedback": result.feedback,
|
|
125
|
+
}
|
|
126
|
+
if result.status == "valid":
|
|
127
|
+
manager.close_task(resolved.id, reason="All child tasks completed")
|
|
128
|
+
click.echo("Task closed.")
|
|
129
|
+
manager.update_task(resolved.id, **updates)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Leaf task: need changes summary
|
|
133
|
+
changes_summary = ""
|
|
134
|
+
if summary_file:
|
|
135
|
+
try:
|
|
136
|
+
with open(summary_file, encoding="utf-8") as f:
|
|
137
|
+
changes_summary = f.read()
|
|
138
|
+
except Exception as e:
|
|
139
|
+
click.echo(f"Error reading summary file: {e}", err=True)
|
|
140
|
+
return
|
|
141
|
+
elif summary:
|
|
142
|
+
changes_summary = summary
|
|
143
|
+
else:
|
|
144
|
+
# Prompt from stdin
|
|
145
|
+
click.echo("Enter changes summary (Ctrl+D to finish):")
|
|
146
|
+
changes_summary = sys.stdin.read()
|
|
147
|
+
|
|
148
|
+
if not changes_summary.strip():
|
|
149
|
+
click.echo("Error: Changes summary is required for leaf tasks.", err=True)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
click.echo(f"Validating task {resolved.id}...")
|
|
153
|
+
|
|
154
|
+
# Initialize validator
|
|
155
|
+
try:
|
|
156
|
+
config = load_config()
|
|
157
|
+
llm_service = LLMService(config)
|
|
158
|
+
validator = TaskValidator(config.gobby_tasks.validation, llm_service)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
click.echo(f"Error initializing validator: {e}", err=True)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# Run validation
|
|
164
|
+
try:
|
|
165
|
+
result = asyncio.run(
|
|
166
|
+
validator.validate_task(
|
|
167
|
+
task_id=resolved.id,
|
|
168
|
+
title=resolved.title,
|
|
169
|
+
description=resolved.description,
|
|
170
|
+
changes_summary=changes_summary,
|
|
171
|
+
validation_criteria=resolved.validation_criteria,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
click.echo(f"Validation Status: {result.status.upper()}")
|
|
176
|
+
if result.feedback:
|
|
177
|
+
click.echo(f"Feedback:\n{result.feedback}")
|
|
178
|
+
|
|
179
|
+
# Apply validation updates
|
|
180
|
+
validation_updates: dict[str, Any] = {
|
|
181
|
+
"validation_status": result.status,
|
|
182
|
+
"validation_feedback": result.feedback,
|
|
183
|
+
}
|
|
184
|
+
MAX_RETRIES = 3
|
|
185
|
+
|
|
186
|
+
if result.status == "valid":
|
|
187
|
+
manager.close_task(resolved.id, reason="Completed via validation")
|
|
188
|
+
click.echo("Task closed.")
|
|
189
|
+
elif result.status == "invalid":
|
|
190
|
+
current_fail_count = resolved.validation_fail_count or 0
|
|
191
|
+
new_fail_count = current_fail_count + 1
|
|
192
|
+
validation_updates["validation_fail_count"] = new_fail_count
|
|
193
|
+
|
|
194
|
+
if new_fail_count < MAX_RETRIES:
|
|
195
|
+
fix_task = manager.create_task(
|
|
196
|
+
project_id=resolved.project_id,
|
|
197
|
+
title=f"Fix validation failures for {resolved.title}",
|
|
198
|
+
description=f"Validation failed with feedback:\n{result.feedback}\n\nPlease fix the issues and re-validate.",
|
|
199
|
+
parent_task_id=resolved.id,
|
|
200
|
+
priority=1,
|
|
201
|
+
task_type="bug",
|
|
202
|
+
)
|
|
203
|
+
validation_updates["validation_feedback"] = (
|
|
204
|
+
result.feedback or ""
|
|
205
|
+
) + f"\n\nCreated fix task: {fix_task.id}"
|
|
206
|
+
click.echo(f"Created fix task: {fix_task.id}")
|
|
207
|
+
else:
|
|
208
|
+
validation_updates["status"] = "failed"
|
|
209
|
+
validation_updates["validation_feedback"] = (
|
|
210
|
+
result.feedback or ""
|
|
211
|
+
) + f"\n\nExceeded max retries ({MAX_RETRIES}). Marked as failed."
|
|
212
|
+
click.echo("Exceeded max retries. Task marked as FAILED.")
|
|
213
|
+
|
|
214
|
+
manager.update_task(resolved.id, **validation_updates)
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
click.echo(f"Validation error: {e}", err=True)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@click.command("generate-criteria")
|
|
221
|
+
@click.argument("task_id", required=False)
|
|
222
|
+
@click.option(
|
|
223
|
+
"--all", "generate_all", is_flag=True, help="Generate criteria for all tasks missing it"
|
|
224
|
+
)
|
|
225
|
+
def generate_criteria_cmd(task_id: str | None, generate_all: bool) -> None:
|
|
226
|
+
"""Generate validation criteria for a task.
|
|
227
|
+
|
|
228
|
+
For parent tasks (with children), sets criteria to 'All child tasks completed'.
|
|
229
|
+
For leaf tasks, uses AI to generate criteria from title/description.
|
|
230
|
+
|
|
231
|
+
Use --all to generate criteria for all tasks that don't have it set.
|
|
232
|
+
"""
|
|
233
|
+
import asyncio
|
|
234
|
+
|
|
235
|
+
from gobby.config.app import load_config
|
|
236
|
+
from gobby.llm import LLMService
|
|
237
|
+
from gobby.tasks.validation import TaskValidator
|
|
238
|
+
|
|
239
|
+
manager = get_task_manager()
|
|
240
|
+
|
|
241
|
+
if generate_all:
|
|
242
|
+
_generate_criteria_for_all(manager)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
if not task_id:
|
|
246
|
+
click.echo("Error: TASK_ID is required (or use --all)", err=True)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
resolved = resolve_task_id(manager, task_id)
|
|
250
|
+
if not resolved:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if resolved.validation_criteria:
|
|
254
|
+
click.echo("Task already has validation criteria:")
|
|
255
|
+
click.echo(resolved.validation_criteria)
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Check if task has children (is a parent task)
|
|
259
|
+
children = manager.list_tasks(parent_task_id=resolved.id, limit=1)
|
|
260
|
+
|
|
261
|
+
if children:
|
|
262
|
+
# Parent task: criteria is child completion
|
|
263
|
+
criteria = "All child tasks must be completed (status: closed)."
|
|
264
|
+
manager.update_task(resolved.id, validation_criteria=criteria)
|
|
265
|
+
click.echo(f"Parent task detected. Set validation criteria:\n{criteria}")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# Leaf task: use LLM to generate criteria
|
|
269
|
+
click.echo(f"Generating validation criteria for task {resolved.id}...")
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
config = load_config()
|
|
273
|
+
llm_service = LLMService(config)
|
|
274
|
+
validator = TaskValidator(config.gobby_tasks.validation, llm_service)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
click.echo(f"Error initializing validator: {e}", err=True)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
generated_criteria: str | None = asyncio.run(
|
|
281
|
+
validator.generate_criteria(
|
|
282
|
+
title=resolved.title,
|
|
283
|
+
description=resolved.description,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
if not generated_criteria:
|
|
288
|
+
click.echo("Failed to generate criteria.", err=True)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Update task with generated criteria
|
|
292
|
+
manager.update_task(resolved.id, validation_criteria=generated_criteria)
|
|
293
|
+
click.echo(f"Generated and saved validation criteria:\n{generated_criteria}")
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
click.echo(f"Error generating criteria: {e}", err=True)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _generate_criteria_for_all(manager: LocalTaskManager) -> None:
|
|
300
|
+
"""Generate validation criteria for all tasks missing it."""
|
|
301
|
+
import asyncio
|
|
302
|
+
|
|
303
|
+
from gobby.config.app import load_config
|
|
304
|
+
from gobby.llm import LLMService
|
|
305
|
+
from gobby.tasks.validation import TaskValidator
|
|
306
|
+
|
|
307
|
+
# Get all open tasks without validation criteria
|
|
308
|
+
all_tasks = manager.list_tasks(status="open", limit=1000)
|
|
309
|
+
tasks_needing_criteria = [t for t in all_tasks if not t.validation_criteria]
|
|
310
|
+
|
|
311
|
+
if not tasks_needing_criteria:
|
|
312
|
+
click.echo("All tasks already have validation criteria.")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
click.echo(f"Found {len(tasks_needing_criteria)} tasks without validation criteria.")
|
|
316
|
+
|
|
317
|
+
# Initialize validator for leaf tasks
|
|
318
|
+
try:
|
|
319
|
+
config = load_config()
|
|
320
|
+
llm_service = LLMService(config)
|
|
321
|
+
validator = TaskValidator(config.gobby_tasks.validation, llm_service)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
click.echo(f"Error initializing validator: {e}", err=True)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
parent_count = 0
|
|
327
|
+
leaf_count = 0
|
|
328
|
+
error_count = 0
|
|
329
|
+
|
|
330
|
+
# Get project context for display
|
|
331
|
+
project_ctx = get_project_context()
|
|
332
|
+
project_name = project_ctx.get("name") if project_ctx else None
|
|
333
|
+
|
|
334
|
+
for task in tasks_needing_criteria:
|
|
335
|
+
# Format task ref
|
|
336
|
+
task_ref = task.id
|
|
337
|
+
if task.seq_num:
|
|
338
|
+
if project_name:
|
|
339
|
+
task_ref = f"{project_name}-#{task.seq_num}"
|
|
340
|
+
else:
|
|
341
|
+
task_ref = f"#{task.seq_num}"
|
|
342
|
+
|
|
343
|
+
# Check if task has children (is a parent task)
|
|
344
|
+
children = manager.list_tasks(parent_task_id=task.id, limit=1)
|
|
345
|
+
|
|
346
|
+
if children:
|
|
347
|
+
# Parent task: criteria is child completion
|
|
348
|
+
parent_criteria = "All child tasks must be completed (status: closed)."
|
|
349
|
+
manager.update_task(task.id, validation_criteria=parent_criteria)
|
|
350
|
+
click.echo(f"\n[parent] {task_ref}: {task.title}")
|
|
351
|
+
click.echo(f" → {parent_criteria}")
|
|
352
|
+
parent_count += 1
|
|
353
|
+
else:
|
|
354
|
+
# Leaf task: use LLM to generate criteria
|
|
355
|
+
try:
|
|
356
|
+
leaf_criteria: str | None = asyncio.run(
|
|
357
|
+
validator.generate_criteria(
|
|
358
|
+
title=task.title,
|
|
359
|
+
description=task.description,
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
if leaf_criteria:
|
|
363
|
+
manager.update_task(task.id, validation_criteria=leaf_criteria)
|
|
364
|
+
click.echo(f"\n[leaf] {task_ref}: {task.title}")
|
|
365
|
+
# Indent each line of criteria
|
|
366
|
+
for line in leaf_criteria.strip().split("\n"):
|
|
367
|
+
click.echo(f" {line}")
|
|
368
|
+
leaf_count += 1
|
|
369
|
+
else:
|
|
370
|
+
click.echo(f"\n[error] {task_ref}: {task.title}")
|
|
371
|
+
click.echo(" Failed to generate criteria", err=True)
|
|
372
|
+
error_count += 1
|
|
373
|
+
except Exception as e:
|
|
374
|
+
click.echo(f"\n[error] {task_ref}: {task.title}")
|
|
375
|
+
click.echo(f" {e}", err=True)
|
|
376
|
+
error_count += 1
|
|
377
|
+
|
|
378
|
+
click.echo(
|
|
379
|
+
f"\nDone: {parent_count} parent tasks, {leaf_count} leaf tasks, {error_count} errors"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _find_unexpanded_epic(manager: LocalTaskManager, root_task_id: str) -> Task | None:
|
|
384
|
+
"""Depth-first search for first unexpanded epic in the task tree."""
|
|
385
|
+
task = manager.get_task(root_task_id)
|
|
386
|
+
if not task:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
# Check if this task itself is an unexpanded epic
|
|
390
|
+
if task.task_type == "epic" and not task.is_expanded:
|
|
391
|
+
return task
|
|
392
|
+
|
|
393
|
+
# Search children depth-first
|
|
394
|
+
children = manager.list_tasks(parent_task_id=root_task_id, limit=1000)
|
|
395
|
+
for child in children:
|
|
396
|
+
if child.task_type == "epic":
|
|
397
|
+
result = _find_unexpanded_epic(manager, child.id)
|
|
398
|
+
if result:
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _count_unexpanded_epics(manager: LocalTaskManager, root_task_id: str) -> int:
|
|
405
|
+
"""Count unexpanded epics in the task tree."""
|
|
406
|
+
count = 0
|
|
407
|
+
task = manager.get_task(root_task_id)
|
|
408
|
+
if not task:
|
|
409
|
+
return 0
|
|
410
|
+
|
|
411
|
+
# Count this task if it's an unexpanded epic
|
|
412
|
+
if task.task_type == "epic" and not task.is_expanded:
|
|
413
|
+
count += 1
|
|
414
|
+
|
|
415
|
+
# Count children recursively
|
|
416
|
+
children = manager.list_tasks(parent_task_id=root_task_id, limit=1000)
|
|
417
|
+
for child in children:
|
|
418
|
+
count += _count_unexpanded_epics(manager, child.id)
|
|
419
|
+
|
|
420
|
+
return count
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@click.command("expand")
|
|
424
|
+
@click.argument("task_refs", nargs=-1, required=True, metavar="TASKS...")
|
|
425
|
+
@click.option("--context", "-c", help="Additional context for expansion")
|
|
426
|
+
@click.option(
|
|
427
|
+
"--web-research/--no-web-research",
|
|
428
|
+
default=False,
|
|
429
|
+
help="Enable/disable agentic web research",
|
|
430
|
+
)
|
|
431
|
+
@click.option(
|
|
432
|
+
"--code-context/--no-code-context",
|
|
433
|
+
default=True,
|
|
434
|
+
help="Enable/disable codebase context gathering",
|
|
435
|
+
)
|
|
436
|
+
@click.option(
|
|
437
|
+
"--cascade", is_flag=True, help="Iteratively expand all child epics (default for epics)"
|
|
438
|
+
)
|
|
439
|
+
@click.option("--force", "-f", is_flag=True, help="Re-expand already expanded tasks")
|
|
440
|
+
@click.option("--project", "-p", "project_name", help="Project name or ID")
|
|
441
|
+
def expand_task_cmd(
|
|
442
|
+
task_refs: tuple[str, ...],
|
|
443
|
+
context: str | None,
|
|
444
|
+
web_research: bool,
|
|
445
|
+
code_context: bool,
|
|
446
|
+
cascade: bool,
|
|
447
|
+
force: bool,
|
|
448
|
+
project_name: str | None,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Expand a task tree. Runs iteratively until complete.
|
|
451
|
+
|
|
452
|
+
TASKS can be: #N (e.g., #1, #47), comma-separated (#1,#2,#3), or UUIDs.
|
|
453
|
+
|
|
454
|
+
For epics, automatically expands all child epics iteratively (--cascade).
|
|
455
|
+
Use --no-cascade to expand only the root task.
|
|
456
|
+
|
|
457
|
+
Examples:
|
|
458
|
+
gobby tasks expand #42 # Expands #42 and all child epics
|
|
459
|
+
gobby tasks expand #42 --force # Re-expand even if already expanded
|
|
460
|
+
"""
|
|
461
|
+
import asyncio
|
|
462
|
+
|
|
463
|
+
from gobby.cli.tasks._utils import parse_task_refs
|
|
464
|
+
from gobby.cli.utils import get_active_session_id
|
|
465
|
+
from gobby.config.app import load_config
|
|
466
|
+
from gobby.llm import LLMService
|
|
467
|
+
from gobby.storage.task_dependencies import TaskDependencyManager
|
|
468
|
+
from gobby.tasks.expansion import TaskExpander
|
|
469
|
+
from gobby.tasks.tdd import (
|
|
470
|
+
TDD_CATEGORIES,
|
|
471
|
+
apply_tdd_sandwich,
|
|
472
|
+
build_expansion_context,
|
|
473
|
+
should_skip_expansion,
|
|
474
|
+
should_skip_tdd,
|
|
475
|
+
)
|
|
476
|
+
from gobby.tasks.validation import TaskValidator
|
|
477
|
+
|
|
478
|
+
# Parse task references
|
|
479
|
+
refs = parse_task_refs(task_refs)
|
|
480
|
+
if not refs:
|
|
481
|
+
click.echo("Error: No task references provided", err=True)
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
manager = get_task_manager()
|
|
485
|
+
|
|
486
|
+
# Resolve all tasks
|
|
487
|
+
root_tasks: list[Task] = []
|
|
488
|
+
for ref in refs:
|
|
489
|
+
task = resolve_task_id(manager, ref)
|
|
490
|
+
if not task:
|
|
491
|
+
continue
|
|
492
|
+
root_tasks.append(task)
|
|
493
|
+
|
|
494
|
+
if not root_tasks:
|
|
495
|
+
click.echo("No valid tasks to expand.", err=True)
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
# Initialize services
|
|
499
|
+
try:
|
|
500
|
+
config = load_config()
|
|
501
|
+
if not config.gobby_tasks.expansion.enabled:
|
|
502
|
+
click.echo("Error: Task expansion is disabled in config.", err=True)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
llm_service = LLMService(config)
|
|
506
|
+
expander = TaskExpander(
|
|
507
|
+
config.gobby_tasks.expansion, llm_service, manager, mcp_manager=None
|
|
508
|
+
)
|
|
509
|
+
dep_manager = TaskDependencyManager(manager.db)
|
|
510
|
+
validator = TaskValidator(config.gobby_tasks.validation, llm_service)
|
|
511
|
+
auto_generate_validation = config.gobby_tasks.validation.auto_generate_on_expand
|
|
512
|
+
|
|
513
|
+
except Exception as e:
|
|
514
|
+
click.echo(f"Error initializing services: {e}", err=True)
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
async def _post_expansion_processing(task: Task, subtask_ids: list[str]) -> dict[str, Any]:
|
|
518
|
+
"""Apply MCP-parity post-expansion processing.
|
|
519
|
+
|
|
520
|
+
- Wire parent → subtask blocking dependencies
|
|
521
|
+
- Apply TDD sandwich for code/config subtasks (non-epic only)
|
|
522
|
+
- Generate validation criteria for subtasks
|
|
523
|
+
"""
|
|
524
|
+
result: dict[str, Any] = {"tdd_applied": False, "validation_generated": 0}
|
|
525
|
+
|
|
526
|
+
if not subtask_ids:
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
# 1. Wire parent → subtask blocking dependencies
|
|
530
|
+
for subtask_id in subtask_ids:
|
|
531
|
+
try:
|
|
532
|
+
dep_manager.add_dependency(
|
|
533
|
+
task_id=task.id, depends_on=subtask_id, dep_type="blocks"
|
|
534
|
+
)
|
|
535
|
+
except ValueError:
|
|
536
|
+
pass # Dependency already exists
|
|
537
|
+
|
|
538
|
+
# 2. Apply TDD sandwich (non-epic tasks with code/config subtasks)
|
|
539
|
+
if task.task_type != "epic":
|
|
540
|
+
impl_task_ids = []
|
|
541
|
+
for sid in subtask_ids:
|
|
542
|
+
subtask = manager.get_task(sid)
|
|
543
|
+
if subtask and subtask.category in TDD_CATEGORIES:
|
|
544
|
+
if not should_skip_tdd(subtask.title):
|
|
545
|
+
impl_task_ids.append(sid)
|
|
546
|
+
|
|
547
|
+
if impl_task_ids:
|
|
548
|
+
tdd_result = await apply_tdd_sandwich(manager, dep_manager, task.id, impl_task_ids)
|
|
549
|
+
if tdd_result.get("success"):
|
|
550
|
+
result["tdd_applied"] = True
|
|
551
|
+
|
|
552
|
+
# 3. Generate validation criteria for subtasks
|
|
553
|
+
if auto_generate_validation:
|
|
554
|
+
for sid in subtask_ids:
|
|
555
|
+
subtask = manager.get_task(sid)
|
|
556
|
+
if subtask and not subtask.validation_criteria and subtask.task_type != "epic":
|
|
557
|
+
try:
|
|
558
|
+
criteria = await validator.generate_criteria(
|
|
559
|
+
title=subtask.title,
|
|
560
|
+
description=subtask.description,
|
|
561
|
+
)
|
|
562
|
+
if criteria:
|
|
563
|
+
manager.update_task(sid, validation_criteria=criteria)
|
|
564
|
+
result["validation_generated"] += 1
|
|
565
|
+
except Exception:
|
|
566
|
+
pass # nosec B110 - best effort validation generation
|
|
567
|
+
|
|
568
|
+
# 4. Update parent task: set is_expanded and validation criteria
|
|
569
|
+
manager.update_task(
|
|
570
|
+
task.id,
|
|
571
|
+
is_expanded=True,
|
|
572
|
+
validation_criteria="All child tasks must be completed (status: closed).",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
# Get current session ID for expansion context
|
|
578
|
+
session_id = get_active_session_id()
|
|
579
|
+
|
|
580
|
+
# Process each root task
|
|
581
|
+
total_iterations = 0
|
|
582
|
+
total_subtasks = 0
|
|
583
|
+
|
|
584
|
+
for root_task in root_tasks:
|
|
585
|
+
root_ref = f"#{root_task.seq_num}" if root_task.seq_num else root_task.id[:8]
|
|
586
|
+
|
|
587
|
+
# For epics, default to cascade mode
|
|
588
|
+
should_cascade = cascade or root_task.task_type == "epic"
|
|
589
|
+
|
|
590
|
+
if should_cascade:
|
|
591
|
+
# Iterative expansion mode
|
|
592
|
+
click.echo(f"Expanding {root_ref}: {root_task.title[:50]}...")
|
|
593
|
+
if web_research:
|
|
594
|
+
click.echo(" • Web research enabled")
|
|
595
|
+
if code_context:
|
|
596
|
+
click.echo(" • Code context enabled")
|
|
597
|
+
|
|
598
|
+
iteration = 0
|
|
599
|
+
while True:
|
|
600
|
+
iteration += 1
|
|
601
|
+
|
|
602
|
+
# Find next unexpanded epic
|
|
603
|
+
target = _find_unexpanded_epic(manager, root_task.id)
|
|
604
|
+
|
|
605
|
+
if target is None:
|
|
606
|
+
click.echo(f"✓ Expansion complete after {iteration - 1} iterations")
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
# Re-fetch to get latest state
|
|
610
|
+
target = manager.get_task(target.id)
|
|
611
|
+
if target is None:
|
|
612
|
+
click.echo(" Task deleted during expansion", err=True)
|
|
613
|
+
break
|
|
614
|
+
|
|
615
|
+
# Check if task should be skipped (TDD prefixes or already expanded)
|
|
616
|
+
skip, reason = should_skip_expansion(target.title, target.is_expanded, force)
|
|
617
|
+
if skip:
|
|
618
|
+
target_ref = f"#{target.seq_num}" if target.seq_num else target.id[:8]
|
|
619
|
+
click.echo(f" Skipping {target_ref}: {reason}")
|
|
620
|
+
continue
|
|
621
|
+
|
|
622
|
+
target_ref = f"#{target.seq_num}" if target.seq_num else target.id[:8]
|
|
623
|
+
click.echo(f"[{iteration}] Expanding {target_ref}: {target.title[:40]}...")
|
|
624
|
+
|
|
625
|
+
# Build merged context from stored expansion_context + user context
|
|
626
|
+
merged_context = build_expansion_context(target.expansion_context, context)
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
result = asyncio.run(
|
|
630
|
+
expander.expand_task(
|
|
631
|
+
task_id=target.id,
|
|
632
|
+
title=target.title,
|
|
633
|
+
description=target.description,
|
|
634
|
+
context=merged_context,
|
|
635
|
+
enable_web_research=web_research,
|
|
636
|
+
enable_code_context=code_context,
|
|
637
|
+
session_id=session_id,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
except Exception as e:
|
|
641
|
+
click.echo(f" Error: {e}", err=True)
|
|
642
|
+
break
|
|
643
|
+
|
|
644
|
+
if "error" in result:
|
|
645
|
+
click.echo(f" Error: {result['error']}", err=True)
|
|
646
|
+
break
|
|
647
|
+
|
|
648
|
+
subtasks = result.get("subtask_ids", [])
|
|
649
|
+
click.echo(f" → Created {len(subtasks)} subtasks")
|
|
650
|
+
total_subtasks += len(subtasks)
|
|
651
|
+
|
|
652
|
+
# Apply post-expansion processing (deps, TDD sandwich, validation)
|
|
653
|
+
post_result = asyncio.run(_post_expansion_processing(target, subtasks))
|
|
654
|
+
if post_result.get("tdd_applied"):
|
|
655
|
+
click.echo(" → Applied TDD sandwich")
|
|
656
|
+
if post_result.get("validation_generated", 0) > 0:
|
|
657
|
+
click.echo(
|
|
658
|
+
f" → Generated {post_result['validation_generated']} validation criteria"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
remaining = _count_unexpanded_epics(manager, root_task.id)
|
|
662
|
+
if remaining > 0:
|
|
663
|
+
click.echo(f" → {remaining} epic(s) remaining")
|
|
664
|
+
|
|
665
|
+
total_iterations += iteration - 1
|
|
666
|
+
|
|
667
|
+
else:
|
|
668
|
+
# Single task expansion (non-cascade)
|
|
669
|
+
skip, reason = should_skip_expansion(root_task.title, root_task.is_expanded, force)
|
|
670
|
+
if skip:
|
|
671
|
+
click.echo(f"Skipping {root_ref}: {reason}")
|
|
672
|
+
continue
|
|
673
|
+
|
|
674
|
+
click.echo(f"Expanding {root_ref}: {root_task.title[:50]}...")
|
|
675
|
+
|
|
676
|
+
# Build merged context from stored expansion_context + user context
|
|
677
|
+
merged_context = build_expansion_context(root_task.expansion_context, context)
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
result = asyncio.run(
|
|
681
|
+
expander.expand_task(
|
|
682
|
+
task_id=root_task.id,
|
|
683
|
+
title=root_task.title,
|
|
684
|
+
description=root_task.description,
|
|
685
|
+
context=merged_context,
|
|
686
|
+
enable_web_research=web_research,
|
|
687
|
+
enable_code_context=code_context,
|
|
688
|
+
session_id=session_id,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
except Exception as e:
|
|
692
|
+
click.echo(f" Error: {e}", err=True)
|
|
693
|
+
continue
|
|
694
|
+
|
|
695
|
+
if "error" in result:
|
|
696
|
+
click.echo(f" Error: {result['error']}", err=True)
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
subtasks = result.get("subtask_ids", [])
|
|
700
|
+
click.echo(f" Created {len(subtasks)} subtasks")
|
|
701
|
+
|
|
702
|
+
# Apply post-expansion processing (deps, TDD sandwich, validation)
|
|
703
|
+
post_result = asyncio.run(_post_expansion_processing(root_task, subtasks))
|
|
704
|
+
if post_result.get("tdd_applied"):
|
|
705
|
+
click.echo(" → Applied TDD sandwich")
|
|
706
|
+
if post_result.get("validation_generated", 0) > 0:
|
|
707
|
+
click.echo(
|
|
708
|
+
f" → Generated {post_result['validation_generated']} validation criteria"
|
|
709
|
+
)
|
|
710
|
+
total_subtasks += len(subtasks)
|
|
711
|
+
total_iterations += 1
|
|
712
|
+
|
|
713
|
+
if len(root_tasks) > 1:
|
|
714
|
+
click.echo(f"\nTotal: {total_subtasks} subtasks across {total_iterations} expansions")
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@click.command("complexity")
|
|
718
|
+
@click.argument("task_id", required=False)
|
|
719
|
+
@click.option("--all", "analyze_all", is_flag=True, help="Analyze all pending tasks")
|
|
720
|
+
@click.option("--pending", is_flag=True, help="Only analyze pending (open) tasks (use with --all)")
|
|
721
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
722
|
+
def complexity_cmd(
|
|
723
|
+
task_id: str | None,
|
|
724
|
+
analyze_all: bool,
|
|
725
|
+
pending: bool,
|
|
726
|
+
json_format: bool,
|
|
727
|
+
) -> None:
|
|
728
|
+
"""Analyze task complexity based on subtasks or description."""
|
|
729
|
+
import json as json_mod
|
|
730
|
+
|
|
731
|
+
manager = get_task_manager()
|
|
732
|
+
|
|
733
|
+
if analyze_all:
|
|
734
|
+
# Batch analysis
|
|
735
|
+
project_ctx = get_project_context()
|
|
736
|
+
project_id = project_ctx.get("id") if project_ctx else None
|
|
737
|
+
|
|
738
|
+
status_filter = "open" if pending else None
|
|
739
|
+
tasks_list = manager.list_tasks(
|
|
740
|
+
project_id=project_id,
|
|
741
|
+
status=status_filter,
|
|
742
|
+
limit=100,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
if not tasks_list:
|
|
746
|
+
click.echo("No tasks found to analyze.")
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
results = []
|
|
750
|
+
for task in tasks_list:
|
|
751
|
+
result = _analyze_task_complexity(manager, task)
|
|
752
|
+
results.append(result)
|
|
753
|
+
|
|
754
|
+
if json_format:
|
|
755
|
+
click.echo(json_mod.dumps(results, indent=2))
|
|
756
|
+
return
|
|
757
|
+
|
|
758
|
+
click.echo(f"Analyzed {len(results)} tasks:\n")
|
|
759
|
+
for r in results:
|
|
760
|
+
click.echo(
|
|
761
|
+
f" {r['task_id'][:12]} | Score: {r['complexity_score']:2}/10 | {r['title'][:50]}"
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
else:
|
|
765
|
+
# Single task analysis
|
|
766
|
+
if not task_id:
|
|
767
|
+
click.echo("Error: TASK_ID required (or use --all)", err=True)
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
resolved = resolve_task_id(manager, task_id)
|
|
771
|
+
if not resolved:
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
result = _analyze_task_complexity(manager, resolved)
|
|
775
|
+
|
|
776
|
+
if json_format:
|
|
777
|
+
click.echo(json_mod.dumps(result, indent=2))
|
|
778
|
+
return
|
|
779
|
+
|
|
780
|
+
click.echo(f"Task: {result['title']}")
|
|
781
|
+
click.echo(f"ID: {result['task_id']}")
|
|
782
|
+
click.echo(f"Complexity Score: {result['complexity_score']}/10")
|
|
783
|
+
click.echo(f"Reasoning: {result['reasoning']}")
|
|
784
|
+
click.echo(f"Recommended Subtasks: {result['recommended_subtasks']}")
|
|
785
|
+
if result["existing_subtasks"] > 0:
|
|
786
|
+
click.echo(f"Existing Subtasks: {result['existing_subtasks']}")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _analyze_task_complexity(manager: LocalTaskManager, task: Task) -> dict[str, Any]:
|
|
790
|
+
"""Analyze complexity for a single task. Returns dict with results."""
|
|
791
|
+
# Check for existing subtasks
|
|
792
|
+
subtasks = manager.list_tasks(parent_task_id=task.id, limit=100)
|
|
793
|
+
subtask_count = len(subtasks)
|
|
794
|
+
|
|
795
|
+
# Simple heuristic-based complexity
|
|
796
|
+
if subtask_count > 0:
|
|
797
|
+
score = min(10, 1 + subtask_count // 2)
|
|
798
|
+
reasoning = f"Task has {subtask_count} subtasks"
|
|
799
|
+
recommended = subtask_count
|
|
800
|
+
else:
|
|
801
|
+
desc_len = len(task.description or "")
|
|
802
|
+
if desc_len < 100:
|
|
803
|
+
score = 2
|
|
804
|
+
reasoning = "Short description, likely simple task"
|
|
805
|
+
recommended = 2
|
|
806
|
+
elif desc_len < 500:
|
|
807
|
+
score = 5
|
|
808
|
+
reasoning = "Medium description, moderate complexity"
|
|
809
|
+
recommended = 5
|
|
810
|
+
else:
|
|
811
|
+
score = 8
|
|
812
|
+
reasoning = "Long description, likely complex task"
|
|
813
|
+
recommended = 10
|
|
814
|
+
|
|
815
|
+
# Update task with complexity score
|
|
816
|
+
manager.update_task(
|
|
817
|
+
task.id,
|
|
818
|
+
complexity_score=score,
|
|
819
|
+
estimated_subtasks=recommended,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
"task_id": task.id,
|
|
824
|
+
"title": task.title,
|
|
825
|
+
"complexity_score": score,
|
|
826
|
+
"reasoning": reasoning,
|
|
827
|
+
"recommended_subtasks": recommended,
|
|
828
|
+
"existing_subtasks": subtask_count,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@click.command("expand-all")
|
|
833
|
+
@click.option("--max", "-m", "max_tasks", default=5, help="Maximum tasks to expand")
|
|
834
|
+
@click.option("--min-complexity", default=1, help="Only expand tasks with complexity >= this")
|
|
835
|
+
@click.option("--type", "task_type", help="Filter by task type")
|
|
836
|
+
@click.option("--web-research/--no-web-research", default=False, help="Enable web research")
|
|
837
|
+
@click.option("--dry-run", "-d", is_flag=True, help="Show what would be expanded without doing it")
|
|
838
|
+
def expand_all_cmd(
|
|
839
|
+
max_tasks: int,
|
|
840
|
+
min_complexity: int,
|
|
841
|
+
task_type: str | None,
|
|
842
|
+
web_research: bool,
|
|
843
|
+
dry_run: bool,
|
|
844
|
+
) -> None:
|
|
845
|
+
"""Expand all unexpanded tasks (tasks without subtasks)."""
|
|
846
|
+
import asyncio
|
|
847
|
+
|
|
848
|
+
from gobby.config.app import load_config
|
|
849
|
+
from gobby.llm import LLMService
|
|
850
|
+
from gobby.tasks.expansion import TaskExpander
|
|
851
|
+
|
|
852
|
+
manager = get_task_manager()
|
|
853
|
+
|
|
854
|
+
# Find tasks without children
|
|
855
|
+
all_tasks = manager.list_tasks(status="open", task_type=task_type, limit=100)
|
|
856
|
+
|
|
857
|
+
unexpanded = []
|
|
858
|
+
for t in all_tasks:
|
|
859
|
+
children = manager.list_tasks(parent_task_id=t.id, limit=1)
|
|
860
|
+
if not children:
|
|
861
|
+
if t.complexity_score is None or t.complexity_score >= min_complexity:
|
|
862
|
+
unexpanded.append(t)
|
|
863
|
+
|
|
864
|
+
to_expand = unexpanded[:max_tasks]
|
|
865
|
+
|
|
866
|
+
if not to_expand:
|
|
867
|
+
click.echo("No unexpanded tasks found matching criteria.")
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
if dry_run:
|
|
871
|
+
click.echo(f"Would expand {len(to_expand)} tasks:")
|
|
872
|
+
for t in to_expand:
|
|
873
|
+
score = t.complexity_score or "?"
|
|
874
|
+
click.echo(f" {t.id[:12]} | Complexity: {score} | {t.title[:50]}")
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
# Initialize services
|
|
878
|
+
try:
|
|
879
|
+
config = load_config()
|
|
880
|
+
if not config.gobby_tasks.expansion.enabled:
|
|
881
|
+
click.echo("Error: Task expansion is disabled in config.", err=True)
|
|
882
|
+
return
|
|
883
|
+
|
|
884
|
+
llm_service = LLMService(config)
|
|
885
|
+
expander = TaskExpander(
|
|
886
|
+
config.gobby_tasks.expansion, llm_service, manager, mcp_manager=None
|
|
887
|
+
)
|
|
888
|
+
except Exception as e:
|
|
889
|
+
click.echo(f"Error initializing services: {e}", err=True)
|
|
890
|
+
return
|
|
891
|
+
|
|
892
|
+
click.echo(f"Expanding {len(to_expand)} tasks...")
|
|
893
|
+
|
|
894
|
+
async def expand_tasks() -> list[dict[str, Any]]:
|
|
895
|
+
results = []
|
|
896
|
+
for task in to_expand:
|
|
897
|
+
click.echo(f"\nExpanding: {task.title[:60]}...")
|
|
898
|
+
try:
|
|
899
|
+
result = await expander.expand_task(
|
|
900
|
+
task_id=task.id,
|
|
901
|
+
title=task.title,
|
|
902
|
+
description=task.description,
|
|
903
|
+
enable_web_research=web_research,
|
|
904
|
+
enable_code_context=True,
|
|
905
|
+
)
|
|
906
|
+
subtask_ids = result.get("subtask_ids", [])
|
|
907
|
+
results.append(
|
|
908
|
+
{
|
|
909
|
+
"task_id": task.id,
|
|
910
|
+
"title": task.title,
|
|
911
|
+
"subtasks_created": len(subtask_ids),
|
|
912
|
+
"status": "success" if not result.get("error") else "error",
|
|
913
|
+
"error": result.get("error"),
|
|
914
|
+
}
|
|
915
|
+
)
|
|
916
|
+
if result.get("error"):
|
|
917
|
+
click.echo(f" Error: {result['error']}")
|
|
918
|
+
else:
|
|
919
|
+
click.echo(f" Created {len(subtask_ids)} subtasks")
|
|
920
|
+
except Exception as e:
|
|
921
|
+
results.append(
|
|
922
|
+
{
|
|
923
|
+
"task_id": task.id,
|
|
924
|
+
"title": task.title,
|
|
925
|
+
"status": "error",
|
|
926
|
+
"error": str(e),
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
click.echo(f" Error: {e}")
|
|
930
|
+
return results
|
|
931
|
+
|
|
932
|
+
results = asyncio.run(expand_tasks())
|
|
933
|
+
|
|
934
|
+
success_count = len([r for r in results if r["status"] == "success"])
|
|
935
|
+
click.echo(f"\nExpanded {success_count}/{len(results)} tasks successfully.")
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@click.command("suggest")
|
|
939
|
+
@click.option("--type", "-t", "task_type", help="Filter by task type")
|
|
940
|
+
@click.option("--no-prefer-subtasks", is_flag=True, help="Don't prefer leaf tasks over parents")
|
|
941
|
+
@click.option("--json", "json_format", is_flag=True, help="Output as JSON")
|
|
942
|
+
def suggest_cmd(task_type: str | None, no_prefer_subtasks: bool, json_format: bool) -> None:
|
|
943
|
+
"""Suggest the next task to work on based on priority and readiness."""
|
|
944
|
+
import json as json_mod
|
|
945
|
+
|
|
946
|
+
manager = get_task_manager()
|
|
947
|
+
prefer_subtasks = not no_prefer_subtasks
|
|
948
|
+
|
|
949
|
+
ready_tasks = manager.list_ready_tasks(task_type=task_type, limit=50)
|
|
950
|
+
|
|
951
|
+
if not ready_tasks:
|
|
952
|
+
if json_format:
|
|
953
|
+
click.echo(json_mod.dumps({"suggestion": None, "reason": "No ready tasks found"}))
|
|
954
|
+
else:
|
|
955
|
+
click.echo("No ready tasks found.")
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
# Score each task
|
|
959
|
+
scored = []
|
|
960
|
+
for task in ready_tasks:
|
|
961
|
+
score = 0
|
|
962
|
+
|
|
963
|
+
# Priority boost (1=high gets +30, 2=medium gets +20, 3=low gets +10)
|
|
964
|
+
score += (4 - task.priority) * 10
|
|
965
|
+
|
|
966
|
+
# Check if it's a leaf task (no children)
|
|
967
|
+
children = manager.list_tasks(parent_task_id=task.id, status="open", limit=1)
|
|
968
|
+
is_leaf = len(children) == 0
|
|
969
|
+
|
|
970
|
+
if prefer_subtasks and is_leaf:
|
|
971
|
+
score += 25
|
|
972
|
+
|
|
973
|
+
# Bonus for tasks with clear complexity
|
|
974
|
+
if task.complexity_score and task.complexity_score <= 5:
|
|
975
|
+
score += 15
|
|
976
|
+
|
|
977
|
+
# Bonus for tasks with category defined
|
|
978
|
+
if task.category:
|
|
979
|
+
score += 10
|
|
980
|
+
|
|
981
|
+
scored.append((task, score, is_leaf))
|
|
982
|
+
|
|
983
|
+
# Sort by score descending
|
|
984
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
985
|
+
best_task, best_score, is_leaf = scored[0]
|
|
986
|
+
|
|
987
|
+
reasons = []
|
|
988
|
+
if best_task.priority == 1:
|
|
989
|
+
reasons.append("high priority")
|
|
990
|
+
if is_leaf:
|
|
991
|
+
reasons.append("actionable leaf task")
|
|
992
|
+
if best_task.complexity_score and best_task.complexity_score <= 5:
|
|
993
|
+
reasons.append("manageable complexity")
|
|
994
|
+
if best_task.category:
|
|
995
|
+
reasons.append(f"has category ({best_task.category})")
|
|
996
|
+
|
|
997
|
+
reason_str = f"Selected because: {', '.join(reasons) if reasons else 'best available option'}"
|
|
998
|
+
|
|
999
|
+
if json_format:
|
|
1000
|
+
result = {
|
|
1001
|
+
"suggestion": best_task.to_dict(),
|
|
1002
|
+
"score": best_score,
|
|
1003
|
+
"reason": reason_str,
|
|
1004
|
+
"alternatives": [
|
|
1005
|
+
{"task_id": t.id, "title": t.title, "score": s} for t, s, _ in scored[1:4]
|
|
1006
|
+
],
|
|
1007
|
+
}
|
|
1008
|
+
click.echo(json_mod.dumps(result, indent=2, default=str))
|
|
1009
|
+
return
|
|
1010
|
+
|
|
1011
|
+
click.echo("Suggested next task:\n")
|
|
1012
|
+
click.echo(f" {best_task.id}")
|
|
1013
|
+
click.echo(f" {best_task.title}")
|
|
1014
|
+
click.echo(f" Priority: {best_task.priority} | Status: {best_task.status}")
|
|
1015
|
+
if best_task.description:
|
|
1016
|
+
desc_preview = best_task.description[:200]
|
|
1017
|
+
if len(best_task.description) > 200:
|
|
1018
|
+
desc_preview += "..."
|
|
1019
|
+
click.echo(f"\n {desc_preview}")
|
|
1020
|
+
click.echo(f"\n {reason_str}")
|
|
1021
|
+
|
|
1022
|
+
if len(scored) > 1:
|
|
1023
|
+
click.echo("\nAlternatives:")
|
|
1024
|
+
for task, _score, _ in scored[1:4]:
|
|
1025
|
+
click.echo(f" {task.id[:12]}: {task.title[:50]}")
|