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,300 @@
1
+ """
2
+ GitignoreManager module for protecting AI agent directories.
3
+
4
+ This module provides a centralized system for managing .gitignore entries
5
+ to protect AI agent directories from being accidentally committed to git.
6
+ It replaces the fragmented approach where only .codex/ was protected.
7
+ """
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import List, Optional, Set
13
+
14
+
15
+ @dataclass
16
+ class AgentDirectory:
17
+ """Represents a single agent's directory that needs protection."""
18
+
19
+ name: str
20
+ """Agent name identifier (e.g., 'claude', 'codex')"""
21
+
22
+ directory: str
23
+ """Directory path with trailing slash (e.g., '.claude/')"""
24
+
25
+ is_special: bool
26
+ """Indicates if special handling is needed (e.g., .github/)"""
27
+
28
+ description: str
29
+ """Human-readable description for documentation"""
30
+
31
+
32
+ @dataclass
33
+ class ProtectionResult:
34
+ """Result of a gitignore protection operation."""
35
+
36
+ success: bool
37
+ """Whether the operation succeeded"""
38
+
39
+ modified: bool
40
+ """Whether .gitignore was modified"""
41
+
42
+ entries_added: List[str] = field(default_factory=list)
43
+ """New entries added to .gitignore"""
44
+
45
+ entries_skipped: List[str] = field(default_factory=list)
46
+ """Entries already present in .gitignore"""
47
+
48
+ errors: List[str] = field(default_factory=list)
49
+ """Error messages if any occurred"""
50
+
51
+ warnings: List[str] = field(default_factory=list)
52
+ """Warning messages if any were generated"""
53
+
54
+
55
+ # Registry of all known AI agent directories
56
+ AGENT_DIRECTORIES = [
57
+ AgentDirectory("claude", ".claude/", False, "Claude Code CLI"),
58
+ AgentDirectory("codex", ".codex/", False, "Codex (contains auth.json)"),
59
+ AgentDirectory("opencode", ".opencode/", False, "opencode CLI"),
60
+ AgentDirectory("windsurf", ".windsurf/", False, "Windsurf"),
61
+ AgentDirectory("gemini", ".gemini/", False, "Google Gemini"),
62
+ AgentDirectory("cursor", ".cursor/", False, "Cursor"),
63
+ AgentDirectory("qwen", ".qwen/", False, "Qwen"),
64
+ AgentDirectory("kilocode", ".kilocode/", False, "Kilocode"),
65
+ AgentDirectory("auggie", ".augment/", False, "Auggie"),
66
+ AgentDirectory("roo", ".roo/", False, "Roo Coder"),
67
+ AgentDirectory("amazonq", ".amazonq/", False, "Amazon Q"),
68
+ AgentDirectory("copilot", ".github/copilot/", True, "GitHub Copilot (user settings)"),
69
+ ]
70
+
71
+
72
+ class GitignoreManager:
73
+ """Manages gitignore entries for AI agent directories."""
74
+
75
+ def __init__(self, project_path: Path):
76
+ """
77
+ Initialize GitignoreManager with project root path.
78
+
79
+ Args:
80
+ project_path: Root directory of the project
81
+
82
+ Raises:
83
+ ValueError: If project_path doesn't exist or isn't a directory
84
+ """
85
+ if not isinstance(project_path, Path):
86
+ project_path = Path(project_path)
87
+
88
+ if not project_path.exists():
89
+ raise ValueError(f"Project path does not exist: {project_path}")
90
+
91
+ if not project_path.is_dir():
92
+ raise ValueError(f"Project path is not a directory: {project_path}")
93
+
94
+ self.project_path = project_path
95
+ self.gitignore_path = project_path / ".gitignore"
96
+ self.marker = "# Added by Spec Kitty CLI (auto-managed)"
97
+ self._line_ending = None
98
+
99
+ def ensure_entries(self, entries: List[str]) -> bool:
100
+ """
101
+ Core method to add entries to .gitignore.
102
+
103
+ This method migrates the logic from the original ensure_gitignore_entries
104
+ function, maintaining the same behavior for compatibility.
105
+
106
+ Args:
107
+ entries: List of gitignore patterns to add
108
+
109
+ Returns:
110
+ True if .gitignore was modified, False otherwise
111
+ """
112
+ if not entries:
113
+ return False
114
+
115
+ # Read existing content or start with empty list
116
+ if self.gitignore_path.exists():
117
+ content = self.gitignore_path.read_text(encoding="utf-8-sig")
118
+ # Detect and store line ending style
119
+ self._line_ending = self._detect_line_ending(content)
120
+ lines = content.splitlines()
121
+ else:
122
+ lines = []
123
+ # Use system default for new files
124
+ self._line_ending = os.linesep
125
+
126
+ existing = set(lines)
127
+ changed = False
128
+
129
+ # Check if any entry needs to be added
130
+ if any(entry not in existing for entry in entries):
131
+ # Add marker if not present
132
+ if self.marker not in existing:
133
+ if lines and lines[-1].strip():
134
+ lines.append("") # Add blank line before marker
135
+ lines.append(self.marker)
136
+ existing.add(self.marker)
137
+ changed = True
138
+
139
+ # Add missing entries
140
+ for entry in entries:
141
+ if entry not in existing:
142
+ lines.append(entry)
143
+ existing.add(entry)
144
+ changed = True
145
+
146
+ # Write back if changed
147
+ if changed:
148
+ # Ensure file ends with newline
149
+ if lines and lines[-1] != "":
150
+ lines.append("")
151
+
152
+ # Join with detected line ending
153
+ content = self._line_ending.join(lines)
154
+ self.gitignore_path.write_text(content, encoding="utf-8")
155
+
156
+ return changed
157
+
158
+ def _detect_line_ending(self, content: str) -> str:
159
+ """
160
+ Detect and return the line ending style used in content.
161
+
162
+ Args:
163
+ content: File content to analyze
164
+
165
+ Returns:
166
+ Line ending string ('\r\n' for Windows, '\n' for Unix/Mac)
167
+ """
168
+ if '\r\n' in content:
169
+ return '\r\n'
170
+ else:
171
+ return '\n'
172
+
173
+ @classmethod
174
+ def get_agent_directories(cls) -> List[AgentDirectory]:
175
+ """
176
+ Get a copy of the registry of all known agent directories.
177
+
178
+ Returns:
179
+ List of AgentDirectory objects representing all known agents
180
+ """
181
+ # Return a copy to prevent external modification
182
+ return AGENT_DIRECTORIES.copy()
183
+
184
+ def protect_all_agents(self) -> ProtectionResult:
185
+ """
186
+ Add all known agent directories to .gitignore.
187
+
188
+ This is the primary method used during spec-kitty init to ensure
189
+ comprehensive protection of all AI agent directories.
190
+
191
+ Also protects runtime files like .kittify/.dashboard.
192
+
193
+ Returns:
194
+ ProtectionResult containing details of the operation
195
+ """
196
+ result = ProtectionResult(success=True, modified=False)
197
+
198
+ try:
199
+ # Get all agent directories
200
+ all_directories = [agent.directory for agent in AGENT_DIRECTORIES]
201
+
202
+ # Add runtime files that should never be tracked
203
+ all_directories.append(".kittify/.dashboard")
204
+
205
+ # Track existing entries before modification
206
+ existing_before = set()
207
+ if self.gitignore_path.exists():
208
+ content = self.gitignore_path.read_text(encoding="utf-8-sig")
209
+ existing_before = set(content.splitlines())
210
+
211
+
212
+ # Attempt to add all directories
213
+ modified = self.ensure_entries(all_directories)
214
+ result.modified = modified
215
+
216
+ # Track what was added vs skipped
217
+ if self.gitignore_path.exists():
218
+ content = self.gitignore_path.read_text(encoding="utf-8-sig")
219
+ existing_after = set(content.splitlines())
220
+
221
+ for directory in all_directories:
222
+ if directory in existing_after:
223
+ if directory not in existing_before:
224
+ result.entries_added.append(directory)
225
+ else:
226
+ result.entries_skipped.append(directory)
227
+
228
+ except PermissionError as e:
229
+ result.success = False
230
+ result.errors.append(
231
+ f"Cannot update .gitignore: Permission denied. Run: chmod u+w {self.gitignore_path}"
232
+ )
233
+ except Exception as e:
234
+ result.success = False
235
+ result.errors.append(f"Error protecting agent directories: {str(e)}")
236
+
237
+ return result
238
+
239
+ def protect_selected_agents(self, agents: List[str]) -> ProtectionResult:
240
+ """
241
+ Add specific agent directories to .gitignore based on selection.
242
+
243
+ Args:
244
+ agents: List of agent names (e.g., ['claude', 'codex', 'opencode'])
245
+
246
+ Returns:
247
+ ProtectionResult containing details of the operation
248
+ """
249
+ result = ProtectionResult(success=True, modified=False)
250
+
251
+ try:
252
+ # Build mapping of agent names to directories
253
+ agent_map = {agent.name: agent for agent in AGENT_DIRECTORIES}
254
+
255
+ # Collect directories for selected agents
256
+ directories_to_add = []
257
+ for agent_name in agents:
258
+ if agent_name in agent_map:
259
+ agent = agent_map[agent_name]
260
+ directories_to_add.append(agent.directory)
261
+
262
+ else:
263
+ result.warnings.append(f"Unknown agent name: {agent_name}")
264
+
265
+ if not directories_to_add:
266
+ result.warnings.append("No valid agent directories to add")
267
+ return result
268
+
269
+ # Track existing entries before modification
270
+ existing_before = set()
271
+ if self.gitignore_path.exists():
272
+ content = self.gitignore_path.read_text(encoding="utf-8-sig")
273
+ existing_before = set(content.splitlines())
274
+
275
+ # Attempt to add selected directories
276
+ modified = self.ensure_entries(directories_to_add)
277
+ result.modified = modified
278
+
279
+ # Track what was added vs skipped
280
+ if self.gitignore_path.exists():
281
+ content = self.gitignore_path.read_text(encoding="utf-8-sig")
282
+ existing_after = set(content.splitlines())
283
+
284
+ for directory in directories_to_add:
285
+ if directory in existing_after:
286
+ if directory not in existing_before:
287
+ result.entries_added.append(directory)
288
+ else:
289
+ result.entries_skipped.append(directory)
290
+
291
+ except PermissionError as e:
292
+ result.success = False
293
+ result.errors.append(
294
+ f"Cannot update .gitignore: Permission denied. Run: chmod u+w {self.gitignore_path}"
295
+ )
296
+ except Exception as e:
297
+ result.success = False
298
+ result.errors.append(f"Error protecting selected agents: {str(e)}")
299
+
300
+ return result
specify_cli/guards.py ADDED
@@ -0,0 +1,145 @@
1
+ """Shared pre-flight validation utilities for Spec Kitty CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+ import re
9
+ import subprocess
10
+
11
+
12
+ class GuardValidationError(Exception):
13
+ """Raised when pre-flight validation fails."""
14
+
15
+
16
+ @dataclass
17
+ class WorktreeValidationResult:
18
+ """Result of worktree location validation."""
19
+
20
+ current_branch: str
21
+ is_feature_branch: bool
22
+ is_main_branch: bool
23
+ worktree_path: Optional[Path]
24
+ errors: List[str]
25
+
26
+ @property
27
+ def is_valid(self) -> bool:
28
+ """Return True when validation passed."""
29
+ if self.errors:
30
+ return False
31
+ if not self.current_branch:
32
+ return True
33
+ return self.is_feature_branch and not self.is_main_branch
34
+
35
+ def format_error(self) -> str:
36
+ """Format error message for display."""
37
+ if not self.errors:
38
+ return ""
39
+
40
+ output = ["Location Pre-flight Check Failed:", ""]
41
+ for error in self.errors:
42
+ output.append(f" {error}")
43
+
44
+ if self.is_main_branch:
45
+ output.extend(
46
+ [
47
+ "",
48
+ "You are on the 'main' branch. Commands must run from feature worktrees.",
49
+ "",
50
+ "Available worktrees:",
51
+ " $ ls .worktrees/",
52
+ "",
53
+ "Navigate to worktree:",
54
+ " $ cd .worktrees/<feature-name>",
55
+ "",
56
+ "Verify branch:",
57
+ " $ git branch --show-current",
58
+ ]
59
+ )
60
+
61
+ return "\n".join(output)
62
+
63
+
64
+ def validate_worktree_location(project_root: Optional[Path] = None) -> WorktreeValidationResult:
65
+ """Validate that commands run from a feature worktree."""
66
+ project_root = Path(project_root) if project_root is not None else Path.cwd()
67
+
68
+ try:
69
+ result = subprocess.run(
70
+ ["git", "branch", "--show-current"],
71
+ capture_output=True,
72
+ text=True,
73
+ cwd=project_root,
74
+ check=False,
75
+ )
76
+ except FileNotFoundError as exc:
77
+ raise GuardValidationError("git executable not found") from exc
78
+
79
+ if result.returncode != 0:
80
+ return WorktreeValidationResult(
81
+ current_branch="unknown",
82
+ is_feature_branch=False,
83
+ is_main_branch=False,
84
+ worktree_path=None,
85
+ errors=["Not a git repository"],
86
+ )
87
+
88
+ current_branch = result.stdout.strip()
89
+ is_main_branch = current_branch in {"main", "master"}
90
+ is_feature_branch = bool(re.match(r"^\d{3}-[\w-]+$", current_branch))
91
+
92
+ errors: List[str] = []
93
+ if not current_branch:
94
+ errors.append("Unable to determine current git branch.")
95
+ elif is_main_branch:
96
+ errors.append("Command must run from feature worktree, not main branch.")
97
+ elif not is_feature_branch:
98
+ errors.append(
99
+ f"Unexpected branch '{current_branch}'. Commands must run from feature worktrees."
100
+ )
101
+
102
+ worktree_path = project_root if is_feature_branch and not errors else None
103
+
104
+ return WorktreeValidationResult(
105
+ current_branch=current_branch or "unknown",
106
+ is_feature_branch=is_feature_branch,
107
+ is_main_branch=is_main_branch,
108
+ worktree_path=worktree_path,
109
+ errors=errors,
110
+ )
111
+
112
+
113
+ def validate_git_clean(project_root: Optional[Path] = None) -> WorktreeValidationResult:
114
+ """Validate git repository has no uncommitted changes."""
115
+ project_root = Path(project_root) if project_root is not None else Path.cwd()
116
+
117
+ try:
118
+ result = subprocess.run(
119
+ ["git", "status", "--porcelain"],
120
+ capture_output=True,
121
+ text=True,
122
+ cwd=project_root,
123
+ check=False,
124
+ )
125
+ except FileNotFoundError as exc:
126
+ raise GuardValidationError("git executable not found") from exc
127
+
128
+ errors: List[str] = []
129
+ if result.returncode != 0:
130
+ errors.append("Unable to read git status.")
131
+ else:
132
+ status_lines = [line for line in result.stdout.splitlines() if line.strip()]
133
+ if status_lines:
134
+ errors.append(
135
+ f"Uncommitted changes detected ({len(status_lines)} files). "
136
+ "Commit or stash changes before switching missions."
137
+ )
138
+
139
+ return WorktreeValidationResult(
140
+ current_branch="",
141
+ is_feature_branch=not errors,
142
+ is_main_branch=False,
143
+ worktree_path=None,
144
+ errors=errors,
145
+ )
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python3
2
+ """Legacy format detection for Spec Kitty lane management.
3
+
4
+ This module provides utilities to detect whether a feature uses the old
5
+ directory-based lane structure (tasks/planned/, tasks/doing/, etc.) or
6
+ the new frontmatter-only lane system (flat tasks/ directory).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ # Lane directories that indicate legacy format when they contain .md files
15
+ LEGACY_LANE_DIRS: List[str] = ["planned", "doing", "for_review", "done"]
16
+
17
+
18
+ def is_legacy_format(feature_path: Path) -> bool:
19
+ """Check if feature uses legacy directory-based lanes.
20
+
21
+ A feature is considered to use legacy format if:
22
+ - It has a tasks/ subdirectory
23
+ - Any of the lane subdirectories (planned/, doing/, for_review/, done/)
24
+ exist AND contain at least one .md file
25
+
26
+ Args:
27
+ feature_path: Path to the feature directory (e.g., kitty-specs/007-feature/)
28
+
29
+ Returns:
30
+ True if legacy directory-based lanes detected, False otherwise.
31
+
32
+ Note:
33
+ Empty lane directories (containing only .gitkeep) are NOT considered
34
+ legacy format - only directories with actual .md work package files.
35
+ """
36
+ tasks_dir = feature_path / "tasks"
37
+ if not tasks_dir.exists():
38
+ return False
39
+
40
+ for lane in LEGACY_LANE_DIRS:
41
+ lane_path = tasks_dir / lane
42
+ if lane_path.is_dir():
43
+ # Check if there are any .md files (not just .gitkeep)
44
+ md_files = list(lane_path.glob("*.md"))
45
+ if md_files:
46
+ return True
47
+
48
+ return False
49
+
50
+
51
+ def get_legacy_lane_counts(feature_path: Path) -> dict[str, int]:
52
+ """Get count of work packages in each legacy lane directory.
53
+
54
+ Useful for migration reporting and validation.
55
+
56
+ Args:
57
+ feature_path: Path to the feature directory
58
+
59
+ Returns:
60
+ Dictionary mapping lane names to count of .md files in each.
61
+ Only includes lanes that have files.
62
+ """
63
+ tasks_dir = feature_path / "tasks"
64
+ counts: dict[str, int] = {}
65
+
66
+ if not tasks_dir.exists():
67
+ return counts
68
+
69
+ for lane in LEGACY_LANE_DIRS:
70
+ lane_path = tasks_dir / lane
71
+ if lane_path.is_dir():
72
+ md_files = list(lane_path.glob("*.md"))
73
+ if md_files:
74
+ counts[lane] = len(md_files)
75
+
76
+ return counts
77
+
78
+
79
+ __all__ = [
80
+ "LEGACY_LANE_DIRS",
81
+ "is_legacy_format",
82
+ "get_legacy_lane_counts",
83
+ ]