spec-kitty-cli 0.12.1__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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
"""Task workflow commands for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from typing_extensions import Annotated
|
|
14
|
+
|
|
15
|
+
from specify_cli.core.dependency_graph import build_dependency_graph, get_dependents
|
|
16
|
+
from specify_cli.core.paths import locate_project_root, get_main_repo_root, find_feature_slug, is_worktree_context
|
|
17
|
+
from specify_cli.tasks_support import (
|
|
18
|
+
LANES,
|
|
19
|
+
WorkPackage,
|
|
20
|
+
activity_entries,
|
|
21
|
+
append_activity_log,
|
|
22
|
+
build_document,
|
|
23
|
+
ensure_lane,
|
|
24
|
+
extract_scalar,
|
|
25
|
+
locate_work_package,
|
|
26
|
+
set_scalar,
|
|
27
|
+
split_frontmatter,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="tasks",
|
|
32
|
+
help="Task workflow commands for AI agents",
|
|
33
|
+
no_args_is_help=True
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_feature_slug() -> str:
|
|
40
|
+
"""Find the current feature slug from the working directory or git branch.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Feature slug (e.g., "008-unified-python-cli")
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
typer.Exit: If feature slug cannot be determined
|
|
47
|
+
"""
|
|
48
|
+
cwd = Path.cwd().resolve()
|
|
49
|
+
repo_root = locate_project_root(cwd)
|
|
50
|
+
|
|
51
|
+
if repo_root is None:
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
slug = find_feature_slug(repo_root)
|
|
55
|
+
if slug is None:
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
return slug
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _output_result(json_mode: bool, data: dict, success_message: str = None):
|
|
62
|
+
"""Output result in JSON or human-readable format.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
json_mode: If True, output JSON; else use Rich console
|
|
66
|
+
data: Data to output (used for JSON mode)
|
|
67
|
+
success_message: Message to display in human mode
|
|
68
|
+
"""
|
|
69
|
+
if json_mode:
|
|
70
|
+
print(json.dumps(data))
|
|
71
|
+
elif success_message:
|
|
72
|
+
console.print(success_message)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _output_error(json_mode: bool, error_message: str):
|
|
76
|
+
"""Output error in JSON or human-readable format.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
json_mode: If True, output JSON; else use Rich console
|
|
80
|
+
error_message: Error message to display
|
|
81
|
+
"""
|
|
82
|
+
if json_mode:
|
|
83
|
+
print(json.dumps({"error": error_message}))
|
|
84
|
+
else:
|
|
85
|
+
console.print(f"[red]Error:[/red] {error_message}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_unchecked_subtasks(
|
|
89
|
+
repo_root: Path,
|
|
90
|
+
feature_slug: str,
|
|
91
|
+
wp_id: str,
|
|
92
|
+
force: bool
|
|
93
|
+
) -> list[str]:
|
|
94
|
+
"""Check for unchecked subtasks in tasks.md for a given WP.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
repo_root: Repository root path
|
|
98
|
+
feature_slug: Feature slug (e.g., "010-workspace-per-wp")
|
|
99
|
+
wp_id: Work package ID (e.g., "WP01")
|
|
100
|
+
force: If True, only warn; if False, fail on unchecked tasks
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of unchecked task IDs (empty if all checked or not found)
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
typer.Exit: If unchecked tasks found and force=False
|
|
107
|
+
"""
|
|
108
|
+
# Use main repo (worktrees have kitty-specs/ sparse-checked out)
|
|
109
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
110
|
+
feature_dir = main_repo_root / "kitty-specs" / feature_slug
|
|
111
|
+
tasks_md = feature_dir / "tasks.md"
|
|
112
|
+
|
|
113
|
+
if not tasks_md.exists():
|
|
114
|
+
return [] # No tasks.md, can't check
|
|
115
|
+
|
|
116
|
+
content = tasks_md.read_text(encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
# Find subtasks for this WP (looking for - [ ] or - [x] checkboxes under WP section)
|
|
119
|
+
lines = content.split('\n')
|
|
120
|
+
unchecked = []
|
|
121
|
+
in_wp_section = False
|
|
122
|
+
|
|
123
|
+
for line in lines:
|
|
124
|
+
# Check if we entered this WP's section
|
|
125
|
+
if re.search(rf'##.*{wp_id}\b', line):
|
|
126
|
+
in_wp_section = True
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Check if we entered a different WP section
|
|
130
|
+
if in_wp_section and re.search(r'##.*WP\d{2}\b', line):
|
|
131
|
+
break # Left this WP's section
|
|
132
|
+
|
|
133
|
+
# Look for unchecked tasks in this WP's section
|
|
134
|
+
if in_wp_section:
|
|
135
|
+
# Match patterns like: - [ ] T001 or - [ ] Task description
|
|
136
|
+
unchecked_match = re.match(r'-\s*\[\s*\]\s*(T\d{3}|.*)', line.strip())
|
|
137
|
+
if unchecked_match:
|
|
138
|
+
task_id = unchecked_match.group(1).split()[0] if unchecked_match.group(1) else line.strip()
|
|
139
|
+
unchecked.append(task_id)
|
|
140
|
+
|
|
141
|
+
return unchecked
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_dependent_warnings(
|
|
145
|
+
repo_root: Path,
|
|
146
|
+
feature_slug: str,
|
|
147
|
+
wp_id: str,
|
|
148
|
+
target_lane: str,
|
|
149
|
+
json_mode: bool
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Display warning when WP moves to for_review and has incomplete dependents.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
repo_root: Repository root path
|
|
155
|
+
feature_slug: Feature slug (e.g., "010-workspace-per-wp")
|
|
156
|
+
wp_id: Work package ID (e.g., "WP01")
|
|
157
|
+
target_lane: Target lane being moved to
|
|
158
|
+
json_mode: If True, suppress Rich console output
|
|
159
|
+
"""
|
|
160
|
+
# Only warn when moving to for_review
|
|
161
|
+
if target_lane != "for_review":
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Don't show warnings in JSON mode
|
|
165
|
+
if json_mode:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Use main repo (worktrees have kitty-specs/ sparse-checked out)
|
|
169
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
170
|
+
feature_dir = main_repo_root / "kitty-specs" / feature_slug
|
|
171
|
+
|
|
172
|
+
# Build dependency graph
|
|
173
|
+
try:
|
|
174
|
+
graph = build_dependency_graph(feature_dir)
|
|
175
|
+
except Exception:
|
|
176
|
+
# If we can't build the graph, skip warnings
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Get dependents
|
|
180
|
+
dependents = get_dependents(wp_id, graph)
|
|
181
|
+
if not dependents:
|
|
182
|
+
return # No dependents, no warnings
|
|
183
|
+
|
|
184
|
+
# Check if any dependents are incomplete (not yet done)
|
|
185
|
+
incomplete = []
|
|
186
|
+
for dep_id in dependents:
|
|
187
|
+
try:
|
|
188
|
+
# Find dependent WP file
|
|
189
|
+
tasks_dir = feature_dir / "tasks"
|
|
190
|
+
dep_files = list(tasks_dir.glob(f"{dep_id}-*.md"))
|
|
191
|
+
if not dep_files:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Read frontmatter
|
|
195
|
+
content = dep_files[0].read_text(encoding="utf-8-sig")
|
|
196
|
+
frontmatter, _, _ = split_frontmatter(content)
|
|
197
|
+
lane = extract_scalar(frontmatter, "lane") or "planned"
|
|
198
|
+
|
|
199
|
+
if lane in ["planned", "doing"]:
|
|
200
|
+
incomplete.append(dep_id)
|
|
201
|
+
except Exception:
|
|
202
|
+
# Skip if we can't read the dependent
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if incomplete:
|
|
206
|
+
console.print(f"\n[yellow]⚠️ Dependency Alert[/yellow]")
|
|
207
|
+
console.print(f"{', '.join(incomplete)} depend on {wp_id} (not yet done)")
|
|
208
|
+
console.print("\nIf changes are requested during review:")
|
|
209
|
+
console.print(" 1. Notify dependent WP agents")
|
|
210
|
+
console.print(" 2. Dependent WPs will need manual rebase after changes")
|
|
211
|
+
for dep in incomplete:
|
|
212
|
+
console.print(f" cd .worktrees/{feature_slug}-{dep} && git rebase {feature_slug}-{wp_id}")
|
|
213
|
+
console.print()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command(name="move-task")
|
|
217
|
+
def move_task(
|
|
218
|
+
task_id: Annotated[str, typer.Argument(help="Task ID (e.g., WP01)")],
|
|
219
|
+
to: Annotated[str, typer.Option("--to", help="Target lane (planned/doing/for_review/done)")],
|
|
220
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
221
|
+
agent: Annotated[Optional[str], typer.Option("--agent", help="Agent name")] = None,
|
|
222
|
+
assignee: Annotated[Optional[str], typer.Option("--assignee", help="Assignee name (sets assignee when moving to doing)")] = None,
|
|
223
|
+
shell_pid: Annotated[Optional[str], typer.Option("--shell-pid", help="Shell PID")] = None,
|
|
224
|
+
note: Annotated[Optional[str], typer.Option("--note", help="History note")] = None,
|
|
225
|
+
review_feedback_file: Annotated[Optional[Path], typer.Option("--review-feedback-file", help="Path to review feedback file (required when moving to planned from review)")] = None,
|
|
226
|
+
reviewer: Annotated[Optional[str], typer.Option("--reviewer", help="Reviewer name (auto-detected from git if omitted)")] = None,
|
|
227
|
+
force: Annotated[bool, typer.Option("--force", help="Force move even with unchecked subtasks or missing feedback")] = False,
|
|
228
|
+
auto_commit: Annotated[bool, typer.Option("--auto-commit/--no-auto-commit", help="Automatically commit WP file changes to main branch")] = True,
|
|
229
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Move task between lanes (planned → doing → for_review → done).
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
spec-kitty agent tasks move-task WP01 --to doing --assignee claude --json
|
|
235
|
+
spec-kitty agent tasks move-task WP02 --to for_review --agent claude --shell-pid $$
|
|
236
|
+
spec-kitty agent tasks move-task WP03 --to done --note "Review passed"
|
|
237
|
+
spec-kitty agent tasks move-task WP03 --to planned --review-feedback-file feedback.md
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
# Validate lane
|
|
241
|
+
target_lane = ensure_lane(to)
|
|
242
|
+
|
|
243
|
+
# Get repo root and feature slug
|
|
244
|
+
repo_root = locate_project_root()
|
|
245
|
+
if repo_root is None:
|
|
246
|
+
_output_error(json_output, "Could not locate project root")
|
|
247
|
+
raise typer.Exit(1)
|
|
248
|
+
|
|
249
|
+
feature_slug = feature or _find_feature_slug()
|
|
250
|
+
|
|
251
|
+
# Informational: Let user know we're using main repo's kitty-specs
|
|
252
|
+
cwd = Path.cwd().resolve()
|
|
253
|
+
if is_worktree_context(cwd) and not json_output:
|
|
254
|
+
main_root = get_main_repo_root(repo_root)
|
|
255
|
+
if cwd != main_root:
|
|
256
|
+
# Check if worktree has its own kitty-specs (stale copy)
|
|
257
|
+
worktree_kitty = None
|
|
258
|
+
current = cwd
|
|
259
|
+
while current != current.parent and ".worktrees" in str(current):
|
|
260
|
+
if (current / "kitty-specs").exists():
|
|
261
|
+
worktree_kitty = current / "kitty-specs"
|
|
262
|
+
break
|
|
263
|
+
current = current.parent
|
|
264
|
+
|
|
265
|
+
if worktree_kitty and (worktree_kitty / feature_slug / "tasks").exists():
|
|
266
|
+
console.print(
|
|
267
|
+
f"[dim]Note: Using main repo's kitty-specs/ (worktree copy ignored)[/dim]"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Load work package first (needed for current_lane check)
|
|
271
|
+
wp = locate_work_package(repo_root, feature_slug, task_id)
|
|
272
|
+
old_lane = wp.current_lane
|
|
273
|
+
|
|
274
|
+
# AGENT OWNERSHIP CHECK: Warn if agent doesn't match WP's current agent
|
|
275
|
+
# This helps prevent agents from accidentally modifying WPs they don't own
|
|
276
|
+
current_agent = extract_scalar(wp.frontmatter, "agent")
|
|
277
|
+
if current_agent and agent and current_agent != agent and not force:
|
|
278
|
+
if not json_output:
|
|
279
|
+
console.print()
|
|
280
|
+
console.print("[bold red]⚠️ AGENT OWNERSHIP WARNING[/bold red]")
|
|
281
|
+
console.print(f" {task_id} is currently assigned to: [cyan]{current_agent}[/cyan]")
|
|
282
|
+
console.print(f" You are trying to move it as: [yellow]{agent}[/yellow]")
|
|
283
|
+
console.print()
|
|
284
|
+
console.print(" If you are the correct agent, use --force to override.")
|
|
285
|
+
console.print(" If not, you may be modifying the wrong WP!")
|
|
286
|
+
console.print()
|
|
287
|
+
_output_error(json_output, f"Agent mismatch: {task_id} is assigned to '{current_agent}', not '{agent}'. Use --force to override.")
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
290
|
+
# Validate review feedback when moving to planned (likely from review)
|
|
291
|
+
if target_lane == "planned" and old_lane == "for_review" and not review_feedback_file and not force:
|
|
292
|
+
error_msg = f"❌ Moving {task_id} from 'for_review' to 'planned' requires review feedback.\n\n"
|
|
293
|
+
error_msg += "Please provide feedback:\n"
|
|
294
|
+
error_msg += " 1. Create feedback file: echo '**Issue**: Description' > feedback.md\n"
|
|
295
|
+
error_msg += f" 2. Run: spec-kitty agent tasks move-task {task_id} --to planned --review-feedback-file feedback.md\n\n"
|
|
296
|
+
error_msg += "OR use --force to skip feedback (not recommended)"
|
|
297
|
+
_output_error(json_output, error_msg)
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
# Validate subtasks are complete when moving to for_review or done (Issue #72)
|
|
301
|
+
if target_lane in ("for_review", "done") and not force:
|
|
302
|
+
unchecked = _check_unchecked_subtasks(repo_root, feature_slug, task_id, force)
|
|
303
|
+
if unchecked:
|
|
304
|
+
error_msg = f"Cannot move {task_id} to {target_lane} - unchecked subtasks:\n"
|
|
305
|
+
for task in unchecked:
|
|
306
|
+
error_msg += f" - [ ] {task}\n"
|
|
307
|
+
error_msg += f"\nMark these complete first:\n"
|
|
308
|
+
for task in unchecked[:3]: # Show first 3 examples
|
|
309
|
+
task_clean = task.split()[0] if ' ' in task else task
|
|
310
|
+
error_msg += f" spec-kitty agent tasks mark-status {task_clean} --status done\n"
|
|
311
|
+
error_msg += f"\nOr use --force to override (not recommended)"
|
|
312
|
+
_output_error(json_output, error_msg)
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
|
|
315
|
+
# Update lane in frontmatter
|
|
316
|
+
updated_front = set_scalar(wp.frontmatter, "lane", target_lane)
|
|
317
|
+
|
|
318
|
+
# Update assignee if provided
|
|
319
|
+
if assignee:
|
|
320
|
+
updated_front = set_scalar(updated_front, "assignee", assignee)
|
|
321
|
+
|
|
322
|
+
# Update agent if provided
|
|
323
|
+
if agent:
|
|
324
|
+
updated_front = set_scalar(updated_front, "agent", agent)
|
|
325
|
+
|
|
326
|
+
# Update shell_pid if provided
|
|
327
|
+
if shell_pid:
|
|
328
|
+
updated_front = set_scalar(updated_front, "shell_pid", shell_pid)
|
|
329
|
+
|
|
330
|
+
# Handle review feedback insertion if moving to planned with feedback
|
|
331
|
+
updated_body = wp.body
|
|
332
|
+
if review_feedback_file and review_feedback_file.exists():
|
|
333
|
+
# Read feedback content
|
|
334
|
+
feedback_content = review_feedback_file.read_text(encoding="utf-8").strip()
|
|
335
|
+
|
|
336
|
+
# Auto-detect reviewer if not provided
|
|
337
|
+
if not reviewer:
|
|
338
|
+
try:
|
|
339
|
+
import subprocess
|
|
340
|
+
result = subprocess.run(
|
|
341
|
+
["git", "config", "user.name"],
|
|
342
|
+
capture_output=True,
|
|
343
|
+
text=True,
|
|
344
|
+
check=True
|
|
345
|
+
)
|
|
346
|
+
reviewer = result.stdout.strip() or "unknown"
|
|
347
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
348
|
+
reviewer = "unknown"
|
|
349
|
+
|
|
350
|
+
# Insert feedback into "## Review Feedback" section
|
|
351
|
+
# Find the section and replace its content
|
|
352
|
+
review_section_start = updated_body.find("## Review Feedback")
|
|
353
|
+
if review_section_start != -1:
|
|
354
|
+
# Find the next section (starts with ##) or end of document
|
|
355
|
+
next_section_start = updated_body.find("\n##", review_section_start + 18)
|
|
356
|
+
|
|
357
|
+
if next_section_start == -1:
|
|
358
|
+
# No next section, replace to end
|
|
359
|
+
before = updated_body[:review_section_start]
|
|
360
|
+
updated_body = before + f"## Review Feedback\n\n**Reviewed by**: {reviewer}\n**Status**: ❌ Changes Requested\n**Date**: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\n\n{feedback_content}\n\n"
|
|
361
|
+
else:
|
|
362
|
+
# Replace content between this section and next
|
|
363
|
+
before = updated_body[:review_section_start]
|
|
364
|
+
after = updated_body[next_section_start:]
|
|
365
|
+
updated_body = before + f"## Review Feedback\n\n**Reviewed by**: {reviewer}\n**Status**: ❌ Changes Requested\n**Date**: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}\n\n{feedback_content}\n\n" + after
|
|
366
|
+
|
|
367
|
+
# Update frontmatter for review status
|
|
368
|
+
updated_front = set_scalar(updated_front, "review_status", "has_feedback")
|
|
369
|
+
updated_front = set_scalar(updated_front, "reviewed_by", reviewer)
|
|
370
|
+
|
|
371
|
+
# Update reviewed_by when moving to done (approved)
|
|
372
|
+
if target_lane == "done" and not extract_scalar(updated_front, "reviewed_by"):
|
|
373
|
+
# Auto-detect reviewer if not provided
|
|
374
|
+
if not reviewer:
|
|
375
|
+
try:
|
|
376
|
+
import subprocess
|
|
377
|
+
result = subprocess.run(
|
|
378
|
+
["git", "config", "user.name"],
|
|
379
|
+
capture_output=True,
|
|
380
|
+
text=True,
|
|
381
|
+
check=True
|
|
382
|
+
)
|
|
383
|
+
reviewer = result.stdout.strip() or "unknown"
|
|
384
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
385
|
+
reviewer = "unknown"
|
|
386
|
+
|
|
387
|
+
updated_front = set_scalar(updated_front, "reviewed_by", reviewer)
|
|
388
|
+
updated_front = set_scalar(updated_front, "review_status", "approved")
|
|
389
|
+
|
|
390
|
+
# Build history entry
|
|
391
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
392
|
+
agent_name = agent or extract_scalar(updated_front, "agent") or "unknown"
|
|
393
|
+
shell_pid_val = shell_pid or extract_scalar(updated_front, "shell_pid") or ""
|
|
394
|
+
note_text = note or f"Moved to {target_lane}"
|
|
395
|
+
|
|
396
|
+
shell_part = f"shell_pid={shell_pid_val} – " if shell_pid_val else ""
|
|
397
|
+
history_entry = f"- {timestamp} – {agent_name} – {shell_part}lane={target_lane} – {note_text}"
|
|
398
|
+
|
|
399
|
+
# Add history entry to body
|
|
400
|
+
updated_body = append_activity_log(updated_body, history_entry)
|
|
401
|
+
|
|
402
|
+
# Build and write updated document
|
|
403
|
+
updated_doc = build_document(updated_front, updated_body, wp.padding)
|
|
404
|
+
wp.path.write_text(updated_doc, encoding="utf-8")
|
|
405
|
+
|
|
406
|
+
# FIX B: Auto-commit to main branch (worktrees use sparse-checkout, don't have kitty-specs/)
|
|
407
|
+
# Agents read/write to main's kitty-specs/ directly (absolute paths)
|
|
408
|
+
# This enables instant status sync across all worktrees (jujutsu-aligned)
|
|
409
|
+
if auto_commit:
|
|
410
|
+
import subprocess
|
|
411
|
+
|
|
412
|
+
# Get the ACTUAL main repo root (not worktree path)
|
|
413
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
414
|
+
|
|
415
|
+
# Extract spec number from feature_slug (e.g., "014" from "014-feature-name")
|
|
416
|
+
spec_number = feature_slug.split('-')[0] if '-' in feature_slug else feature_slug
|
|
417
|
+
|
|
418
|
+
# Commit to main (file is always in main, worktrees excluded via sparse-checkout)
|
|
419
|
+
commit_msg = f"chore: Move {task_id} to {target_lane} on spec {spec_number}"
|
|
420
|
+
if agent_name != "unknown":
|
|
421
|
+
commit_msg += f" [{agent_name}]"
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
# wp.path already points to main's kitty-specs/ (absolute path)
|
|
425
|
+
# Worktrees use sparse-checkout to exclude kitty-specs/, so path is always to main
|
|
426
|
+
actual_file_path = wp.path.resolve()
|
|
427
|
+
|
|
428
|
+
# Stage the file first, then commit
|
|
429
|
+
# Use -u to only update tracked files (bypasses .gitignore check)
|
|
430
|
+
add_result = subprocess.run(
|
|
431
|
+
["git", "add", "-u", str(actual_file_path)],
|
|
432
|
+
cwd=main_repo_root,
|
|
433
|
+
capture_output=True,
|
|
434
|
+
text=True,
|
|
435
|
+
check=False
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if add_result.returncode != 0:
|
|
439
|
+
if not json_output:
|
|
440
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to stage file: {add_result.stderr}")
|
|
441
|
+
else:
|
|
442
|
+
# Commit the staged file
|
|
443
|
+
commit_result = subprocess.run(
|
|
444
|
+
["git", "commit", "-m", commit_msg],
|
|
445
|
+
cwd=main_repo_root,
|
|
446
|
+
capture_output=True,
|
|
447
|
+
text=True,
|
|
448
|
+
check=False
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
if commit_result.returncode == 0:
|
|
452
|
+
if not json_output:
|
|
453
|
+
console.print(f"[cyan]→ Committed status change to main branch[/cyan]")
|
|
454
|
+
elif "nothing to commit" in commit_result.stdout or "nothing to commit" in commit_result.stderr:
|
|
455
|
+
# File wasn't actually changed, that's OK
|
|
456
|
+
pass
|
|
457
|
+
else:
|
|
458
|
+
# Commit failed
|
|
459
|
+
if not json_output:
|
|
460
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to auto-commit: {commit_result.stderr}")
|
|
461
|
+
|
|
462
|
+
except Exception as e:
|
|
463
|
+
# Unexpected error
|
|
464
|
+
if not json_output:
|
|
465
|
+
console.print(f"[yellow]Warning:[/yellow] Auto-commit exception: {e}")
|
|
466
|
+
|
|
467
|
+
# Output result
|
|
468
|
+
result = {
|
|
469
|
+
"result": "success",
|
|
470
|
+
"task_id": task_id,
|
|
471
|
+
"old_lane": old_lane,
|
|
472
|
+
"new_lane": target_lane,
|
|
473
|
+
"path": str(wp.path)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
_output_result(
|
|
477
|
+
json_output,
|
|
478
|
+
result,
|
|
479
|
+
f"[green]✓[/green] Moved {task_id} from {old_lane} to {target_lane}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Check for dependent WP warnings when moving to for_review (T083)
|
|
483
|
+
_check_dependent_warnings(repo_root, feature_slug, task_id, target_lane, json_output)
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
_output_error(json_output, str(e))
|
|
487
|
+
raise typer.Exit(1)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@app.command(name="mark-status")
|
|
491
|
+
def mark_status(
|
|
492
|
+
task_ids: Annotated[list[str], typer.Argument(help="Task ID(s) - space-separated (e.g., T001 T002 T003)")],
|
|
493
|
+
status: Annotated[str, typer.Option("--status", help="Status: done/pending")],
|
|
494
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
495
|
+
auto_commit: Annotated[bool, typer.Option("--auto-commit/--no-auto-commit", help="Automatically commit tasks.md changes to main branch")] = True,
|
|
496
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Update task checkbox status in tasks.md for one or more tasks.
|
|
499
|
+
|
|
500
|
+
Accepts MULTIPLE task IDs separated by spaces. All tasks are updated
|
|
501
|
+
in a single operation with one commit.
|
|
502
|
+
|
|
503
|
+
Examples:
|
|
504
|
+
# Single task:
|
|
505
|
+
spec-kitty agent tasks mark-status T001 --status done
|
|
506
|
+
|
|
507
|
+
# Multiple tasks (space-separated):
|
|
508
|
+
spec-kitty agent tasks mark-status T001 T002 T003 --status done
|
|
509
|
+
|
|
510
|
+
# Many tasks at once:
|
|
511
|
+
spec-kitty agent tasks mark-status T040 T041 T042 T043 T044 T045 --status done --feature 001-my-feature
|
|
512
|
+
|
|
513
|
+
# With JSON output:
|
|
514
|
+
spec-kitty agent tasks mark-status T001 T002 --status done --json
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
# Validate status
|
|
518
|
+
if status not in ("done", "pending"):
|
|
519
|
+
_output_error(json_output, f"Invalid status '{status}'. Must be 'done' or 'pending'.")
|
|
520
|
+
raise typer.Exit(1)
|
|
521
|
+
|
|
522
|
+
# Validate we have at least one task
|
|
523
|
+
if not task_ids:
|
|
524
|
+
_output_error(json_output, "At least one task ID is required")
|
|
525
|
+
raise typer.Exit(1)
|
|
526
|
+
|
|
527
|
+
# Get repo root and feature slug
|
|
528
|
+
repo_root = locate_project_root()
|
|
529
|
+
if repo_root is None:
|
|
530
|
+
_output_error(json_output, "Could not locate project root")
|
|
531
|
+
raise typer.Exit(1)
|
|
532
|
+
|
|
533
|
+
feature_slug = feature or _find_feature_slug()
|
|
534
|
+
# Use main repo root (worktrees have kitty-specs/ sparse-checked out)
|
|
535
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
536
|
+
feature_dir = main_repo_root / "kitty-specs" / feature_slug
|
|
537
|
+
tasks_md = feature_dir / "tasks.md"
|
|
538
|
+
|
|
539
|
+
if not tasks_md.exists():
|
|
540
|
+
_output_error(json_output, f"tasks.md not found: {tasks_md}")
|
|
541
|
+
raise typer.Exit(1)
|
|
542
|
+
|
|
543
|
+
# Read tasks.md content
|
|
544
|
+
content = tasks_md.read_text(encoding="utf-8")
|
|
545
|
+
lines = content.split('\n')
|
|
546
|
+
new_checkbox = "[x]" if status == "done" else "[ ]"
|
|
547
|
+
|
|
548
|
+
# Track which tasks were updated and which weren't found
|
|
549
|
+
updated_tasks = []
|
|
550
|
+
not_found_tasks = []
|
|
551
|
+
|
|
552
|
+
# Update all requested tasks in a single pass
|
|
553
|
+
for task_id in task_ids:
|
|
554
|
+
task_found = False
|
|
555
|
+
for i, line in enumerate(lines):
|
|
556
|
+
# Match checkbox lines with this task ID
|
|
557
|
+
if re.search(rf'-\s*\[[ x]\]\s*{re.escape(task_id)}\b', line):
|
|
558
|
+
# Replace the checkbox
|
|
559
|
+
lines[i] = re.sub(r'-\s*\[[ x]\]', f'- {new_checkbox}', line)
|
|
560
|
+
updated_tasks.append(task_id)
|
|
561
|
+
task_found = True
|
|
562
|
+
break
|
|
563
|
+
|
|
564
|
+
if not task_found:
|
|
565
|
+
not_found_tasks.append(task_id)
|
|
566
|
+
|
|
567
|
+
# Fail if no tasks were updated
|
|
568
|
+
if not updated_tasks:
|
|
569
|
+
_output_error(json_output, f"No task IDs found in tasks.md: {', '.join(not_found_tasks)}")
|
|
570
|
+
raise typer.Exit(1)
|
|
571
|
+
|
|
572
|
+
# Write updated content (single write for all changes)
|
|
573
|
+
updated_content = '\n'.join(lines)
|
|
574
|
+
tasks_md.write_text(updated_content, encoding="utf-8")
|
|
575
|
+
|
|
576
|
+
# Auto-commit to main branch (single commit for all tasks)
|
|
577
|
+
if auto_commit:
|
|
578
|
+
import subprocess
|
|
579
|
+
|
|
580
|
+
# Extract spec number from feature_slug (e.g., "014" from "014-feature-name")
|
|
581
|
+
spec_number = feature_slug.split('-')[0] if '-' in feature_slug else feature_slug
|
|
582
|
+
|
|
583
|
+
# Build commit message
|
|
584
|
+
if len(updated_tasks) == 1:
|
|
585
|
+
commit_msg = f"chore: Mark {updated_tasks[0]} as {status} on spec {spec_number}"
|
|
586
|
+
else:
|
|
587
|
+
commit_msg = f"chore: Mark {len(updated_tasks)} subtasks as {status} on spec {spec_number}"
|
|
588
|
+
|
|
589
|
+
try:
|
|
590
|
+
actual_tasks_path = tasks_md.resolve()
|
|
591
|
+
|
|
592
|
+
# Stage the file first, then commit
|
|
593
|
+
# Use -u to only update tracked files (bypasses .gitignore check)
|
|
594
|
+
add_result = subprocess.run(
|
|
595
|
+
["git", "add", "-u", str(actual_tasks_path)],
|
|
596
|
+
cwd=main_repo_root,
|
|
597
|
+
capture_output=True,
|
|
598
|
+
text=True,
|
|
599
|
+
check=False
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if add_result.returncode != 0:
|
|
603
|
+
if not json_output:
|
|
604
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to stage file: {add_result.stderr}")
|
|
605
|
+
else:
|
|
606
|
+
# Commit the staged file
|
|
607
|
+
commit_result = subprocess.run(
|
|
608
|
+
["git", "commit", "-m", commit_msg],
|
|
609
|
+
cwd=main_repo_root,
|
|
610
|
+
capture_output=True,
|
|
611
|
+
text=True,
|
|
612
|
+
check=False
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if commit_result.returncode == 0:
|
|
616
|
+
if not json_output:
|
|
617
|
+
console.print(f"[cyan]→ Committed subtask changes to main branch[/cyan]")
|
|
618
|
+
elif "nothing to commit" not in commit_result.stdout and "nothing to commit" not in commit_result.stderr:
|
|
619
|
+
if not json_output:
|
|
620
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to auto-commit: {commit_result.stderr}")
|
|
621
|
+
|
|
622
|
+
except Exception as e:
|
|
623
|
+
if not json_output:
|
|
624
|
+
console.print(f"[yellow]Warning:[/yellow] Auto-commit exception: {e}")
|
|
625
|
+
|
|
626
|
+
# Build result
|
|
627
|
+
result = {
|
|
628
|
+
"result": "success",
|
|
629
|
+
"updated": updated_tasks,
|
|
630
|
+
"not_found": not_found_tasks,
|
|
631
|
+
"status": status,
|
|
632
|
+
"count": len(updated_tasks)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# Output result
|
|
636
|
+
if not_found_tasks and not json_output:
|
|
637
|
+
console.print(f"[yellow]Warning:[/yellow] Not found: {', '.join(not_found_tasks)}")
|
|
638
|
+
|
|
639
|
+
if len(updated_tasks) == 1:
|
|
640
|
+
success_msg = f"[green]✓[/green] Marked {updated_tasks[0]} as {status}"
|
|
641
|
+
else:
|
|
642
|
+
success_msg = f"[green]✓[/green] Marked {len(updated_tasks)} subtasks as {status}: {', '.join(updated_tasks)}"
|
|
643
|
+
|
|
644
|
+
_output_result(json_output, result, success_msg)
|
|
645
|
+
|
|
646
|
+
except Exception as e:
|
|
647
|
+
_output_error(json_output, str(e))
|
|
648
|
+
raise typer.Exit(1)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@app.command(name="list-tasks")
|
|
652
|
+
def list_tasks(
|
|
653
|
+
lane: Annotated[Optional[str], typer.Option("--lane", help="Filter by lane")] = None,
|
|
654
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
655
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
656
|
+
) -> None:
|
|
657
|
+
"""List tasks with optional lane filtering.
|
|
658
|
+
|
|
659
|
+
Examples:
|
|
660
|
+
spec-kitty agent tasks list-tasks --json
|
|
661
|
+
spec-kitty agent tasks list-tasks --lane doing --json
|
|
662
|
+
"""
|
|
663
|
+
try:
|
|
664
|
+
# Get repo root and feature slug
|
|
665
|
+
repo_root = locate_project_root()
|
|
666
|
+
if repo_root is None:
|
|
667
|
+
_output_error(json_output, "Could not locate project root")
|
|
668
|
+
raise typer.Exit(1)
|
|
669
|
+
|
|
670
|
+
feature_slug = feature or _find_feature_slug()
|
|
671
|
+
|
|
672
|
+
# Use main repo (worktrees have kitty-specs/ sparse-checked out)
|
|
673
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
674
|
+
|
|
675
|
+
# Find all task files
|
|
676
|
+
tasks_dir = main_repo_root / "kitty-specs" / feature_slug / "tasks"
|
|
677
|
+
if not tasks_dir.exists():
|
|
678
|
+
_output_error(json_output, f"Tasks directory not found: {tasks_dir}")
|
|
679
|
+
raise typer.Exit(1)
|
|
680
|
+
|
|
681
|
+
tasks = []
|
|
682
|
+
for task_file in tasks_dir.glob("WP*.md"):
|
|
683
|
+
if task_file.name.lower() == "readme.md":
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
content = task_file.read_text(encoding="utf-8-sig")
|
|
687
|
+
frontmatter, _, _ = split_frontmatter(content)
|
|
688
|
+
|
|
689
|
+
task_lane = extract_scalar(frontmatter, "lane") or "planned"
|
|
690
|
+
task_wp_id = extract_scalar(frontmatter, "work_package_id") or task_file.stem
|
|
691
|
+
task_title = extract_scalar(frontmatter, "title") or ""
|
|
692
|
+
|
|
693
|
+
# Filter by lane if specified
|
|
694
|
+
if lane and task_lane != lane:
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
tasks.append({
|
|
698
|
+
"work_package_id": task_wp_id,
|
|
699
|
+
"title": task_title,
|
|
700
|
+
"lane": task_lane,
|
|
701
|
+
"path": str(task_file)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
# Sort by work package ID
|
|
705
|
+
tasks.sort(key=lambda t: t["work_package_id"])
|
|
706
|
+
|
|
707
|
+
if json_output:
|
|
708
|
+
print(json.dumps({"tasks": tasks, "count": len(tasks)}))
|
|
709
|
+
else:
|
|
710
|
+
if not tasks:
|
|
711
|
+
console.print(f"[yellow]No tasks found{' in lane ' + lane if lane else ''}[/yellow]")
|
|
712
|
+
else:
|
|
713
|
+
console.print(f"[bold]Tasks{' in lane ' + lane if lane else ''}:[/bold]\n")
|
|
714
|
+
for task in tasks:
|
|
715
|
+
console.print(f" {task['work_package_id']}: {task['title']} [{task['lane']}]")
|
|
716
|
+
|
|
717
|
+
except Exception as e:
|
|
718
|
+
_output_error(json_output, str(e))
|
|
719
|
+
raise typer.Exit(1)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@app.command(name="add-history")
|
|
723
|
+
def add_history(
|
|
724
|
+
task_id: Annotated[str, typer.Argument(help="Task ID (e.g., WP01)")],
|
|
725
|
+
note: Annotated[str, typer.Option("--note", help="History note")],
|
|
726
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
727
|
+
agent: Annotated[Optional[str], typer.Option("--agent", help="Agent name")] = None,
|
|
728
|
+
shell_pid: Annotated[Optional[str], typer.Option("--shell-pid", help="Shell PID")] = None,
|
|
729
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
730
|
+
) -> None:
|
|
731
|
+
"""Append history entry to task activity log.
|
|
732
|
+
|
|
733
|
+
Examples:
|
|
734
|
+
spec-kitty agent tasks add-history WP01 --note "Completed implementation" --json
|
|
735
|
+
"""
|
|
736
|
+
try:
|
|
737
|
+
# Get repo root and feature slug
|
|
738
|
+
repo_root = locate_project_root()
|
|
739
|
+
if repo_root is None:
|
|
740
|
+
_output_error(json_output, "Could not locate project root")
|
|
741
|
+
raise typer.Exit(1)
|
|
742
|
+
|
|
743
|
+
feature_slug = feature or _find_feature_slug()
|
|
744
|
+
|
|
745
|
+
# Load work package
|
|
746
|
+
wp = locate_work_package(repo_root, feature_slug, task_id)
|
|
747
|
+
|
|
748
|
+
# Get current lane from frontmatter
|
|
749
|
+
current_lane = extract_scalar(wp.frontmatter, "lane") or "planned"
|
|
750
|
+
|
|
751
|
+
# Build history entry
|
|
752
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
753
|
+
agent_name = agent or extract_scalar(wp.frontmatter, "agent") or "unknown"
|
|
754
|
+
shell_pid_val = shell_pid or extract_scalar(wp.frontmatter, "shell_pid") or ""
|
|
755
|
+
|
|
756
|
+
shell_part = f"shell_pid={shell_pid_val} – " if shell_pid_val else ""
|
|
757
|
+
history_entry = f"- {timestamp} – {agent_name} – {shell_part}lane={current_lane} – {note}"
|
|
758
|
+
|
|
759
|
+
# Add history entry to body
|
|
760
|
+
updated_body = append_activity_log(wp.body, history_entry)
|
|
761
|
+
|
|
762
|
+
# Build and write updated document
|
|
763
|
+
updated_doc = build_document(wp.frontmatter, updated_body, wp.padding)
|
|
764
|
+
wp.path.write_text(updated_doc, encoding="utf-8")
|
|
765
|
+
|
|
766
|
+
result = {
|
|
767
|
+
"result": "success",
|
|
768
|
+
"task_id": task_id,
|
|
769
|
+
"note": note
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
_output_result(
|
|
773
|
+
json_output,
|
|
774
|
+
result,
|
|
775
|
+
f"[green]✓[/green] Added history entry to {task_id}"
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
except Exception as e:
|
|
779
|
+
_output_error(json_output, str(e))
|
|
780
|
+
raise typer.Exit(1)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@app.command(name="finalize-tasks")
|
|
784
|
+
def finalize_tasks(
|
|
785
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
786
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Parse tasks.md and inject dependencies into WP frontmatter.
|
|
789
|
+
|
|
790
|
+
Scans tasks.md for "Depends on: WP##" patterns or phase groupings,
|
|
791
|
+
builds dependency graph, validates for cycles, and writes dependencies
|
|
792
|
+
field to each WP file's frontmatter.
|
|
793
|
+
|
|
794
|
+
Examples:
|
|
795
|
+
spec-kitty agent tasks finalize-tasks --json
|
|
796
|
+
spec-kitty agent tasks finalize-tasks --feature 001-my-feature
|
|
797
|
+
"""
|
|
798
|
+
try:
|
|
799
|
+
# Get repo root and feature slug
|
|
800
|
+
repo_root = locate_project_root()
|
|
801
|
+
if repo_root is None:
|
|
802
|
+
_output_error(json_output, "Could not locate project root")
|
|
803
|
+
raise typer.Exit(1)
|
|
804
|
+
|
|
805
|
+
feature_slug = feature or _find_feature_slug()
|
|
806
|
+
# Use main repo (worktrees have kitty-specs/ sparse-checked out)
|
|
807
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
808
|
+
feature_dir = main_repo_root / "kitty-specs" / feature_slug
|
|
809
|
+
tasks_md = feature_dir / "tasks.md"
|
|
810
|
+
tasks_dir = feature_dir / "tasks"
|
|
811
|
+
|
|
812
|
+
if not tasks_md.exists():
|
|
813
|
+
_output_error(json_output, f"tasks.md not found: {tasks_md}")
|
|
814
|
+
raise typer.Exit(1)
|
|
815
|
+
|
|
816
|
+
if not tasks_dir.exists():
|
|
817
|
+
_output_error(json_output, f"Tasks directory not found: {tasks_dir}")
|
|
818
|
+
raise typer.Exit(1)
|
|
819
|
+
|
|
820
|
+
# Parse tasks.md for dependency patterns
|
|
821
|
+
content = tasks_md.read_text(encoding="utf-8")
|
|
822
|
+
dependencies_map: dict[str, list[str]] = {}
|
|
823
|
+
|
|
824
|
+
# Strategy 1: Look for explicit "Depends on: WP##" patterns
|
|
825
|
+
# Strategy 2: Look for phase groupings where later phases depend on earlier ones
|
|
826
|
+
# For now, implement simple pattern matching
|
|
827
|
+
|
|
828
|
+
wp_pattern = re.compile(r'WP(\d{2})')
|
|
829
|
+
depends_pattern = re.compile(r'(?:depends on|dependency:|requires):\s*(WP\d{2}(?:,\s*WP\d{2})*)', re.IGNORECASE)
|
|
830
|
+
|
|
831
|
+
current_wp = None
|
|
832
|
+
for line in content.split('\n'):
|
|
833
|
+
# Find WP headers
|
|
834
|
+
wp_match = wp_pattern.search(line)
|
|
835
|
+
if wp_match and ('##' in line or 'Work Package' in line):
|
|
836
|
+
current_wp = f"WP{wp_match.group(1)}"
|
|
837
|
+
if current_wp not in dependencies_map:
|
|
838
|
+
dependencies_map[current_wp] = []
|
|
839
|
+
|
|
840
|
+
# Find dependency declarations for current WP
|
|
841
|
+
if current_wp:
|
|
842
|
+
dep_match = depends_pattern.search(line)
|
|
843
|
+
if dep_match:
|
|
844
|
+
# Extract all WP IDs mentioned
|
|
845
|
+
dep_wps = re.findall(r'WP\d{2}', dep_match.group(1))
|
|
846
|
+
dependencies_map[current_wp].extend(dep_wps)
|
|
847
|
+
# Remove duplicates
|
|
848
|
+
dependencies_map[current_wp] = list(dict.fromkeys(dependencies_map[current_wp]))
|
|
849
|
+
|
|
850
|
+
# Ensure all WP files in tasks/ dir are in the map (with empty deps if not mentioned)
|
|
851
|
+
for wp_file in tasks_dir.glob("WP*.md"):
|
|
852
|
+
wp_id = wp_file.stem.split('-')[0] # Extract WP## from WP##-title.md
|
|
853
|
+
if wp_id not in dependencies_map:
|
|
854
|
+
dependencies_map[wp_id] = []
|
|
855
|
+
|
|
856
|
+
# Update each WP file's frontmatter with dependencies
|
|
857
|
+
updated_count = 0
|
|
858
|
+
for wp_id, deps in sorted(dependencies_map.items()):
|
|
859
|
+
# Find WP file
|
|
860
|
+
wp_files = list(tasks_dir.glob(f"{wp_id}-*.md")) + list(tasks_dir.glob(f"{wp_id}.md"))
|
|
861
|
+
if not wp_files:
|
|
862
|
+
console.print(f"[yellow]Warning:[/yellow] No file found for {wp_id}")
|
|
863
|
+
continue
|
|
864
|
+
|
|
865
|
+
wp_file = wp_files[0]
|
|
866
|
+
|
|
867
|
+
# Read current content
|
|
868
|
+
content = wp_file.read_text(encoding="utf-8-sig")
|
|
869
|
+
frontmatter, body, padding = split_frontmatter(content)
|
|
870
|
+
|
|
871
|
+
# Update dependencies field
|
|
872
|
+
updated_front = set_scalar(frontmatter, "dependencies", deps)
|
|
873
|
+
|
|
874
|
+
# Rebuild and write
|
|
875
|
+
updated_doc = build_document(updated_front, body, padding)
|
|
876
|
+
wp_file.write_text(updated_doc, encoding="utf-8")
|
|
877
|
+
updated_count += 1
|
|
878
|
+
|
|
879
|
+
# Validate dependency graph for cycles
|
|
880
|
+
from specify_cli.core.dependency_graph import detect_cycles
|
|
881
|
+
cycles = detect_cycles(dependencies_map)
|
|
882
|
+
if cycles:
|
|
883
|
+
_output_error(json_output, f"Circular dependencies detected: {cycles}")
|
|
884
|
+
raise typer.Exit(1)
|
|
885
|
+
|
|
886
|
+
result = {
|
|
887
|
+
"result": "success",
|
|
888
|
+
"updated": updated_count,
|
|
889
|
+
"dependencies": dependencies_map,
|
|
890
|
+
"feature": feature_slug
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
_output_result(
|
|
894
|
+
json_output,
|
|
895
|
+
result,
|
|
896
|
+
f"[green]✓[/green] Updated {updated_count} WP files with dependencies"
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
except Exception as e:
|
|
900
|
+
_output_error(json_output, str(e))
|
|
901
|
+
raise typer.Exit(1)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@app.command(name="validate-workflow")
|
|
905
|
+
def validate_workflow(
|
|
906
|
+
task_id: Annotated[str, typer.Argument(help="Task ID (e.g., WP01)")],
|
|
907
|
+
feature: Annotated[Optional[str], typer.Option("--feature", help="Feature slug (auto-detected if omitted)")] = None,
|
|
908
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
909
|
+
) -> None:
|
|
910
|
+
"""Validate task metadata structure and workflow consistency.
|
|
911
|
+
|
|
912
|
+
Examples:
|
|
913
|
+
spec-kitty agent tasks validate-workflow WP01 --json
|
|
914
|
+
"""
|
|
915
|
+
try:
|
|
916
|
+
# Get repo root and feature slug
|
|
917
|
+
repo_root = locate_project_root()
|
|
918
|
+
if repo_root is None:
|
|
919
|
+
_output_error(json_output, "Could not locate project root")
|
|
920
|
+
raise typer.Exit(1)
|
|
921
|
+
|
|
922
|
+
feature_slug = feature or _find_feature_slug()
|
|
923
|
+
|
|
924
|
+
# Load work package
|
|
925
|
+
wp = locate_work_package(repo_root, feature_slug, task_id)
|
|
926
|
+
|
|
927
|
+
# Validation checks
|
|
928
|
+
errors = []
|
|
929
|
+
warnings = []
|
|
930
|
+
|
|
931
|
+
# Check required fields
|
|
932
|
+
required_fields = ["work_package_id", "title", "lane"]
|
|
933
|
+
for field in required_fields:
|
|
934
|
+
if not extract_scalar(wp.frontmatter, field):
|
|
935
|
+
errors.append(f"Missing required field: {field}")
|
|
936
|
+
|
|
937
|
+
# Check lane is valid
|
|
938
|
+
lane_value = extract_scalar(wp.frontmatter, "lane")
|
|
939
|
+
if lane_value and lane_value not in LANES:
|
|
940
|
+
errors.append(f"Invalid lane '{lane_value}'. Must be one of: {', '.join(LANES)}")
|
|
941
|
+
|
|
942
|
+
# Check work_package_id matches filename
|
|
943
|
+
wp_id = extract_scalar(wp.frontmatter, "work_package_id")
|
|
944
|
+
if wp_id and not wp.path.name.startswith(wp_id):
|
|
945
|
+
warnings.append(f"Work package ID '{wp_id}' doesn't match filename '{wp.path.name}'")
|
|
946
|
+
|
|
947
|
+
# Check for activity log
|
|
948
|
+
if "## Activity Log" not in wp.body:
|
|
949
|
+
warnings.append("Missing Activity Log section")
|
|
950
|
+
|
|
951
|
+
# Determine validity
|
|
952
|
+
is_valid = len(errors) == 0
|
|
953
|
+
|
|
954
|
+
result = {
|
|
955
|
+
"valid": is_valid,
|
|
956
|
+
"errors": errors,
|
|
957
|
+
"warnings": warnings,
|
|
958
|
+
"task_id": task_id,
|
|
959
|
+
"lane": lane_value or "unknown"
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if json_output:
|
|
963
|
+
print(json.dumps(result))
|
|
964
|
+
else:
|
|
965
|
+
if is_valid:
|
|
966
|
+
console.print(f"[green]✓[/green] {task_id} validation passed")
|
|
967
|
+
else:
|
|
968
|
+
console.print(f"[red]✗[/red] {task_id} validation failed")
|
|
969
|
+
for error in errors:
|
|
970
|
+
console.print(f" [red]Error:[/red] {error}")
|
|
971
|
+
|
|
972
|
+
if warnings:
|
|
973
|
+
console.print(f"\n[yellow]Warnings:[/yellow]")
|
|
974
|
+
for warning in warnings:
|
|
975
|
+
console.print(f" [yellow]•[/yellow] {warning}")
|
|
976
|
+
|
|
977
|
+
except Exception as e:
|
|
978
|
+
_output_error(json_output, str(e))
|
|
979
|
+
raise typer.Exit(1)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
@app.command(name="status")
|
|
983
|
+
def status(
|
|
984
|
+
feature: Annotated[
|
|
985
|
+
Optional[str],
|
|
986
|
+
typer.Option("--feature", "-f", help="Feature slug (e.g., 012-documentation-mission). Auto-detected if not provided.")
|
|
987
|
+
] = None,
|
|
988
|
+
json_output: Annotated[
|
|
989
|
+
bool,
|
|
990
|
+
typer.Option("--json", help="Output as JSON")
|
|
991
|
+
] = False,
|
|
992
|
+
stale_threshold: Annotated[
|
|
993
|
+
int,
|
|
994
|
+
typer.Option("--stale-threshold", help="Minutes of inactivity before a WP is considered stale")
|
|
995
|
+
] = 10,
|
|
996
|
+
):
|
|
997
|
+
"""Display kanban status board for all work packages in a feature.
|
|
998
|
+
|
|
999
|
+
Shows a beautiful overview of work package statuses, progress metrics,
|
|
1000
|
+
and next steps based on dependencies.
|
|
1001
|
+
|
|
1002
|
+
WPs in "doing" with no commits for --stale-threshold minutes are flagged
|
|
1003
|
+
as potentially stale (agent may have stopped).
|
|
1004
|
+
|
|
1005
|
+
Example:
|
|
1006
|
+
spec-kitty agent tasks status
|
|
1007
|
+
spec-kitty agent tasks status --feature 012-documentation-mission
|
|
1008
|
+
spec-kitty agent tasks status --json
|
|
1009
|
+
spec-kitty agent tasks status --stale-threshold 15
|
|
1010
|
+
"""
|
|
1011
|
+
from rich.table import Table
|
|
1012
|
+
from rich.panel import Panel
|
|
1013
|
+
from rich.text import Text
|
|
1014
|
+
from collections import Counter
|
|
1015
|
+
|
|
1016
|
+
try:
|
|
1017
|
+
cwd = Path.cwd().resolve()
|
|
1018
|
+
repo_root = locate_project_root(cwd)
|
|
1019
|
+
|
|
1020
|
+
if repo_root is None:
|
|
1021
|
+
raise typer.Exit(1)
|
|
1022
|
+
|
|
1023
|
+
# Auto-detect or use provided feature slug
|
|
1024
|
+
feature_slug = feature if feature else _find_feature_slug()
|
|
1025
|
+
|
|
1026
|
+
# Get main repo root for correct path resolution
|
|
1027
|
+
main_repo_root = get_main_repo_root(repo_root)
|
|
1028
|
+
|
|
1029
|
+
# Locate feature directory
|
|
1030
|
+
feature_dir = main_repo_root / "kitty-specs" / feature_slug
|
|
1031
|
+
|
|
1032
|
+
if not feature_dir.exists():
|
|
1033
|
+
console.print(f"[red]Error:[/red] Feature directory not found: {feature_dir}")
|
|
1034
|
+
raise typer.Exit(1)
|
|
1035
|
+
|
|
1036
|
+
tasks_dir = feature_dir / "tasks"
|
|
1037
|
+
|
|
1038
|
+
if not tasks_dir.exists():
|
|
1039
|
+
console.print(f"[red]Error:[/red] Tasks directory not found: {tasks_dir}")
|
|
1040
|
+
raise typer.Exit(1)
|
|
1041
|
+
|
|
1042
|
+
# Collect all work packages
|
|
1043
|
+
work_packages = []
|
|
1044
|
+
for wp_file in sorted(tasks_dir.glob("WP*.md")):
|
|
1045
|
+
front, body, padding = split_frontmatter(wp_file.read_text(encoding="utf-8"))
|
|
1046
|
+
|
|
1047
|
+
wp_id = extract_scalar(front, "work_package_id")
|
|
1048
|
+
title = extract_scalar(front, "title")
|
|
1049
|
+
lane = extract_scalar(front, "lane") or "unknown"
|
|
1050
|
+
phase = extract_scalar(front, "phase") or "Unknown Phase"
|
|
1051
|
+
agent = extract_scalar(front, "agent") or ""
|
|
1052
|
+
shell_pid = extract_scalar(front, "shell_pid") or ""
|
|
1053
|
+
|
|
1054
|
+
work_packages.append({
|
|
1055
|
+
"id": wp_id,
|
|
1056
|
+
"title": title,
|
|
1057
|
+
"lane": lane,
|
|
1058
|
+
"phase": phase,
|
|
1059
|
+
"file": wp_file.name,
|
|
1060
|
+
"agent": agent,
|
|
1061
|
+
"shell_pid": shell_pid,
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
if not work_packages:
|
|
1065
|
+
console.print(f"[yellow]No work packages found in {tasks_dir}[/yellow]")
|
|
1066
|
+
raise typer.Exit(0)
|
|
1067
|
+
|
|
1068
|
+
# JSON output
|
|
1069
|
+
if json_output:
|
|
1070
|
+
# Check for stale WPs first (need to do this before JSON output too)
|
|
1071
|
+
from specify_cli.core.stale_detection import check_doing_wps_for_staleness
|
|
1072
|
+
|
|
1073
|
+
doing_wps = [wp for wp in work_packages if wp["lane"] == "doing"]
|
|
1074
|
+
stale_results = check_doing_wps_for_staleness(
|
|
1075
|
+
main_repo_root=main_repo_root,
|
|
1076
|
+
feature_slug=feature_slug,
|
|
1077
|
+
doing_wps=doing_wps,
|
|
1078
|
+
threshold_minutes=stale_threshold,
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
# Add staleness info to WPs
|
|
1082
|
+
for wp in work_packages:
|
|
1083
|
+
if wp["lane"] == "doing" and wp["id"] in stale_results:
|
|
1084
|
+
result = stale_results[wp["id"]]
|
|
1085
|
+
wp["is_stale"] = result.is_stale
|
|
1086
|
+
wp["minutes_since_commit"] = result.minutes_since_commit
|
|
1087
|
+
wp["worktree_exists"] = result.worktree_exists
|
|
1088
|
+
|
|
1089
|
+
lane_counts = Counter(wp["lane"] for wp in work_packages)
|
|
1090
|
+
stale_count = sum(1 for wp in work_packages if wp.get("is_stale"))
|
|
1091
|
+
result = {
|
|
1092
|
+
"feature": feature_slug,
|
|
1093
|
+
"total_wps": len(work_packages),
|
|
1094
|
+
"by_lane": dict(lane_counts),
|
|
1095
|
+
"work_packages": work_packages,
|
|
1096
|
+
"progress_percentage": round(lane_counts.get("done", 0) / len(work_packages) * 100, 1),
|
|
1097
|
+
"stale_wps": stale_count,
|
|
1098
|
+
}
|
|
1099
|
+
print(json.dumps(result, indent=2))
|
|
1100
|
+
return
|
|
1101
|
+
|
|
1102
|
+
# Rich table output
|
|
1103
|
+
# Group by lane
|
|
1104
|
+
by_lane = {"planned": [], "doing": [], "for_review": [], "done": []}
|
|
1105
|
+
for wp in work_packages:
|
|
1106
|
+
lane = wp["lane"]
|
|
1107
|
+
if lane in by_lane:
|
|
1108
|
+
by_lane[lane].append(wp)
|
|
1109
|
+
else:
|
|
1110
|
+
by_lane.setdefault("other", []).append(wp)
|
|
1111
|
+
|
|
1112
|
+
# Check for stale WPs in "doing" lane
|
|
1113
|
+
from specify_cli.core.stale_detection import check_doing_wps_for_staleness
|
|
1114
|
+
|
|
1115
|
+
stale_results = check_doing_wps_for_staleness(
|
|
1116
|
+
main_repo_root=main_repo_root,
|
|
1117
|
+
feature_slug=feature_slug,
|
|
1118
|
+
doing_wps=by_lane["doing"],
|
|
1119
|
+
threshold_minutes=stale_threshold,
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
# Add staleness info to WPs
|
|
1123
|
+
for wp in by_lane["doing"]:
|
|
1124
|
+
wp_id = wp["id"]
|
|
1125
|
+
if wp_id in stale_results:
|
|
1126
|
+
result = stale_results[wp_id]
|
|
1127
|
+
wp["is_stale"] = result.is_stale
|
|
1128
|
+
wp["minutes_since_commit"] = result.minutes_since_commit
|
|
1129
|
+
wp["worktree_exists"] = result.worktree_exists
|
|
1130
|
+
else:
|
|
1131
|
+
wp["is_stale"] = False
|
|
1132
|
+
|
|
1133
|
+
# Calculate metrics
|
|
1134
|
+
total = len(work_packages)
|
|
1135
|
+
done_count = len(by_lane["done"])
|
|
1136
|
+
in_progress = len(by_lane["doing"]) + len(by_lane["for_review"])
|
|
1137
|
+
planned_count = len(by_lane["planned"])
|
|
1138
|
+
progress_pct = round((done_count / total * 100), 1) if total > 0 else 0
|
|
1139
|
+
|
|
1140
|
+
# Create title panel
|
|
1141
|
+
title_text = Text()
|
|
1142
|
+
title_text.append(f"📊 Work Package Status: ", style="bold cyan")
|
|
1143
|
+
title_text.append(feature_slug, style="bold white")
|
|
1144
|
+
|
|
1145
|
+
console.print()
|
|
1146
|
+
console.print(Panel(title_text, border_style="cyan"))
|
|
1147
|
+
|
|
1148
|
+
# Progress bar
|
|
1149
|
+
progress_text = Text()
|
|
1150
|
+
progress_text.append(f"Progress: ", style="bold")
|
|
1151
|
+
progress_text.append(f"{done_count}/{total}", style="bold green")
|
|
1152
|
+
progress_text.append(f" ({progress_pct}%)", style="dim")
|
|
1153
|
+
|
|
1154
|
+
# Create visual progress bar
|
|
1155
|
+
bar_width = 40
|
|
1156
|
+
filled = int(bar_width * progress_pct / 100)
|
|
1157
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
1158
|
+
progress_text.append(f"\n{bar}", style="green")
|
|
1159
|
+
|
|
1160
|
+
console.print(progress_text)
|
|
1161
|
+
console.print()
|
|
1162
|
+
|
|
1163
|
+
# Kanban board table
|
|
1164
|
+
table = Table(title="Kanban Board", show_header=True, header_style="bold magenta", border_style="dim")
|
|
1165
|
+
table.add_column("📋 Planned", style="yellow", no_wrap=False, width=25)
|
|
1166
|
+
table.add_column("🔄 Doing", style="blue", no_wrap=False, width=25)
|
|
1167
|
+
table.add_column("👀 For Review", style="cyan", no_wrap=False, width=25)
|
|
1168
|
+
table.add_column("✅ Done", style="green", no_wrap=False, width=25)
|
|
1169
|
+
|
|
1170
|
+
# Find max length for rows
|
|
1171
|
+
max_rows = max(len(by_lane["planned"]), len(by_lane["doing"]),
|
|
1172
|
+
len(by_lane["for_review"]), len(by_lane["done"]))
|
|
1173
|
+
|
|
1174
|
+
# Add rows
|
|
1175
|
+
for i in range(max_rows):
|
|
1176
|
+
row = []
|
|
1177
|
+
for lane in ["planned", "doing", "for_review", "done"]:
|
|
1178
|
+
if i < len(by_lane[lane]):
|
|
1179
|
+
wp = by_lane[lane][i]
|
|
1180
|
+
title_truncated = wp['title'][:22] + "..." if len(wp['title']) > 22 else wp['title']
|
|
1181
|
+
|
|
1182
|
+
# Add stale indicator for doing WPs
|
|
1183
|
+
if lane == "doing" and wp.get("is_stale"):
|
|
1184
|
+
cell = f"[red]⚠️ {wp['id']}[/red]\n{title_truncated}"
|
|
1185
|
+
else:
|
|
1186
|
+
cell = f"{wp['id']}\n{title_truncated}"
|
|
1187
|
+
row.append(cell)
|
|
1188
|
+
else:
|
|
1189
|
+
row.append("")
|
|
1190
|
+
table.add_row(*row)
|
|
1191
|
+
|
|
1192
|
+
# Add count row
|
|
1193
|
+
table.add_row(
|
|
1194
|
+
f"[bold]{len(by_lane['planned'])} WPs[/bold]",
|
|
1195
|
+
f"[bold]{len(by_lane['doing'])} WPs[/bold]",
|
|
1196
|
+
f"[bold]{len(by_lane['for_review'])} WPs[/bold]",
|
|
1197
|
+
f"[bold]{len(by_lane['done'])} WPs[/bold]",
|
|
1198
|
+
style="dim"
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
console.print(table)
|
|
1202
|
+
console.print()
|
|
1203
|
+
|
|
1204
|
+
# Next steps section
|
|
1205
|
+
if by_lane["for_review"]:
|
|
1206
|
+
console.print("[bold cyan]👀 Ready for Review:[/bold cyan]")
|
|
1207
|
+
for wp in by_lane["for_review"]:
|
|
1208
|
+
console.print(f" • {wp['id']} - {wp['title']}")
|
|
1209
|
+
console.print()
|
|
1210
|
+
|
|
1211
|
+
if by_lane["doing"]:
|
|
1212
|
+
console.print("[bold blue]🔄 In Progress:[/bold blue]")
|
|
1213
|
+
stale_wps = []
|
|
1214
|
+
for wp in by_lane["doing"]:
|
|
1215
|
+
if wp.get("is_stale"):
|
|
1216
|
+
mins = wp.get("minutes_since_commit", "?")
|
|
1217
|
+
agent = wp.get("agent", "unknown")
|
|
1218
|
+
console.print(f" • [red]⚠️ {wp['id']}[/red] - {wp['title']} [dim](stale: {mins}m, agent: {agent})[/dim]")
|
|
1219
|
+
stale_wps.append(wp)
|
|
1220
|
+
else:
|
|
1221
|
+
console.print(f" • {wp['id']} - {wp['title']}")
|
|
1222
|
+
console.print()
|
|
1223
|
+
|
|
1224
|
+
# Show stale warning if any
|
|
1225
|
+
if stale_wps:
|
|
1226
|
+
console.print(f"[yellow]⚠️ {len(stale_wps)} stale WP(s) detected - agents may have stopped without transitioning[/yellow]")
|
|
1227
|
+
console.print("[dim] Run: spec-kitty agent tasks move-task <WP_ID> --to for_review[/dim]")
|
|
1228
|
+
console.print()
|
|
1229
|
+
|
|
1230
|
+
if by_lane["planned"]:
|
|
1231
|
+
console.print("[bold yellow]📋 Next Up (Planned):[/bold yellow]")
|
|
1232
|
+
# Show first 3 planned items
|
|
1233
|
+
for wp in by_lane["planned"][:3]:
|
|
1234
|
+
console.print(f" • {wp['id']} - {wp['title']}")
|
|
1235
|
+
if len(by_lane["planned"]) > 3:
|
|
1236
|
+
console.print(f" [dim]... and {len(by_lane['planned']) - 3} more[/dim]")
|
|
1237
|
+
console.print()
|
|
1238
|
+
|
|
1239
|
+
# Summary metrics
|
|
1240
|
+
summary = Table.grid(padding=(0, 2))
|
|
1241
|
+
summary.add_column(style="bold")
|
|
1242
|
+
summary.add_column()
|
|
1243
|
+
summary.add_row("Total WPs:", str(total))
|
|
1244
|
+
summary.add_row("Completed:", f"[green]{done_count}[/green] ({progress_pct}%)")
|
|
1245
|
+
summary.add_row("In Progress:", f"[blue]{in_progress}[/blue]")
|
|
1246
|
+
summary.add_row("Planned:", f"[yellow]{planned_count}[/yellow]")
|
|
1247
|
+
|
|
1248
|
+
console.print(Panel(summary, title="[bold]Summary[/bold]", border_style="dim"))
|
|
1249
|
+
console.print()
|
|
1250
|
+
|
|
1251
|
+
except Exception as e:
|
|
1252
|
+
_output_error(json_output, str(e))
|
|
1253
|
+
raise typer.Exit(1)
|