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,114 @@
1
+ """Migration: Update scripts to latest version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from ..registry import MigrationRegistry
9
+ from .base import BaseMigration, MigrationResult
10
+
11
+
12
+ @MigrationRegistry.register
13
+ class UpdateScriptsMigration(BaseMigration):
14
+ """Update .kittify/scripts/ to latest version.
15
+
16
+ The create-new-feature.sh script was fixed in v0.7.3 to scan both
17
+ kitty-specs/ AND .worktrees/ for existing feature numbers. Projects
18
+ initialized before v0.7.3 have the old script that only scans kitty-specs/,
19
+ causing duplicate feature numbers when using worktrees.
20
+ """
21
+
22
+ migration_id = "0.7.3_update_scripts"
23
+ description = "Update scripts to fix worktree feature numbering"
24
+ target_version = "0.7.3"
25
+
26
+ def detect(self, project_path: Path) -> bool:
27
+ """Check if project has old scripts that need updating."""
28
+ script_path = project_path / ".kittify" / "scripts" / "bash" / "create-new-feature.sh"
29
+
30
+ if not script_path.exists():
31
+ return False
32
+
33
+ # Check if script has the fix (scans .worktrees/)
34
+ try:
35
+ content = script_path.read_text(encoding="utf-8")
36
+ # Old script doesn't have this line
37
+ if "Also scan .worktrees/" not in content:
38
+ return True
39
+ except (OSError, UnicodeDecodeError):
40
+ return False
41
+
42
+ return False
43
+
44
+ def can_apply(self, project_path: Path) -> tuple[bool, str]:
45
+ """Check if we can apply this migration."""
46
+ kittify_dir = project_path / ".kittify"
47
+ if not kittify_dir.exists():
48
+ return False, "No .kittify directory (not a spec-kitty project)"
49
+
50
+ return True, ""
51
+
52
+ def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
53
+ """Copy updated scripts from package templates."""
54
+ changes: list[str] = []
55
+ warnings: list[str] = []
56
+ errors: list[str] = []
57
+
58
+ import specify_cli
59
+
60
+ pkg_root = Path(specify_cli.__file__).parent
61
+
62
+ # Scripts to update
63
+ scripts = [
64
+ ("scripts/bash/create-new-feature.sh", ".kittify/scripts/bash/create-new-feature.sh"),
65
+ ("scripts/bash/common.sh", ".kittify/scripts/bash/common.sh"),
66
+ ]
67
+
68
+ any_scripts_found = False
69
+ for src_rel, _ in scripts:
70
+ if (pkg_root / src_rel).exists():
71
+ any_scripts_found = True
72
+ break
73
+
74
+ if not any_scripts_found:
75
+ warnings.append(
76
+ "Bash scripts not found in package (removed in later version or never existed). "
77
+ "If you need script updates, they may have been handled by migration 0.10.0 cleanup. "
78
+ "This is not an error."
79
+ )
80
+ return MigrationResult(
81
+ success=True,
82
+ changes_made=[],
83
+ errors=[],
84
+ warnings=warnings,
85
+ )
86
+
87
+ for src_rel, dest_rel in scripts:
88
+ src = pkg_root / src_rel
89
+ dest = project_path / dest_rel
90
+
91
+ if not src.exists():
92
+ warnings.append(f"Template {src_rel} not found in package")
93
+ continue
94
+
95
+ if not dest.parent.exists():
96
+ if not dry_run:
97
+ dest.parent.mkdir(parents=True, exist_ok=True)
98
+
99
+ if dry_run:
100
+ changes.append(f"Would update {dest_rel}")
101
+ else:
102
+ try:
103
+ shutil.copy2(src, dest)
104
+ changes.append(f"Updated {dest_rel}")
105
+ except OSError as e:
106
+ errors.append(f"Failed to update {dest_rel}: {e}")
107
+
108
+ success = len(errors) == 0
109
+ return MigrationResult(
110
+ success=success,
111
+ changes_made=changes,
112
+ errors=errors,
113
+ warnings=warnings,
114
+ )
@@ -0,0 +1,82 @@
1
+ """Migration: Remove deprecated .kittify/active-mission file/symlink."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..registry import MigrationRegistry
8
+ from .base import BaseMigration, MigrationResult
9
+
10
+
11
+ @MigrationRegistry.register
12
+ class RemoveActiveMissionMigration(BaseMigration):
13
+ """Remove deprecated .kittify/active-mission file or symlink.
14
+
15
+ As of v0.8.0, missions are selected per-feature during /spec-kitty.specify.
16
+ The project-level .kittify/active-mission symlink/file is no longer used.
17
+
18
+ This migration removes the obsolete active-mission file and informs the
19
+ user about the new per-feature mission workflow.
20
+ """
21
+
22
+ migration_id = "0.8.0_remove_active_mission"
23
+ description = "Remove deprecated .kittify/active-mission (missions are now per-feature)"
24
+ target_version = "0.8.0"
25
+
26
+ def detect(self, project_path: Path) -> bool:
27
+ """Check if .kittify/active-mission exists."""
28
+ kittify_dir = project_path / ".kittify"
29
+ active_mission = kittify_dir / "active-mission"
30
+
31
+ # Check for file, symlink, or broken symlink
32
+ return active_mission.exists() or active_mission.is_symlink()
33
+
34
+ def can_apply(self, project_path: Path) -> tuple[bool, str]:
35
+ """Migration can always be applied if active-mission exists."""
36
+ return True, ""
37
+
38
+ def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
39
+ """Remove .kittify/active-mission file or symlink."""
40
+ changes: list[str] = []
41
+ warnings: list[str] = []
42
+ errors: list[str] = []
43
+
44
+ kittify_dir = project_path / ".kittify"
45
+ active_mission = kittify_dir / "active-mission"
46
+
47
+ if active_mission.exists() or active_mission.is_symlink():
48
+ if dry_run:
49
+ changes.append(
50
+ "Would remove .kittify/active-mission"
51
+ )
52
+ changes.append(
53
+ " -> Missions are now selected per-feature during /spec-kitty.specify"
54
+ )
55
+ else:
56
+ try:
57
+ active_mission.unlink()
58
+ changes.append(
59
+ "Removed deprecated .kittify/active-mission"
60
+ )
61
+ changes.append(
62
+ " -> Missions are now selected per-feature during /spec-kitty.specify"
63
+ )
64
+ changes.append(
65
+ " -> Existing features will use 'software-dev' mission by default"
66
+ )
67
+ except OSError as e:
68
+ errors.append(
69
+ f"Failed to remove .kittify/active-mission: {e}"
70
+ )
71
+ else:
72
+ warnings.append(
73
+ "No .kittify/active-mission found (already migrated or new project)"
74
+ )
75
+
76
+ success = len(errors) == 0
77
+ return MigrationResult(
78
+ success=success,
79
+ changes_made=changes,
80
+ errors=errors,
81
+ warnings=warnings,
82
+ )
@@ -0,0 +1,148 @@
1
+ """Migration: Create AGENTS.md symlink in worktrees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from ..registry import MigrationRegistry
10
+ from .base import BaseMigration, MigrationResult
11
+
12
+
13
+ @MigrationRegistry.register
14
+ class WorktreeAgentsSymlinkMigration(BaseMigration):
15
+ """Create .kittify/AGENTS.md symlink in worktrees.
16
+
17
+ Worktrees need access to the main repo's .kittify/AGENTS.md file
18
+ for command templates that reference it. Since .kittify/ is gitignored,
19
+ worktrees don't automatically have it.
20
+
21
+ This migration creates a symlink from each worktree's
22
+ .kittify/AGENTS.md to the main repo's .kittify/AGENTS.md.
23
+ """
24
+
25
+ migration_id = "0.8.0_worktree_agents_symlink"
26
+ description = "Create .kittify/AGENTS.md symlink in worktrees"
27
+ target_version = "0.8.0"
28
+
29
+ def detect(self, project_path: Path) -> bool:
30
+ """Check if any worktrees are missing .kittify/AGENTS.md."""
31
+ worktrees_dir = project_path / ".worktrees"
32
+ main_agents = project_path / ".kittify" / "AGENTS.md"
33
+
34
+ # No main AGENTS.md means nothing to symlink
35
+ if not main_agents.exists():
36
+ return False
37
+
38
+ if not worktrees_dir.exists():
39
+ return False
40
+
41
+ for worktree in worktrees_dir.iterdir():
42
+ if worktree.is_dir() and not worktree.name.startswith('.'):
43
+ wt_agents = worktree / ".kittify" / "AGENTS.md"
44
+ # Check if missing or broken symlink
45
+ if not wt_agents.exists() and not wt_agents.is_symlink():
46
+ return True
47
+ # Also check for broken symlinks
48
+ if wt_agents.is_symlink() and not wt_agents.exists():
49
+ return True
50
+
51
+ return False
52
+
53
+ def can_apply(self, project_path: Path) -> tuple[bool, str]:
54
+ """Check that main repo has AGENTS.md."""
55
+ main_agents = project_path / ".kittify" / "AGENTS.md"
56
+
57
+ if not main_agents.exists():
58
+ return (
59
+ False,
60
+ "Main repo .kittify/AGENTS.md must exist before creating symlinks"
61
+ )
62
+
63
+ return True, ""
64
+
65
+ def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
66
+ """Create .kittify/AGENTS.md symlink in all worktrees."""
67
+ changes: list[str] = []
68
+ warnings: list[str] = []
69
+ errors: list[str] = []
70
+
71
+ worktrees_dir = project_path / ".worktrees"
72
+ main_agents = project_path / ".kittify" / "AGENTS.md"
73
+
74
+ if not main_agents.exists():
75
+ warnings.append("Main repo .kittify/AGENTS.md not found, skipping")
76
+ return MigrationResult(
77
+ success=True,
78
+ changes_made=changes,
79
+ errors=errors,
80
+ warnings=warnings,
81
+ )
82
+
83
+ if worktrees_dir.exists():
84
+ for worktree in worktrees_dir.iterdir():
85
+ if worktree.is_dir() and not worktree.name.startswith('.'):
86
+ wt_kittify = worktree / ".kittify"
87
+ wt_agents = wt_kittify / "AGENTS.md"
88
+
89
+ # Skip if already exists and is valid
90
+ if wt_agents.exists() and not wt_agents.is_symlink():
91
+ warnings.append(
92
+ f"Worktree {worktree.name} has non-symlink AGENTS.md, skipping"
93
+ )
94
+ continue
95
+
96
+ if wt_agents.is_symlink() and wt_agents.exists():
97
+ # Valid symlink already exists
98
+ continue
99
+
100
+ # Calculate relative path: ../../../.kittify/AGENTS.md
101
+ # From: .worktrees/001-feature/.kittify/AGENTS.md
102
+ # To: .kittify/AGENTS.md
103
+ relative_path = "../../../.kittify/AGENTS.md"
104
+
105
+ if dry_run:
106
+ changes.append(
107
+ f"Would create .kittify/AGENTS.md symlink in worktree {worktree.name}"
108
+ )
109
+ else:
110
+ try:
111
+ # Ensure .kittify directory exists
112
+ wt_kittify.mkdir(parents=True, exist_ok=True)
113
+
114
+ # Remove broken symlink if present
115
+ if wt_agents.is_symlink():
116
+ wt_agents.unlink()
117
+
118
+ # Create the symlink
119
+ # Need to change to the directory to create relative symlink
120
+ original_cwd = os.getcwd()
121
+ try:
122
+ os.chdir(wt_kittify)
123
+ os.symlink(relative_path, "AGENTS.md")
124
+ finally:
125
+ os.chdir(original_cwd)
126
+
127
+ changes.append(
128
+ f"Created .kittify/AGENTS.md symlink in worktree {worktree.name}"
129
+ )
130
+ except OSError as e:
131
+ # Symlink failed (Windows?), try copying instead
132
+ try:
133
+ shutil.copy2(main_agents, wt_agents)
134
+ changes.append(
135
+ f"Copied .kittify/AGENTS.md to worktree {worktree.name} (symlink failed)"
136
+ )
137
+ except OSError as copy_error:
138
+ errors.append(
139
+ f"Failed to create AGENTS.md in {worktree.name}: {e}, copy also failed: {copy_error}"
140
+ )
141
+
142
+ success = len(errors) == 0
143
+ return MigrationResult(
144
+ success=success,
145
+ changes_made=changes,
146
+ errors=errors,
147
+ warnings=warnings,
148
+ )
@@ -0,0 +1,346 @@
1
+ """Migration: Convert directory-based task lanes to frontmatter-only lanes."""
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
+
13
+
14
+ @MigrationRegistry.register
15
+ class FrontmatterOnlyLanesMigration(BaseMigration):
16
+ """Migrate from directory-based to frontmatter-only lane management.
17
+
18
+ As of v0.9.0, task lanes are determined solely by the `lane:` field
19
+ in the YAML frontmatter of work package files. The directory structure
20
+ (tasks/planned/, tasks/doing/, etc.) is flattened to a single tasks/
21
+ directory.
22
+
23
+ This migration:
24
+ 1. Moves WP files from tasks/{lane}/ to tasks/
25
+ 2. Ensures the `lane:` field is set from the source directory
26
+ 3. Removes empty lane subdirectories
27
+ 4. Processes main kitty-specs/ and all .worktrees/
28
+ """
29
+
30
+ migration_id = "0.9.0_frontmatter_only_lanes"
31
+ description = "Flatten task lanes to frontmatter-only (no more directory-based lanes)"
32
+ target_version = "0.9.0"
33
+
34
+ LANE_DIRS: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
35
+
36
+ # System files to ignore when determining if a directory is empty
37
+ # These files are created automatically by operating systems and should not
38
+ # prevent lane directory cleanup
39
+ IGNORE_FILES = frozenset({
40
+ ".gitkeep", # Git placeholder
41
+ ".DS_Store", # macOS Finder metadata
42
+ "Thumbs.db", # Windows thumbnail cache
43
+ "desktop.ini", # Windows folder settings
44
+ ".directory", # KDE folder settings
45
+ "._*", # macOS resource fork prefix (pattern)
46
+ })
47
+
48
+ @classmethod
49
+ def _should_ignore_file(cls, filename: str) -> bool:
50
+ """Check if a file should be ignored when determining if directory is empty.
51
+
52
+ Args:
53
+ filename: Name of the file to check
54
+
55
+ Returns:
56
+ True if file should be ignored (system file), False otherwise
57
+ """
58
+ # Check exact matches
59
+ if filename in cls.IGNORE_FILES:
60
+ return True
61
+
62
+ # Check pattern matches (e.g., ._* for macOS resource forks)
63
+ # Check for macOS resource fork files (._filename)
64
+ if filename.startswith("._"):
65
+ return True
66
+
67
+ return False
68
+
69
+ @classmethod
70
+ def _get_real_contents(cls, directory: Path) -> List[Path]:
71
+ """Get directory contents, excluding system files.
72
+
73
+ Args:
74
+ directory: Path to directory to check
75
+
76
+ Returns:
77
+ List of "real" files (excluding system files like .DS_Store)
78
+ """
79
+ if not directory.exists() or not directory.is_dir():
80
+ return []
81
+
82
+ return [
83
+ item
84
+ for item in directory.iterdir()
85
+ if not cls._should_ignore_file(item.name)
86
+ ]
87
+
88
+ def detect(self, project_path: Path) -> bool:
89
+ """Check if any feature uses legacy directory-based lanes."""
90
+ # Check main kitty-specs/
91
+ main_specs = project_path / "kitty-specs"
92
+ if main_specs.exists():
93
+ for feature_dir in main_specs.iterdir():
94
+ if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
95
+ return True
96
+
97
+ # Check .worktrees/
98
+ worktrees_dir = project_path / ".worktrees"
99
+ if worktrees_dir.exists():
100
+ for worktree in worktrees_dir.iterdir():
101
+ if worktree.is_dir():
102
+ wt_specs = worktree / "kitty-specs"
103
+ if wt_specs.exists():
104
+ for feature_dir in wt_specs.iterdir():
105
+ if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
106
+ return True
107
+
108
+ return False
109
+
110
+ def _is_legacy_format(self, feature_path: Path) -> bool:
111
+ """Check if a feature uses legacy directory-based lanes."""
112
+ tasks_dir = feature_path / "tasks"
113
+ if not tasks_dir.exists():
114
+ return False
115
+
116
+ # A feature is legacy if it has ANY lane subdirectories
117
+ # (even if empty - they shouldn't exist in new format)
118
+ for lane in self.LANE_DIRS:
119
+ lane_path = tasks_dir / lane
120
+ if lane_path.exists() and lane_path.is_dir():
121
+ # Directory exists - this is legacy format
122
+ # Check if it has any real content (ignoring system files)
123
+ real_contents = self._get_real_contents(lane_path)
124
+ if real_contents:
125
+ return True
126
+ # Even if only system files, still need migration to remove the directory
127
+ # (The directory itself shouldn't exist in new format)
128
+ elif any(lane_path.iterdir()):
129
+ return True
130
+
131
+ return False
132
+
133
+ def can_apply(self, project_path: Path) -> tuple[bool, str]:
134
+ """Migration can always be applied if legacy format is detected."""
135
+ return True, ""
136
+
137
+ def apply(self, project_path: Path, dry_run: bool = False) -> MigrationResult:
138
+ """Migrate all features from directory-based to frontmatter-only lanes."""
139
+ changes: List[str] = []
140
+ warnings: List[str] = []
141
+ errors: List[str] = []
142
+
143
+ features_found = self._find_features_to_migrate(project_path)
144
+
145
+ if not features_found:
146
+ warnings.append("No features need migration - all already use flat structure")
147
+ return MigrationResult(
148
+ success=True,
149
+ changes_made=changes,
150
+ errors=errors,
151
+ warnings=warnings,
152
+ )
153
+
154
+ total_migrated = 0
155
+ total_skipped = 0
156
+
157
+ for feature_dir, location_label in features_found:
158
+ feature_changes, feature_warnings, feature_errors, migrated, skipped = (
159
+ self._migrate_feature(feature_dir, location_label, dry_run)
160
+ )
161
+ changes.extend(feature_changes)
162
+ warnings.extend(feature_warnings)
163
+ errors.extend(feature_errors)
164
+ total_migrated += migrated
165
+ total_skipped += skipped
166
+
167
+ if dry_run:
168
+ changes.append(f"Would migrate {total_migrated} WP files across {len(features_found)} features")
169
+ else:
170
+ changes.append(f"Migrated {total_migrated} WP files across {len(features_found)} features")
171
+
172
+ if total_skipped > 0:
173
+ warnings.append(f"Skipped {total_skipped} files (already exist or conflicts)")
174
+
175
+ success = len(errors) == 0
176
+ return MigrationResult(
177
+ success=success,
178
+ changes_made=changes,
179
+ errors=errors,
180
+ warnings=warnings,
181
+ )
182
+
183
+ def _find_features_to_migrate(self, project_path: Path) -> List[Tuple[Path, str]]:
184
+ """Find all features with legacy format in main repo and worktrees."""
185
+ features: List[Tuple[Path, str]] = []
186
+
187
+ # Scan main kitty-specs/
188
+ main_specs = project_path / "kitty-specs"
189
+ if main_specs.exists():
190
+ for feature_dir in sorted(main_specs.iterdir()):
191
+ if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
192
+ features.append((feature_dir, "main"))
193
+
194
+ # Scan .worktrees/
195
+ worktrees_dir = project_path / ".worktrees"
196
+ if worktrees_dir.exists():
197
+ for worktree in sorted(worktrees_dir.iterdir()):
198
+ if worktree.is_dir():
199
+ wt_specs = worktree / "kitty-specs"
200
+ if wt_specs.exists():
201
+ for feature_dir in sorted(wt_specs.iterdir()):
202
+ if feature_dir.is_dir() and self._is_legacy_format(feature_dir):
203
+ features.append((feature_dir, f"worktree:{worktree.name}"))
204
+
205
+ return features
206
+
207
+ def _migrate_feature(
208
+ self,
209
+ feature_dir: Path,
210
+ location_label: str,
211
+ dry_run: bool,
212
+ ) -> Tuple[List[str], List[str], List[str], int, int]:
213
+ """Migrate a single feature from directory-based to flat structure."""
214
+ changes: List[str] = []
215
+ warnings: List[str] = []
216
+ errors: List[str] = []
217
+ migrated = 0
218
+ skipped = 0
219
+
220
+ tasks_dir = feature_dir / "tasks"
221
+ if not tasks_dir.exists():
222
+ return changes, warnings, errors, migrated, skipped
223
+
224
+ feature_name = feature_dir.name
225
+ changes.append(f"[{location_label}] {feature_name}:")
226
+
227
+ for lane in self.LANE_DIRS:
228
+ lane_dir = tasks_dir / lane
229
+ if not lane_dir.is_dir():
230
+ continue
231
+
232
+ # Find ALL markdown files, not just WP*.md
233
+ md_files = sorted(lane_dir.glob("*.md"))
234
+
235
+ for md_file in md_files:
236
+ # Skip README.md if it exists
237
+ if md_file.name == "README.md":
238
+ continue
239
+
240
+ target = tasks_dir / md_file.name
241
+
242
+ # Check if already exists in flat directory
243
+ if target.exists():
244
+ warnings.append(f" Skip: {md_file.name} already exists in tasks/")
245
+ skipped += 1
246
+ continue
247
+
248
+ try:
249
+ if dry_run:
250
+ changes.append(f" Would move: {lane}/{md_file.name} → tasks/{md_file.name}")
251
+ else:
252
+ # Read and update content
253
+ content = md_file.read_text(encoding="utf-8-sig")
254
+ updated_content = self._ensure_lane_in_frontmatter(content, lane)
255
+
256
+ # Write to new location
257
+ target.write_text(updated_content, encoding="utf-8")
258
+
259
+ # Remove original
260
+ md_file.unlink()
261
+
262
+ changes.append(f" Moved: {lane}/{md_file.name} → tasks/{md_file.name}")
263
+
264
+ migrated += 1
265
+
266
+ except Exception as e:
267
+ errors.append(f" Error migrating {md_file.name}: {e}")
268
+
269
+ # Clean up empty lane directories
270
+ if not dry_run:
271
+ for lane in self.LANE_DIRS:
272
+ lane_dir = tasks_dir / lane
273
+ if lane_dir.exists() and lane_dir.is_dir():
274
+ # Check for real contents (ignoring system files)
275
+ real_contents = self._get_real_contents(lane_dir)
276
+ if not real_contents:
277
+ # Directory has no real files (only system files like .DS_Store or .gitkeep)
278
+ try:
279
+ # Use shutil.rmtree for more robust removal
280
+ # This will remove the directory and all system files within it
281
+ shutil.rmtree(lane_dir)
282
+ changes.append(f" Removed empty: {lane}/")
283
+ except OSError as e:
284
+ warnings.append(f" Could not remove {lane}/: {e}")
285
+ else:
286
+ for lane in self.LANE_DIRS:
287
+ lane_dir = tasks_dir / lane
288
+ if lane_dir.exists() and lane_dir.is_dir():
289
+ real_contents = self._get_real_contents(lane_dir)
290
+ if not real_contents:
291
+ changes.append(f" Would remove empty: {lane}/")
292
+
293
+ return changes, warnings, errors, migrated, skipped
294
+
295
+ def _ensure_lane_in_frontmatter(self, content: str, expected_lane: str) -> str:
296
+ """Ensure frontmatter has correct lane field."""
297
+ # Find frontmatter boundaries
298
+ if not content.startswith("---"):
299
+ # No frontmatter, add it
300
+ return f'---\nlane: "{expected_lane}"\n---\n{content}'
301
+
302
+ # Find closing ---
303
+ lines = content.split("\n")
304
+ closing_idx = -1
305
+ for i, line in enumerate(lines[1:], start=1):
306
+ if line.strip() == "---":
307
+ closing_idx = i
308
+ break
309
+
310
+ if closing_idx == -1:
311
+ # Malformed frontmatter, add lane anyway
312
+ return f'---\nlane: "{expected_lane}"\n---\n{content}'
313
+
314
+ frontmatter_lines = lines[1:closing_idx]
315
+ body_lines = lines[closing_idx + 1:]
316
+
317
+ # Check if lane field exists
318
+ lane_pattern = re.compile(r'^lane:\s*(.*)$')
319
+ lane_found = False
320
+ updated_lines = []
321
+
322
+ for line in frontmatter_lines:
323
+ match = lane_pattern.match(line)
324
+ if match:
325
+ lane_found = True
326
+ current_value = match.group(1).strip().strip('"\'')
327
+ if current_value != expected_lane:
328
+ # Replace with expected lane from directory
329
+ updated_lines.append(f'lane: "{expected_lane}"')
330
+ else:
331
+ updated_lines.append(line)
332
+ else:
333
+ updated_lines.append(line)
334
+
335
+ if not lane_found:
336
+ # Insert lane field (before history: if present, otherwise at end)
337
+ insert_idx = len(updated_lines)
338
+ for i, line in enumerate(updated_lines):
339
+ if line.startswith("history:"):
340
+ insert_idx = i
341
+ break
342
+ updated_lines.insert(insert_idx, f'lane: "{expected_lane}"')
343
+
344
+ # Reconstruct document
345
+ result_lines = ["---"] + updated_lines + ["---"] + body_lines
346
+ return "\n".join(result_lines)