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,656 @@
1
+ """Migration: Complete lane migration, clean up worktrees, and normalize frontmatter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import List, Tuple
9
+
10
+ from ..registry import MigrationRegistry
11
+ from .base import BaseMigration, MigrationResult
12
+ from specify_cli.frontmatter import normalize_file, FrontmatterError
13
+
14
+
15
+ @MigrationRegistry.register
16
+ class CompleteLaneMigration(BaseMigration):
17
+ """Complete the lane migration and clean up worktrees for v0.9.0+.
18
+
19
+ Part 1: Complete Lane Migration
20
+ The v0.9.0 migration only moved files matching `WP*.md` pattern,
21
+ but some projects have other files in lane subdirectories
22
+ (like phase-*.md, task-*.md, or files without .md extensions).
23
+
24
+ Part 2: Worktree Cleanup
25
+ Worktrees should inherit everything from main repo in v0.9.0+:
26
+ - Agent command directories (.codex/prompts/, .gemini/commands/, etc.)
27
+ - Scripts (.kittify/scripts/)
28
+ Having separate copies causes old command templates to reference
29
+ deprecated scripts like tasks-move-to-lane.sh.
30
+
31
+ Part 3: Frontmatter Normalization (CRITICAL)
32
+ Normalize all YAML frontmatter to absolute consistency using ruamel.yaml.
33
+ This prevents issues where:
34
+ - Some files have `lane: "for_review"` (quoted)
35
+ - Some files have `lane: for_review` (unquoted)
36
+ Both are valid YAML but inconsistency breaks grep searches and tooling.
37
+
38
+ This migration:
39
+ 1. Finds ALL remaining files in lane subdirectories (not just WP*.md)
40
+ 2. Moves them to the flat tasks/ directory
41
+ 3. Ensures lane: field in frontmatter for .md files
42
+ 4. Removes any remaining lane subdirectories
43
+ 5. Removes ALL agent command directories from worktrees
44
+ 6. Removes .kittify/scripts/ from worktrees
45
+ 7. Normalizes ALL frontmatter in all .md files for consistency
46
+ """
47
+
48
+ migration_id = "0.9.1_complete_migration"
49
+ description = "Complete lane migration + clean up worktrees + normalize frontmatter"
50
+ target_version = "0.9.1"
51
+
52
+ # All known agent command directories
53
+ AGENT_DIRS = [
54
+ (".claude", "commands"),
55
+ (".github", "prompts"),
56
+ (".gemini", "commands"),
57
+ (".cursor", "commands"),
58
+ (".qwen", "commands"),
59
+ (".opencode", "command"),
60
+ (".windsurf", "workflows"),
61
+ (".codex", "prompts"),
62
+ (".kilocode", "workflows"),
63
+ (".augment", "commands"),
64
+ (".roo", "commands"),
65
+ (".amazonq", "prompts"),
66
+ ]
67
+
68
+ LANE_DIRS: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
69
+
70
+ # System files to ignore when determining if a directory is empty
71
+ # These files are created automatically by operating systems and should not
72
+ # prevent lane directory cleanup
73
+ IGNORE_FILES = frozenset({
74
+ ".gitkeep", # Git placeholder
75
+ ".DS_Store", # macOS Finder metadata
76
+ "Thumbs.db", # Windows thumbnail cache
77
+ "desktop.ini", # Windows folder settings
78
+ ".directory", # KDE folder settings
79
+ "._*", # macOS resource fork prefix (pattern)
80
+ })
81
+
82
+ @classmethod
83
+ def _should_ignore_file(cls, filename: str) -> bool:
84
+ """Check if a file should be ignored when determining if directory is empty.
85
+
86
+ Args:
87
+ filename: Name of the file to check
88
+
89
+ Returns:
90
+ True if file should be ignored (system file), False otherwise
91
+ """
92
+ # Check exact matches
93
+ if filename in cls.IGNORE_FILES:
94
+ return True
95
+
96
+ # Check pattern matches (e.g., ._* for macOS resource forks)
97
+ # Check for macOS resource fork files (._filename)
98
+ if filename.startswith("._"):
99
+ return True
100
+
101
+ return False
102
+
103
+ @classmethod
104
+ def _get_real_contents(cls, directory: Path) -> List[Path]:
105
+ """Get directory contents, excluding system files.
106
+
107
+ Args:
108
+ directory: Path to directory to check
109
+
110
+ Returns:
111
+ List of "real" files (excluding system files like .DS_Store)
112
+ """
113
+ if not directory.exists() or not directory.is_dir():
114
+ return []
115
+
116
+ return [
117
+ item
118
+ for item in directory.iterdir()
119
+ if not cls._should_ignore_file(item.name)
120
+ ]
121
+
122
+ def detect(self, project_path: Path) -> bool:
123
+ """Check if lane subdirectories exist OR worktrees have agent dirs/scripts."""
124
+ # Part 1: Check for remaining lane subdirectories
125
+ main_specs = project_path / "kitty-specs"
126
+ if main_specs.exists():
127
+ for feature_dir in main_specs.iterdir():
128
+ if feature_dir.is_dir() and self._has_remaining_lane_dirs(feature_dir):
129
+ return True
130
+
131
+ worktrees_dir = project_path / ".worktrees"
132
+ if worktrees_dir.exists():
133
+ for worktree in worktrees_dir.iterdir():
134
+ if not worktree.is_dir():
135
+ continue
136
+
137
+ # Check for lane dirs in worktree features
138
+ wt_specs = worktree / "kitty-specs"
139
+ if wt_specs.exists():
140
+ for feature_dir in wt_specs.iterdir():
141
+ if feature_dir.is_dir() and self._has_remaining_lane_dirs(feature_dir):
142
+ return True
143
+
144
+ # Part 2: Check for agent command directories in worktree
145
+ for agent_dir, subdir in self.AGENT_DIRS:
146
+ wt_commands = worktree / agent_dir / subdir
147
+ if wt_commands.exists():
148
+ return True
149
+
150
+ # Part 2: Check for .kittify/scripts/ in worktree
151
+ wt_scripts = worktree / ".kittify" / "scripts"
152
+ if wt_scripts.exists():
153
+ return True
154
+
155
+ return False
156
+
157
+ def _has_remaining_lane_dirs(self, feature_path: Path) -> bool:
158
+ """Check if feature still has lane subdirectories with any content."""
159
+ tasks_dir = feature_path / "tasks"
160
+ if not tasks_dir.exists():
161
+ return False
162
+
163
+ for lane in self.LANE_DIRS:
164
+ lane_path = tasks_dir / lane
165
+ if lane_path.is_dir():
166
+ # Check for real contents (ignoring system files)
167
+ real_contents = self._get_real_contents(lane_path)
168
+ if real_contents:
169
+ return True
170
+ # Even if only system files, still need migration to remove the directory
171
+ # (The directory itself shouldn't exist in new format)
172
+ elif any(lane_path.iterdir()):
173
+ return True
174
+
175
+ return False
176
+
177
+ def can_apply(self, project_path: Path) -> tuple[bool, str]:
178
+ """Migration can always be applied if lane directories exist."""
179
+ return True, ""
180
+
181
+ def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
182
+ """Apply both lane migration and worktree cleanup."""
183
+ changes: List[str] = []
184
+ warnings: List[str] = []
185
+ errors: List[str] = []
186
+
187
+ # Part 1: Complete lane migration
188
+ changes.append("=== Part 1: Complete Lane Migration ===")
189
+ features_found = self._find_features_with_lanes(project_path)
190
+
191
+ if features_found:
192
+ total_migrated = 0
193
+ total_dirs_removed = 0
194
+
195
+ for feature_dir, location_label in features_found:
196
+ feature_changes, feature_warnings, feature_errors, migrated, dirs_removed = (
197
+ self._migrate_remaining_files(feature_dir, location_label, dry_run)
198
+ )
199
+ changes.extend(feature_changes)
200
+ warnings.extend(feature_warnings)
201
+ errors.extend(feature_errors)
202
+ total_migrated += migrated
203
+ total_dirs_removed += dirs_removed
204
+
205
+ if dry_run:
206
+ changes.append(
207
+ f"Would migrate {total_migrated} files and remove {total_dirs_removed} lane directories"
208
+ )
209
+ else:
210
+ changes.append(
211
+ f"Migrated {total_migrated} files and removed {total_dirs_removed} lane directories"
212
+ )
213
+ else:
214
+ changes.append("No lane subdirectories found")
215
+
216
+ # Part 2: Clean up worktrees
217
+ changes.append("")
218
+ changes.append("=== Part 2: Worktree Cleanup ===")
219
+ worktree_changes, worktree_errors = self._cleanup_worktrees(project_path, dry_run)
220
+ changes.extend(worktree_changes)
221
+ errors.extend(worktree_errors)
222
+
223
+ # Part 3: Normalize frontmatter
224
+ changes.append("")
225
+ changes.append("=== Part 3: Normalize Frontmatter ===")
226
+ fm_changes, fm_warnings, fm_errors = self._normalize_all_frontmatter(project_path, dry_run)
227
+ changes.extend(fm_changes)
228
+ warnings.extend(fm_warnings)
229
+ errors.extend(fm_errors)
230
+
231
+ success = len(errors) == 0
232
+ return MigrationResult(
233
+ success=success,
234
+ changes_made=changes,
235
+ errors=errors,
236
+ warnings=warnings,
237
+ )
238
+
239
+ def _find_features_with_lanes(self, project_path: Path) -> List[Tuple[Path, str]]:
240
+ """Find all features with remaining lane subdirectories."""
241
+ features: List[Tuple[Path, str]] = []
242
+
243
+ # Scan main kitty-specs/
244
+ main_specs = project_path / "kitty-specs"
245
+ if main_specs.exists():
246
+ for feature_dir in sorted(main_specs.iterdir()):
247
+ if feature_dir.is_dir() and self._has_remaining_lane_dirs(feature_dir):
248
+ features.append((feature_dir, "main"))
249
+
250
+ # Scan .worktrees/
251
+ worktrees_dir = project_path / ".worktrees"
252
+ if worktrees_dir.exists():
253
+ for worktree in sorted(worktrees_dir.iterdir()):
254
+ if worktree.is_dir():
255
+ wt_specs = worktree / "kitty-specs"
256
+ if wt_specs.exists():
257
+ for feature_dir in sorted(wt_specs.iterdir()):
258
+ if feature_dir.is_dir() and self._has_remaining_lane_dirs(feature_dir):
259
+ features.append((feature_dir, f"worktree:{worktree.name}"))
260
+
261
+ return features
262
+
263
+ def _migrate_remaining_files(
264
+ self,
265
+ feature_dir: Path,
266
+ location_label: str,
267
+ dry_run: bool,
268
+ ) -> Tuple[List[str], List[str], List[str], int, int]:
269
+ """Migrate all remaining files from a feature's lane subdirectories."""
270
+ changes: List[str] = []
271
+ warnings: List[str] = []
272
+ errors: List[str] = []
273
+ migrated = 0
274
+ dirs_removed = 0
275
+
276
+ tasks_dir = feature_dir / "tasks"
277
+ if not tasks_dir.exists():
278
+ return changes, warnings, errors, migrated, dirs_removed
279
+
280
+ feature_name = feature_dir.name
281
+ changes.append(f"[{location_label}] {feature_name}:")
282
+
283
+ for lane in self.LANE_DIRS:
284
+ lane_dir = tasks_dir / lane
285
+ if not lane_dir.is_dir():
286
+ continue
287
+
288
+ # Get ALL items in the lane directory (files and subdirectories)
289
+ for item in sorted(lane_dir.iterdir()):
290
+ if item.name == ".gitkeep":
291
+ continue # Skip .gitkeep files
292
+
293
+ if item.is_file():
294
+ # Move file to flat directory
295
+ target = tasks_dir / item.name
296
+
297
+ # Check if already exists
298
+ if target.exists():
299
+ warnings.append(f" Skip: {item.name} already exists in tasks/")
300
+ continue
301
+
302
+ try:
303
+ if dry_run:
304
+ changes.append(f" Would move: {lane}/{item.name} → tasks/{item.name}")
305
+ else:
306
+ # For .md files, ensure lane in frontmatter
307
+ if item.suffix == ".md":
308
+ content = item.read_text(encoding="utf-8-sig")
309
+ updated_content = self._ensure_lane_in_frontmatter(content, lane)
310
+ target.write_text(updated_content, encoding="utf-8")
311
+ else:
312
+ # For non-.md files, just copy
313
+ target.write_bytes(item.read_bytes())
314
+
315
+ # Remove original
316
+ item.unlink()
317
+
318
+ changes.append(f" Moved: {lane}/{item.name} → tasks/{item.name}")
319
+
320
+ migrated += 1
321
+
322
+ except Exception as e:
323
+ errors.append(f" Error migrating {lane}/{item.name}: {e}")
324
+
325
+ elif item.is_dir():
326
+ # Handle nested directories (shouldn't exist but might)
327
+ warnings.append(
328
+ f" Warning: Nested directory {lane}/{item.name}/ found - please check manually"
329
+ )
330
+
331
+ # Clean up empty lane directory
332
+ if not dry_run:
333
+ if lane_dir.is_dir():
334
+ # Check for real contents (ignoring system files)
335
+ real_contents = self._get_real_contents(lane_dir)
336
+ if not real_contents:
337
+ # Directory has no real files (only system files like .DS_Store or .gitkeep)
338
+ try:
339
+ # Use shutil.rmtree for more robust removal
340
+ # This will remove the directory and all system files within it
341
+ shutil.rmtree(lane_dir)
342
+ changes.append(f" Removed: {lane}/")
343
+ dirs_removed += 1
344
+ except OSError as e:
345
+ warnings.append(f" Could not remove {lane}/: {e}")
346
+ else:
347
+ if lane_dir.is_dir():
348
+ real_contents = self._get_real_contents(lane_dir)
349
+ if not real_contents:
350
+ changes.append(f" Would remove: {lane}/")
351
+ dirs_removed += 1
352
+
353
+ return changes, warnings, errors, migrated, dirs_removed
354
+
355
+ def _ensure_lane_in_frontmatter(self, content: str, expected_lane: str) -> str:
356
+ """Ensure frontmatter has correct lane field."""
357
+ # Find frontmatter boundaries
358
+ if not content.startswith("---"):
359
+ # No frontmatter, add it
360
+ return f'---\nlane: "{expected_lane}"\n---\n{content}'
361
+
362
+ # Find closing ---
363
+ lines = content.split("\n")
364
+ closing_idx = -1
365
+ for i, line in enumerate(lines[1:], start=1):
366
+ if line.strip() == "---":
367
+ closing_idx = i
368
+ break
369
+
370
+ if closing_idx == -1:
371
+ # Malformed frontmatter, add lane anyway
372
+ return f'---\nlane: "{expected_lane}"\n---\n{content}'
373
+
374
+ frontmatter_lines = lines[1:closing_idx]
375
+ body_lines = lines[closing_idx + 1:]
376
+
377
+ # Check if lane field exists
378
+ lane_pattern = re.compile(r'^lane:\s*(.*)$')
379
+ lane_found = False
380
+ updated_lines = []
381
+
382
+ for line in frontmatter_lines:
383
+ match = lane_pattern.match(line)
384
+ if match:
385
+ lane_found = True
386
+ current_value = match.group(1).strip().strip('"\'')
387
+ if current_value != expected_lane:
388
+ # Replace with expected lane from directory
389
+ updated_lines.append(f'lane: "{expected_lane}"')
390
+ else:
391
+ updated_lines.append(line)
392
+ else:
393
+ updated_lines.append(line)
394
+
395
+ if not lane_found:
396
+ # Insert lane field (before history: if present, otherwise at end)
397
+ insert_idx = len(updated_lines)
398
+ for i, line in enumerate(updated_lines):
399
+ if line.startswith("history:"):
400
+ insert_idx = i
401
+ break
402
+ updated_lines.insert(insert_idx, f'lane: "{expected_lane}"')
403
+
404
+ # Reconstruct document
405
+ result_lines = ["---"] + updated_lines + ["---"] + body_lines
406
+ return "\n".join(result_lines)
407
+
408
+ def _cleanup_worktrees(self, project_path: Path, dry_run: bool) -> Tuple[List[str], List[str]]:
409
+ """Clean up agent command directories and scripts from all worktrees."""
410
+ changes: List[str] = []
411
+ errors: List[str] = []
412
+
413
+ worktrees_dir = project_path / ".worktrees"
414
+ if not worktrees_dir.exists():
415
+ changes.append("No .worktrees/ directory found")
416
+ return changes, errors
417
+
418
+ worktrees_cleaned = 0
419
+ for worktree in sorted(worktrees_dir.iterdir()):
420
+ if not worktree.is_dir():
421
+ continue
422
+
423
+ worktree_name = worktree.name
424
+ cleaned_this_worktree = False
425
+
426
+ # Remove agent command directories
427
+ for agent_dir, subdir in self.AGENT_DIRS:
428
+ commands_dir = worktree / agent_dir / subdir
429
+ # Check is_symlink() FIRST - exists() returns False for broken symlinks!
430
+ if commands_dir.is_symlink() or commands_dir.exists():
431
+ if dry_run:
432
+ is_symlink = commands_dir.is_symlink()
433
+ type_str = "symlink" if is_symlink else "directory"
434
+ changes.append(
435
+ f"[{worktree_name}] Would remove {agent_dir}/{subdir}/ ({type_str})"
436
+ )
437
+ else:
438
+ try:
439
+ # Check if it's a symlink - handle differently
440
+ if commands_dir.is_symlink():
441
+ commands_dir.unlink()
442
+ changes.append(
443
+ f"[{worktree_name}] Removed {agent_dir}/{subdir}/ symlink (inherits from main)"
444
+ )
445
+ elif commands_dir.is_dir():
446
+ shutil.rmtree(commands_dir)
447
+ changes.append(
448
+ f"[{worktree_name}] Removed {agent_dir}/{subdir}/ (inherits from main)"
449
+ )
450
+
451
+ # Clean up parent directory if now empty
452
+ parent = commands_dir.parent
453
+ if parent.exists() and not any(parent.iterdir()):
454
+ parent.rmdir()
455
+
456
+ cleaned_this_worktree = True
457
+
458
+ except OSError as e:
459
+ errors.append(
460
+ f"[{worktree_name}] Failed to remove {agent_dir}/{subdir}/: {e}"
461
+ )
462
+
463
+ # Remove .kittify/scripts/
464
+ scripts_dir = worktree / ".kittify" / "scripts"
465
+ # Check is_symlink() FIRST - exists() returns False for broken symlinks!
466
+ if scripts_dir.is_symlink() or scripts_dir.exists():
467
+ if dry_run:
468
+ is_symlink = scripts_dir.is_symlink()
469
+ type_str = "symlink" if is_symlink else "directory"
470
+ changes.append(
471
+ f"[{worktree_name}] Would remove .kittify/scripts/ ({type_str})"
472
+ )
473
+ else:
474
+ try:
475
+ # Check if it's a symlink - handle differently
476
+ if scripts_dir.is_symlink():
477
+ scripts_dir.unlink()
478
+ changes.append(
479
+ f"[{worktree_name}] Removed .kittify/scripts/ symlink (inherits from main)"
480
+ )
481
+ elif scripts_dir.is_dir():
482
+ shutil.rmtree(scripts_dir)
483
+ changes.append(
484
+ f"[{worktree_name}] Removed .kittify/scripts/ (inherits from main)"
485
+ )
486
+ cleaned_this_worktree = True
487
+ except OSError as e:
488
+ errors.append(
489
+ f"[{worktree_name}] Failed to remove .kittify/scripts/: {e}"
490
+ )
491
+
492
+ if cleaned_this_worktree:
493
+ worktrees_cleaned += 1
494
+
495
+ if worktrees_cleaned > 0:
496
+ if dry_run:
497
+ changes.append(f"Would clean up {worktrees_cleaned} worktrees")
498
+ else:
499
+ changes.append(f"Cleaned up {worktrees_cleaned} worktrees")
500
+ else:
501
+ changes.append("No worktrees needed cleanup")
502
+
503
+ return changes, errors
504
+
505
+ def _normalize_all_frontmatter(
506
+ self, project_path: Path, dry_run: bool
507
+ ) -> Tuple[List[str], List[str], List[str]]:
508
+ """Normalize frontmatter in all markdown files for consistency.
509
+
510
+ This ensures:
511
+ - Consistent YAML formatting (no manual quotes)
512
+ - Consistent field ordering
513
+ - Proper ruamel.yaml formatting
514
+ """
515
+ changes: List[str] = []
516
+ warnings: List[str] = []
517
+ errors: List[str] = []
518
+
519
+ # Find all markdown files in kitty-specs/
520
+ md_files: List[Path] = []
521
+
522
+ # Main kitty-specs/
523
+ main_specs = project_path / "kitty-specs"
524
+ if main_specs.exists():
525
+ md_files.extend(main_specs.rglob("*.md"))
526
+
527
+ # Worktrees
528
+ worktrees_dir = project_path / ".worktrees"
529
+ if worktrees_dir.exists():
530
+ for worktree in worktrees_dir.iterdir():
531
+ if worktree.is_dir():
532
+ wt_specs = worktree / "kitty-specs"
533
+ if wt_specs.exists():
534
+ md_files.extend(wt_specs.rglob("*.md"))
535
+
536
+ if not md_files:
537
+ changes.append("No markdown files found")
538
+ return changes, warnings, errors
539
+
540
+ normalized_count = 0
541
+ skipped_count = 0
542
+
543
+ for md_file in sorted(md_files):
544
+ # Skip if not a task/WP file (e.g., README.md, spec.md, etc.)
545
+ # Only normalize files in tasks/ directories
546
+ if "tasks" not in md_file.parts:
547
+ continue
548
+
549
+ try:
550
+ if dry_run:
551
+ # Just check if it would change
552
+ try:
553
+ from specify_cli.frontmatter import FrontmatterManager
554
+ manager = FrontmatterManager()
555
+ original = md_file.read_text(encoding="utf-8-sig")
556
+ frontmatter, body = manager.read(md_file)
557
+
558
+ # Write to temp buffer
559
+ import io
560
+ buffer = io.StringIO()
561
+ buffer.write("---\n")
562
+ manager.yaml.dump(manager._normalize_frontmatter(frontmatter), buffer)
563
+ buffer.write("---\n")
564
+ buffer.write(body)
565
+ new_content = buffer.getvalue()
566
+
567
+ if original != new_content:
568
+ changes.append(f"Would normalize: {md_file.relative_to(project_path)}")
569
+ normalized_count += 1
570
+ else:
571
+ skipped_count += 1
572
+ except FrontmatterError:
573
+ warnings.append(f"Skip (no frontmatter): {md_file.relative_to(project_path)}")
574
+ skipped_count += 1
575
+ else:
576
+ # Actually normalize
577
+ if normalize_file(md_file):
578
+ changes.append(f"Normalized: {md_file.relative_to(project_path)}")
579
+ normalized_count += 1
580
+ else:
581
+ skipped_count += 1
582
+
583
+ except FrontmatterError as e:
584
+ warnings.append(f"Skip (error): {md_file.relative_to(project_path)}: {e}")
585
+ skipped_count += 1
586
+ except Exception as e:
587
+ errors.append(f"Failed to normalize {md_file.relative_to(project_path)}: {e}")
588
+
589
+ if dry_run:
590
+ changes.append(f"Would normalize {normalized_count} files, skip {skipped_count}")
591
+ else:
592
+ changes.append(f"Normalized {normalized_count} files, skipped {skipped_count}")
593
+
594
+ return changes, warnings, errors
595
+
596
+
597
+ # Export AGENT_DIRS for use by other migrations
598
+ # This is the canonical source - all other migrations should import this
599
+ AGENT_DIR_TO_KEY = {
600
+ ".claude": "claude",
601
+ ".github": "copilot",
602
+ ".gemini": "gemini",
603
+ ".cursor": "cursor",
604
+ ".qwen": "qwen",
605
+ ".opencode": "opencode",
606
+ ".windsurf": "windsurf",
607
+ ".codex": "codex",
608
+ ".kilocode": "kilocode",
609
+ ".augment": "auggie",
610
+ ".roo": "roo",
611
+ ".amazonq": "q",
612
+ }
613
+
614
+
615
+ def get_agent_dirs_for_project(project_path: Path) -> list[tuple[str, str]]:
616
+ """Get agent directories to process based on project config.
617
+
618
+ Reads config.yaml to determine which agents are enabled.
619
+ Only returns directories for configured agents.
620
+ Falls back to all agents for legacy projects without config.
621
+
622
+ Args:
623
+ project_path: Path to project root
624
+
625
+ Returns:
626
+ List of (agent_root, subdir) tuples for configured agents
627
+ """
628
+ try:
629
+ from specify_cli.orchestrator.agent_config import get_configured_agents
630
+
631
+ available = get_configured_agents(project_path)
632
+
633
+ if not available:
634
+ # Empty config - fallback to all agents
635
+ return list(CompleteLaneMigration.AGENT_DIRS)
636
+
637
+ # Filter AGENT_DIRS to only include configured agents
638
+ configured_dirs = []
639
+ for agent_root, subdir in CompleteLaneMigration.AGENT_DIRS:
640
+ agent_key = AGENT_DIR_TO_KEY.get(agent_root)
641
+ if agent_key in available:
642
+ configured_dirs.append((agent_root, subdir))
643
+
644
+ return configured_dirs
645
+
646
+ except Exception:
647
+ # Config missing or error reading - fallback to all agents
648
+ # This handles legacy projects gracefully
649
+ return list(CompleteLaneMigration.AGENT_DIRS)
650
+
651
+
652
+ __all__ = [
653
+ "CompleteLaneMigration",
654
+ "AGENT_DIR_TO_KEY",
655
+ "get_agent_dirs_for_project",
656
+ ]