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,13 @@
1
+ {
2
+ "chat.promptFilesRecommendations": {
3
+ "spec-kitty.constitution": true,
4
+ "spec-kitty.specify": true,
5
+ "spec-kitty.plan": true,
6
+ "spec-kitty.tasks": true,
7
+ "spec-kitty.implement": true
8
+ },
9
+ "chat.tools.terminal.autoApprove": {
10
+ ".kittify/scripts/bash/": true,
11
+ ".kittify/scripts/ps/": true
12
+ }
13
+ }
@@ -0,0 +1,225 @@
1
+ """Text sanitization utilities for preventing encoding errors.
2
+
3
+ This module provides utilities to normalize Windows-1252 smart quotes and other
4
+ problematic characters that can cause UTF-8 encoding errors in markdown files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ __all__ = [
14
+ "sanitize_markdown_text",
15
+ "sanitize_file",
16
+ "detect_problematic_characters",
17
+ "PROBLEMATIC_CHARS",
18
+ ]
19
+
20
+ # Map of Windows-1252 / problematic characters to safe UTF-8 replacements
21
+ PROBLEMATIC_CHARS = {
22
+ # Smart quotes (Windows-1252 bytes 0x91-0x94)
23
+ "\u2018": "'", # LEFT SINGLE QUOTATION MARK → apostrophe
24
+ "\u2019": "'", # RIGHT SINGLE QUOTATION MARK → apostrophe
25
+ "\u201c": '"', # LEFT DOUBLE QUOTATION MARK → straight quote
26
+ "\u201d": '"', # RIGHT DOUBLE QUOTATION MARK → straight quote
27
+ # Em/en dashes
28
+ "\u2013": "--", # EN DASH → double hyphen
29
+ "\u2014": "---", # EM DASH → triple hyphen
30
+ # Mathematical operators that may come from cp1252
31
+ "\u00b1": "+/-", # PLUS-MINUS SIGN → +/-
32
+ "\u00d7": "x", # MULTIPLICATION SIGN → x
33
+ "\u00f7": "/", # DIVISION SIGN → /
34
+ # Ellipsis
35
+ "\u2026": "...", # HORIZONTAL ELLIPSIS → three periods
36
+ # Bullets
37
+ "\u2022": "*", # BULLET → asterisk
38
+ "\u2023": ">", # TRIANGULAR BULLET → greater than
39
+ # Degree symbol (often problematic)
40
+ "\u00b0": " degrees", # DEGREE SIGN → " degrees"
41
+ # Non-breaking space (invisible but causes issues)
42
+ "\u00a0": " ", # NO-BREAK SPACE → regular space
43
+ # Trademark/copyright symbols
44
+ "\u2122": "(TM)", # TRADE MARK SIGN
45
+ "\u00a9": "(C)", # COPYRIGHT SIGN
46
+ "\u00ae": "(R)", # REGISTERED SIGN
47
+ }
48
+
49
+ # Compile regex for detecting any problematic character
50
+ _PROBLEMATIC_PATTERN = re.compile(
51
+ "[" + "".join(re.escape(char) for char in PROBLEMATIC_CHARS.keys()) + "]"
52
+ )
53
+
54
+
55
+ def sanitize_markdown_text(text: str, *, preserve_utf8: bool = False) -> str:
56
+ """Sanitize markdown text by replacing problematic characters.
57
+
58
+ Args:
59
+ text: The markdown text to sanitize
60
+ preserve_utf8: If True, only replace characters that cause encoding issues.
61
+ If False (default), replace all problematic characters for
62
+ maximum compatibility.
63
+
64
+ Returns:
65
+ Sanitized text with problematic characters replaced
66
+
67
+ Examples:
68
+ >>> sanitize_markdown_text("User's "favorite" feature")
69
+ 'User\\'s "favorite" feature'
70
+
71
+ >>> sanitize_markdown_text("Price: $100 ± $10")
72
+ 'Price: $100 +/- $10'
73
+
74
+ >>> sanitize_markdown_text("Temperature: 72° outside")
75
+ 'Temperature: 72 degrees outside'
76
+ """
77
+ if not text:
78
+ return text
79
+
80
+ # Replace each problematic character with its safe equivalent
81
+ result = text
82
+ for problematic, replacement in PROBLEMATIC_CHARS.items():
83
+ if problematic in result:
84
+ result = result.replace(problematic, replacement)
85
+
86
+ return result
87
+
88
+
89
+ def detect_problematic_characters(
90
+ text: str,
91
+ ) -> list[tuple[int, int, str, str]]:
92
+ """Detect problematic characters in text and return their locations.
93
+
94
+ Args:
95
+ text: The text to check
96
+
97
+ Returns:
98
+ List of tuples: (line_number, column, character, suggested_replacement)
99
+ Line numbers are 1-indexed, columns are 0-indexed.
100
+
101
+ Examples:
102
+ >>> text = "Line 1\\nUser's "test"\\nLine 3"
103
+ >>> issues = detect_problematic_characters(text)
104
+ >>> len(issues)
105
+ 3
106
+ >>> issues[0]
107
+ (2, 4, '\u2019', "'")
108
+ """
109
+ issues: list[tuple[int, int, str, str]] = []
110
+
111
+ lines = text.splitlines(keepends=True)
112
+ for line_num, line in enumerate(lines, start=1):
113
+ for match in _PROBLEMATIC_PATTERN.finditer(line):
114
+ char = match.group(0)
115
+ replacement = PROBLEMATIC_CHARS.get(char, "?")
116
+ issues.append((line_num, match.start(), char, replacement))
117
+
118
+ return issues
119
+
120
+
121
+ def sanitize_file(
122
+ file_path: Path,
123
+ *,
124
+ backup: bool = True,
125
+ dry_run: bool = False,
126
+ ) -> tuple[bool, Optional[str]]:
127
+ """Sanitize a markdown file in place.
128
+
129
+ Args:
130
+ file_path: Path to the markdown file to sanitize
131
+ backup: If True, create a .bak file before modifying
132
+ dry_run: If True, only check and report, don't modify
133
+
134
+ Returns:
135
+ Tuple of (was_modified, error_message)
136
+ - was_modified: True if the file had problematic characters
137
+ - error_message: None if successful, error message if failed
138
+
139
+ Examples:
140
+ >>> from pathlib import Path
141
+ >>> from tempfile import NamedTemporaryFile
142
+ >>> with NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
143
+ ... f.write('User's "test"')
144
+ ... tmp_path = Path(f.name)
145
+ >>> modified, error = sanitize_file(tmp_path, backup=False)
146
+ >>> modified
147
+ True
148
+ >>> tmp_path.read_text()
149
+ 'User\\'s "test"'
150
+ >>> tmp_path.unlink() # cleanup
151
+ """
152
+ if not file_path.exists():
153
+ return False, f"File not found: {file_path}"
154
+
155
+ try:
156
+ # Try reading as UTF-8 first
157
+ try:
158
+ original_text = file_path.read_text(encoding="utf-8-sig")
159
+ encoding_issue = False
160
+ except UnicodeDecodeError:
161
+ # Fall back to cp1252 or latin-1
162
+ encoding_issue = True
163
+ original_bytes = file_path.read_bytes()
164
+ for encoding in ("cp1252", "latin-1"):
165
+ try:
166
+ original_text = original_bytes.decode(encoding)
167
+ break
168
+ except UnicodeDecodeError:
169
+ continue
170
+ else:
171
+ # Last resort: replace invalid characters
172
+ original_text = original_bytes.decode("utf-8", errors="replace")
173
+
174
+ # Strip UTF-8 BOM if present in the text
175
+ original_text = original_text.lstrip('\ufeff')
176
+
177
+ # Sanitize the text
178
+ sanitized_text = sanitize_markdown_text(original_text)
179
+
180
+ # Check if any changes were made
181
+ if sanitized_text == original_text and not encoding_issue:
182
+ return False, None # No changes needed
183
+
184
+ if dry_run:
185
+ return True, None # Would modify but dry run
186
+
187
+ # Create backup if requested
188
+ if backup:
189
+ backup_path = file_path.with_suffix(file_path.suffix + ".bak")
190
+ backup_path.write_bytes(file_path.read_bytes())
191
+
192
+ # Write sanitized content
193
+ file_path.write_text(sanitized_text, encoding="utf-8")
194
+ return True, None
195
+
196
+ except Exception as exc:
197
+ return False, f"Error sanitizing {file_path}: {exc}"
198
+
199
+
200
+ def sanitize_directory(
201
+ directory: Path,
202
+ *,
203
+ pattern: str = "**/*.md",
204
+ backup: bool = False,
205
+ dry_run: bool = False,
206
+ ) -> dict[str, tuple[bool, Optional[str]]]:
207
+ """Sanitize all markdown files in a directory.
208
+
209
+ Args:
210
+ directory: Directory to scan
211
+ pattern: Glob pattern for files to sanitize (default: **/*.md)
212
+ backup: If True, create .bak files before modifying
213
+ dry_run: If True, only check and report, don't modify
214
+
215
+ Returns:
216
+ Dictionary mapping file paths to (was_modified, error_message) tuples
217
+ """
218
+ results: dict[str, tuple[bool, Optional[str]]] = {}
219
+
220
+ for file_path in directory.glob(pattern):
221
+ if file_path.is_file():
222
+ result = sanitize_file(file_path, backup=backup, dry_run=dry_run)
223
+ results[str(file_path)] = result
224
+
225
+ return results
@@ -0,0 +1,18 @@
1
+ """Spec Kitty upgrade system for migrating projects between versions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .metadata import MigrationRecord, ProjectMetadata
6
+ from .detector import VersionDetector
7
+ from .registry import MigrationRegistry, register
8
+ from .runner import MigrationRunner, UpgradeResult
9
+
10
+ __all__ = [
11
+ "MigrationRecord",
12
+ "ProjectMetadata",
13
+ "VersionDetector",
14
+ "MigrationRegistry",
15
+ "MigrationRunner",
16
+ "UpgradeResult",
17
+ "register",
18
+ ]
@@ -0,0 +1,239 @@
1
+ """Version detection for Spec Kitty projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ from .metadata import ProjectMetadata
9
+
10
+
11
+ class VersionDetector:
12
+ """Detects project version through heuristics when metadata is missing."""
13
+
14
+ # Agent directories that should be in .gitignore (v0.4.8+)
15
+ EXPECTED_AGENTS = [
16
+ ".claude/",
17
+ ".codex/",
18
+ ".opencode/",
19
+ ".windsurf/",
20
+ ".gemini/",
21
+ ".cursor/",
22
+ ".qwen/",
23
+ ".kilocode/",
24
+ ".augment/",
25
+ ".roo/",
26
+ ".amazonq/",
27
+ ".github/copilot/",
28
+ ]
29
+
30
+ def __init__(self, project_path: Path):
31
+ """Initialize the detector.
32
+
33
+ Args:
34
+ project_path: Root of the project
35
+ """
36
+ self.project_path = project_path
37
+ self.kittify_dir = project_path / ".kittify"
38
+ self.specify_dir = project_path / ".specify" # Old name
39
+
40
+ def detect_version(self) -> str:
41
+ """Detect the approximate version of a project.
42
+
43
+ Returns:
44
+ A version string like "0.1.0" (oldest detectable)
45
+ or "0.6.7" (current).
46
+ """
47
+ # First try to load from metadata
48
+ if self.kittify_dir.exists():
49
+ metadata = ProjectMetadata.load(self.kittify_dir)
50
+ if metadata:
51
+ return metadata.version
52
+
53
+ # Heuristic detection based on directory structure
54
+ return self._detect_from_structure()
55
+
56
+ def _detect_from_structure(self) -> str:
57
+ """Detect version from project structure."""
58
+ # v0.1.x: Uses .specify/ directory and /specs/
59
+ if self.specify_dir.exists():
60
+ return "0.1.0"
61
+
62
+ # No .kittify at all - not initialized or very old
63
+ if not self.kittify_dir.exists():
64
+ return "0.0.0"
65
+
66
+ # Check for command-templates vs commands directory
67
+ # v0.6.5+ uses command-templates/
68
+ templates_dir = self.kittify_dir / "templates"
69
+ missions_dir = self.kittify_dir / "missions"
70
+
71
+ # Check templates location
72
+ has_command_templates = (templates_dir / "command-templates").exists()
73
+ has_old_commands = (templates_dir / "commands").exists()
74
+
75
+ # Check missions for command-templates
76
+ has_mission_command_templates = False
77
+ has_mission_commands = False
78
+ if missions_dir.exists():
79
+ for mission in missions_dir.iterdir():
80
+ if mission.is_dir():
81
+ if (mission / "command-templates").exists():
82
+ has_mission_command_templates = True
83
+ if (mission / "commands").exists():
84
+ has_mission_commands = True
85
+
86
+ # v0.6.5+: Has command-templates (not commands)
87
+ if has_command_templates or has_mission_command_templates:
88
+ if not has_old_commands and not has_mission_commands:
89
+ return "0.6.5"
90
+
91
+ # v0.6.4 and earlier: Has old commands/ directory
92
+ if has_old_commands or has_mission_commands:
93
+ return "0.6.4"
94
+
95
+ # Check for git hooks (v0.5.0+)
96
+ git_hooks = self.project_path / ".git" / "hooks"
97
+ if git_hooks.exists() and (git_hooks / "pre-commit").exists():
98
+ try:
99
+ hook_content = (git_hooks / "pre-commit").read_text(
100
+ encoding="utf-8", errors="ignore"
101
+ )
102
+ if "spec-kitty" in hook_content.lower() or "encoding" in hook_content.lower():
103
+ return "0.5.0"
104
+ except OSError:
105
+ pass
106
+
107
+ # Check .gitignore for agent directories (v0.4.8+)
108
+ gitignore = self.project_path / ".gitignore"
109
+ if gitignore.exists():
110
+ try:
111
+ content = gitignore.read_text(encoding="utf-8-sig", errors="ignore")
112
+ agent_dirs = [".claude/", ".codex/", ".gemini/", ".cursor/"]
113
+ agent_count = sum(1 for d in agent_dirs if d in content)
114
+ if agent_count >= 4:
115
+ return "0.4.8"
116
+ except OSError:
117
+ pass
118
+
119
+ # Check for missions directory (v0.2.0+)
120
+ if missions_dir.exists():
121
+ return "0.2.0"
122
+
123
+ # Default to oldest .kittify-based version
124
+ return "0.2.0"
125
+
126
+ def get_needed_migrations(self, target_version: str) -> List[str]:
127
+ """Get list of migration IDs needed to reach target version.
128
+
129
+ Args:
130
+ target_version: Version to upgrade to
131
+
132
+ Returns:
133
+ List of migration IDs in order
134
+ """
135
+ from .registry import MigrationRegistry
136
+
137
+ current = self.detect_version()
138
+ migrations = MigrationRegistry.get_applicable(current, target_version)
139
+ return [m.migration_id for m in migrations]
140
+
141
+ def has_old_commands_structure(self) -> bool:
142
+ """Check if the project uses old commands/ directories.
143
+
144
+ Returns:
145
+ True if old commands/ directories exist
146
+ """
147
+ templates_commands = self.kittify_dir / "templates" / "commands"
148
+ if templates_commands.exists():
149
+ return True
150
+
151
+ missions_dir = self.kittify_dir / "missions"
152
+ if missions_dir.exists():
153
+ for mission in missions_dir.iterdir():
154
+ if mission.is_dir() and (mission / "commands").exists():
155
+ return True
156
+
157
+ return False
158
+
159
+ def has_old_specify_structure(self) -> bool:
160
+ """Check if the project uses old .specify/ structure.
161
+
162
+ Returns:
163
+ True if .specify/ directory exists
164
+ """
165
+ return self.specify_dir.exists()
166
+
167
+ def count_missing_agent_gitignore_entries(self) -> int:
168
+ """Count how many agent directories are missing from .gitignore.
169
+
170
+ Returns:
171
+ Number of missing entries
172
+ """
173
+ gitignore = self.project_path / ".gitignore"
174
+ if not gitignore.exists():
175
+ return len(self.EXPECTED_AGENTS)
176
+
177
+ try:
178
+ content = gitignore.read_text(encoding="utf-8-sig", errors="ignore")
179
+ except OSError:
180
+ return len(self.EXPECTED_AGENTS)
181
+
182
+ missing = [d for d in self.EXPECTED_AGENTS if d not in content]
183
+ return len(missing)
184
+
185
+ @classmethod
186
+ def detect_broken_mission_system(cls, project_path: Path) -> bool:
187
+ """Detect if the mission system has corrupted files.
188
+
189
+ Checks for:
190
+ 1. Missing mission.yaml files in mission directories
191
+ 2. Invalid YAML syntax in mission.yaml files
192
+ 3. Missing required fields (name)
193
+
194
+ Args:
195
+ project_path: Path to the project root
196
+
197
+ Returns:
198
+ True if mission system is broken/corrupted, False if healthy
199
+ """
200
+ import yaml
201
+
202
+ missions_dir = project_path / ".kittify" / "missions"
203
+
204
+ # No missions directory at all is broken
205
+ if not missions_dir.exists():
206
+ return True
207
+
208
+ # Check each mission directory
209
+ has_any_mission = False
210
+ for mission_dir in missions_dir.iterdir():
211
+ if not mission_dir.is_dir():
212
+ continue
213
+
214
+ has_any_mission = True
215
+ mission_yaml = mission_dir / "mission.yaml"
216
+
217
+ # Check if mission.yaml exists
218
+ if not mission_yaml.exists():
219
+ return True
220
+
221
+ # Check if mission.yaml is valid YAML with required fields
222
+ try:
223
+ with open(mission_yaml, encoding="utf-8") as f:
224
+ data = yaml.safe_load(f)
225
+
226
+ # Check required fields
227
+ if not data or "name" not in data:
228
+ return True
229
+
230
+ except yaml.YAMLError:
231
+ return True
232
+ except OSError:
233
+ return True
234
+
235
+ # If no mission directories found, that's broken
236
+ if not has_any_mission:
237
+ return True
238
+
239
+ return False
@@ -0,0 +1,182 @@
1
+ """Project metadata management for Spec Kitty upgrade system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import yaml
11
+
12
+
13
+ @dataclass
14
+ class MigrationRecord:
15
+ """Record of a single migration application."""
16
+
17
+ id: str
18
+ applied_at: datetime
19
+ result: str # "success", "skipped", "failed"
20
+ notes: Optional[str] = None
21
+
22
+
23
+ @dataclass
24
+ class ProjectMetadata:
25
+ """Metadata for a Spec Kitty project stored in .kittify/metadata.yaml."""
26
+
27
+ version: str
28
+ initialized_at: datetime
29
+ last_upgraded_at: Optional[datetime] = None
30
+ python_version: str = ""
31
+ platform: str = ""
32
+ platform_version: str = ""
33
+ applied_migrations: List[MigrationRecord] = field(default_factory=list)
34
+
35
+ @classmethod
36
+ def load(cls, kittify_dir: Path) -> Optional["ProjectMetadata"]:
37
+ """Load metadata from .kittify/metadata.yaml.
38
+
39
+ Args:
40
+ kittify_dir: Path to the .kittify directory
41
+
42
+ Returns:
43
+ ProjectMetadata if file exists, None otherwise
44
+ """
45
+ metadata_path = kittify_dir / "metadata.yaml"
46
+ if not metadata_path.exists():
47
+ return None
48
+
49
+ try:
50
+ with open(metadata_path, "r", encoding="utf-8-sig") as f:
51
+ data = yaml.safe_load(f)
52
+ except (OSError, yaml.YAMLError):
53
+ return None
54
+
55
+ if not data:
56
+ return None
57
+
58
+ spec_kitty = data.get("spec_kitty", {})
59
+ env = data.get("environment", {})
60
+ migrations_data = data.get("migrations", {}).get("applied", [])
61
+
62
+ applied = []
63
+ for m in migrations_data:
64
+ try:
65
+ applied.append(
66
+ MigrationRecord(
67
+ id=m["id"],
68
+ applied_at=datetime.fromisoformat(m["applied_at"]),
69
+ result=m["result"],
70
+ notes=m.get("notes"),
71
+ )
72
+ )
73
+ except (KeyError, ValueError):
74
+ # Skip malformed migration records
75
+ continue
76
+
77
+ initialized_at_str = spec_kitty.get("initialized_at")
78
+ try:
79
+ initialized_at = (
80
+ datetime.fromisoformat(initialized_at_str)
81
+ if initialized_at_str
82
+ else datetime.now()
83
+ )
84
+ except ValueError:
85
+ initialized_at = datetime.now()
86
+
87
+ last_upgraded_str = spec_kitty.get("last_upgraded_at")
88
+ try:
89
+ last_upgraded_at = (
90
+ datetime.fromisoformat(last_upgraded_str) if last_upgraded_str else None
91
+ )
92
+ except ValueError:
93
+ last_upgraded_at = None
94
+
95
+ return cls(
96
+ version=spec_kitty.get("version", "unknown"),
97
+ initialized_at=initialized_at,
98
+ last_upgraded_at=last_upgraded_at,
99
+ python_version=env.get("python_version", ""),
100
+ platform=env.get("platform", ""),
101
+ platform_version=env.get("platform_version", ""),
102
+ applied_migrations=applied,
103
+ )
104
+
105
+ def save(self, kittify_dir: Path) -> None:
106
+ """Save metadata to .kittify/metadata.yaml.
107
+
108
+ Args:
109
+ kittify_dir: Path to the .kittify directory
110
+ """
111
+ metadata_path = kittify_dir / "metadata.yaml"
112
+ kittify_dir.mkdir(parents=True, exist_ok=True)
113
+
114
+ data = {
115
+ "spec_kitty": {
116
+ "version": self.version,
117
+ "initialized_at": self.initialized_at.isoformat(),
118
+ "last_upgraded_at": (
119
+ self.last_upgraded_at.isoformat() if self.last_upgraded_at else None
120
+ ),
121
+ },
122
+ "environment": {
123
+ "python_version": self.python_version,
124
+ "platform": self.platform,
125
+ "platform_version": self.platform_version,
126
+ },
127
+ "migrations": {
128
+ "applied": [
129
+ {
130
+ "id": m.id,
131
+ "applied_at": m.applied_at.isoformat(),
132
+ "result": m.result,
133
+ "notes": m.notes,
134
+ }
135
+ for m in self.applied_migrations
136
+ ]
137
+ },
138
+ }
139
+
140
+ # Add header comment
141
+ header = (
142
+ "# Spec Kitty Project Metadata\n"
143
+ "# Auto-generated by spec-kitty init/upgrade\n"
144
+ "# DO NOT EDIT MANUALLY\n\n"
145
+ )
146
+
147
+ with open(metadata_path, "w", encoding="utf-8") as f:
148
+ f.write(header)
149
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
150
+
151
+ def has_migration(self, migration_id: str) -> bool:
152
+ """Check if a migration has been successfully applied.
153
+
154
+ Args:
155
+ migration_id: The ID of the migration to check
156
+
157
+ Returns:
158
+ True if migration was applied successfully
159
+ """
160
+ return any(
161
+ m.id == migration_id and m.result == "success"
162
+ for m in self.applied_migrations
163
+ )
164
+
165
+ def record_migration(
166
+ self, migration_id: str, result: str, notes: Optional[str] = None
167
+ ) -> None:
168
+ """Record a migration application.
169
+
170
+ Args:
171
+ migration_id: The ID of the migration
172
+ result: The result ("success", "skipped", "failed")
173
+ notes: Optional notes about the migration
174
+ """
175
+ self.applied_migrations.append(
176
+ MigrationRecord(
177
+ id=migration_id,
178
+ applied_at=datetime.now(),
179
+ result=result,
180
+ notes=notes,
181
+ )
182
+ )