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.
Files changed (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. 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)