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,695 @@
1
+ #!/usr/bin/env python3
2
+ """Acceptance workflow utilities without external dependencies."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ import json
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Set, Tuple
13
+
14
+ from task_helpers import (
15
+ LANES,
16
+ TaskCliError,
17
+ WorkPackage,
18
+ activity_entries,
19
+ extract_scalar,
20
+ find_repo_root,
21
+ get_lane_from_frontmatter,
22
+ git_status_lines,
23
+ is_legacy_format,
24
+ run_git,
25
+ split_frontmatter,
26
+ )
27
+
28
+ AcceptanceMode = str # Expected values: "pr", "local", "checklist"
29
+
30
+
31
+ class AcceptanceError(TaskCliError):
32
+ """Raised when acceptance cannot complete due to outstanding issues."""
33
+
34
+
35
+ class ArtifactEncodingError(AcceptanceError):
36
+ """Raised when a project artifact cannot be decoded as UTF-8."""
37
+
38
+ def __init__(self, path: Path, error: UnicodeDecodeError):
39
+ byte = error.object[error.start : error.start + 1]
40
+ byte_display = f"0x{byte[0]:02x}" if byte else "unknown"
41
+ message = (
42
+ f"Invalid UTF-8 encoding in {path}: byte {byte_display} at offset {error.start}. "
43
+ "Run with --normalize-encoding to fix automatically."
44
+ )
45
+ super().__init__(message)
46
+ self.path = path
47
+ self.error = error
48
+
49
+
50
+ @dataclass
51
+ class WorkPackageState:
52
+ work_package_id: str
53
+ lane: str
54
+ title: str
55
+ path: str
56
+ has_lane_entry: bool
57
+ latest_lane: Optional[str]
58
+ metadata: Dict[str, Optional[str]] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass
62
+ class AcceptanceSummary:
63
+ feature: str
64
+ repo_root: Path
65
+ feature_dir: Path
66
+ tasks_dir: Path
67
+ branch: Optional[str]
68
+ worktree_root: Path
69
+ primary_repo_root: Path
70
+ lanes: Dict[str, List[str]]
71
+ work_packages: List[WorkPackageState]
72
+ metadata_issues: List[str]
73
+ activity_issues: List[str]
74
+ unchecked_tasks: List[str]
75
+ needs_clarification: List[str]
76
+ missing_artifacts: List[str]
77
+ optional_missing: List[str]
78
+ git_dirty: List[str]
79
+ warnings: List[str]
80
+
81
+ @property
82
+ def all_done(self) -> bool:
83
+ return not (
84
+ self.lanes.get("planned")
85
+ or self.lanes.get("doing")
86
+ or self.lanes.get("for_review")
87
+ )
88
+
89
+ @property
90
+ def ok(self) -> bool:
91
+ return (
92
+ self.all_done
93
+ and not self.metadata_issues
94
+ and not self.activity_issues
95
+ and not self.unchecked_tasks
96
+ and not self.needs_clarification
97
+ and not self.missing_artifacts
98
+ and not self.git_dirty
99
+ )
100
+
101
+ def outstanding(self) -> Dict[str, List[str]]:
102
+ buckets = {
103
+ "not_done": [
104
+ *self.lanes.get("planned", []),
105
+ *self.lanes.get("doing", []),
106
+ *self.lanes.get("for_review", []),
107
+ ],
108
+ "metadata": self.metadata_issues,
109
+ "activity": self.activity_issues,
110
+ "unchecked_tasks": self.unchecked_tasks,
111
+ "needs_clarification": self.needs_clarification,
112
+ "missing_artifacts": self.missing_artifacts,
113
+ "git_dirty": self.git_dirty,
114
+ }
115
+ return {key: value for key, value in buckets.items() if value}
116
+
117
+ def to_dict(self) -> Dict[str, object]:
118
+ return {
119
+ "feature": self.feature,
120
+ "branch": self.branch,
121
+ "repo_root": str(self.repo_root),
122
+ "feature_dir": str(self.feature_dir),
123
+ "tasks_dir": str(self.tasks_dir),
124
+ "worktree_root": str(self.worktree_root),
125
+ "primary_repo_root": str(self.primary_repo_root),
126
+ "lanes": self.lanes,
127
+ "work_packages": [
128
+ {
129
+ "id": wp.work_package_id,
130
+ "lane": wp.lane,
131
+ "title": wp.title,
132
+ "path": wp.path,
133
+ "latest_lane": wp.latest_lane,
134
+ "has_lane_entry": wp.has_lane_entry,
135
+ "metadata": wp.metadata,
136
+ }
137
+ for wp in self.work_packages
138
+ ],
139
+ "metadata_issues": self.metadata_issues,
140
+ "activity_issues": self.activity_issues,
141
+ "unchecked_tasks": self.unchecked_tasks,
142
+ "needs_clarification": self.needs_clarification,
143
+ "missing_artifacts": self.missing_artifacts,
144
+ "optional_missing": self.optional_missing,
145
+ "git_dirty": self.git_dirty,
146
+ "warnings": self.warnings,
147
+ "all_done": self.all_done,
148
+ "ok": self.ok,
149
+ }
150
+
151
+
152
+ @dataclass
153
+ class AcceptanceResult:
154
+ summary: AcceptanceSummary
155
+ mode: AcceptanceMode
156
+ accepted_at: str
157
+ accepted_by: str
158
+ parent_commit: Optional[str]
159
+ accept_commit: Optional[str]
160
+ commit_created: bool
161
+ instructions: List[str]
162
+ cleanup_instructions: List[str]
163
+ notes: List[str] = field(default_factory=list)
164
+
165
+ def to_dict(self) -> Dict[str, object]:
166
+ return {
167
+ "accepted_at": self.accepted_at,
168
+ "accepted_by": self.accepted_by,
169
+ "mode": self.mode,
170
+ "parent_commit": self.parent_commit,
171
+ "accept_commit": self.accept_commit,
172
+ "commit_created": self.commit_created,
173
+ "instructions": self.instructions,
174
+ "cleanup_instructions": self.cleanup_instructions,
175
+ "notes": self.notes,
176
+ "summary": self.summary.to_dict(),
177
+ }
178
+
179
+
180
+ def _iter_work_packages(repo_root: Path, feature: str) -> Iterable[WorkPackage]:
181
+ """Iterate work packages for a feature.
182
+
183
+ Supports both legacy (directory-based) and new (frontmatter-only) lane formats.
184
+ """
185
+ feature_dir = repo_root / "kitty-specs" / feature
186
+ tasks_dir = feature_dir / "tasks"
187
+ if not tasks_dir.exists():
188
+ raise AcceptanceError(f"Feature '{feature}' has no tasks directory at {tasks_dir}.")
189
+
190
+ if is_legacy_format(feature_dir):
191
+ # Legacy format: lane determined by subdirectory
192
+ for lane_dir in sorted(tasks_dir.iterdir()):
193
+ if not lane_dir.is_dir():
194
+ continue
195
+ lane = lane_dir.name
196
+ if lane not in LANES:
197
+ continue
198
+ for path in sorted(lane_dir.rglob("*.md")):
199
+ text = _read_text_strict(path)
200
+ front, body, padding = split_frontmatter(text)
201
+ relative = path.relative_to(lane_dir)
202
+ yield WorkPackage(
203
+ feature=feature,
204
+ path=path,
205
+ current_lane=lane,
206
+ relative_subpath=relative,
207
+ frontmatter=front,
208
+ body=body,
209
+ padding=padding,
210
+ )
211
+ else:
212
+ # New format: flat directory, lane from frontmatter
213
+ for path in sorted(tasks_dir.glob("*.md")):
214
+ if path.name == "README.md":
215
+ continue
216
+ text = _read_text_strict(path)
217
+ front, body, padding = split_frontmatter(text)
218
+ lane = get_lane_from_frontmatter(path, warn_on_missing=False)
219
+ yield WorkPackage(
220
+ feature=feature,
221
+ path=path,
222
+ current_lane=lane,
223
+ relative_subpath=path.relative_to(tasks_dir),
224
+ frontmatter=front,
225
+ body=body,
226
+ padding=padding,
227
+ )
228
+
229
+
230
+ def detect_feature_slug(
231
+ repo_root: Path,
232
+ *,
233
+ env: Optional[Mapping[str, str]] = None,
234
+ cwd: Optional[Path] = None,
235
+ ) -> str:
236
+ env = env or os.environ
237
+ if "SPECIFY_FEATURE" in env and env["SPECIFY_FEATURE"].strip():
238
+ return env["SPECIFY_FEATURE"].strip()
239
+
240
+ try:
241
+ branch = (
242
+ run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root, check=True)
243
+ .stdout.strip()
244
+ )
245
+ if branch and branch != "HEAD" and re.match(r"^\d{3}-", branch):
246
+ return branch
247
+ except TaskCliError:
248
+ pass
249
+
250
+ cwd = (cwd or Path.cwd()).resolve()
251
+ for parent in [cwd, *cwd.parents]:
252
+ if parent.name.startswith(".worktrees"):
253
+ parts = list(parent.parts)
254
+ try:
255
+ idx = parts.index(".worktrees")
256
+ candidate = parts[idx + 1]
257
+ if re.match(r"^\d{3}-", candidate):
258
+ return candidate
259
+ except (ValueError, IndexError):
260
+ continue
261
+ if parent.name.startswith("0") and re.match(r"^\d{3}-", parent.name):
262
+ return parent.name
263
+
264
+ raise AcceptanceError(
265
+ "Unable to determine feature slug automatically. Provide --feature explicitly."
266
+ )
267
+
268
+
269
+ def _read_file(path: Path) -> str:
270
+ return _read_text_strict(path) if path.exists() else ""
271
+
272
+
273
+ def _read_text_strict(path: Path) -> str:
274
+ try:
275
+ return path.read_text(encoding="utf-8-sig")
276
+ except UnicodeDecodeError as exc:
277
+ raise ArtifactEncodingError(path, exc) from exc
278
+
279
+
280
+ def _find_unchecked_tasks(tasks_file: Path) -> List[str]:
281
+ if not tasks_file.exists():
282
+ return ["tasks.md missing"]
283
+
284
+ unchecked: List[str] = []
285
+ for line in _read_text_strict(tasks_file).splitlines():
286
+ if re.match(r"^\s*-\s*\[ \]", line):
287
+ unchecked.append(line.strip())
288
+ return unchecked
289
+
290
+
291
+ def _check_needs_clarification(files: Sequence[Path]) -> List[str]:
292
+ results: List[str] = []
293
+ for file_path in files:
294
+ if file_path.exists():
295
+ text = _read_text_strict(file_path)
296
+ if "[NEEDS CLARIFICATION" in text:
297
+ results.append(str(file_path))
298
+ return results
299
+
300
+
301
+ def _missing_artifacts(feature_dir: Path) -> Tuple[List[str], List[str]]:
302
+ required = [feature_dir / "spec.md", feature_dir / "plan.md", feature_dir / "tasks.md"]
303
+ optional = [
304
+ feature_dir / "quickstart.md",
305
+ feature_dir / "data-model.md",
306
+ feature_dir / "research.md",
307
+ feature_dir / "contracts",
308
+ ]
309
+ missing_required = [str(p.relative_to(feature_dir)) for p in required if not p.exists()]
310
+ missing_optional = [str(p.relative_to(feature_dir)) for p in optional if not p.exists()]
311
+ return missing_required, missing_optional
312
+
313
+
314
+ def normalize_feature_encoding(repo_root: Path, feature: str) -> List[Path]:
315
+ """Normalize file encoding from Windows-1252 to UTF-8 with ASCII character mapping.
316
+
317
+ Converts Windows-1252 encoded files to UTF-8, replacing Unicode smart quotes
318
+ and special characters with ASCII equivalents for maximum compatibility.
319
+ """
320
+ # Map Unicode characters to ASCII equivalents
321
+ NORMALIZE_MAP = {
322
+ '\u2018': "'", # Left single quotation mark → apostrophe
323
+ '\u2019': "'", # Right single quotation mark → apostrophe
324
+ '\u201A': "'", # Single low-9 quotation mark → apostrophe
325
+ '\u201C': '"', # Left double quotation mark → straight quote
326
+ '\u201D': '"', # Right double quotation mark → straight quote
327
+ '\u201E': '"', # Double low-9 quotation mark → straight quote
328
+ '\u2014': '--', # Em dash → double hyphen
329
+ '\u2013': '-', # En dash → hyphen
330
+ '\u2026': '...', # Horizontal ellipsis → three dots
331
+ '\u00A0': ' ', # Non-breaking space → regular space
332
+ '\u2022': '*', # Bullet → asterisk
333
+ '\u00B7': '*', # Middle dot → asterisk
334
+ }
335
+
336
+ feature_dir = repo_root / "kitty-specs" / feature
337
+ if not feature_dir.exists():
338
+ return []
339
+
340
+ candidates: List[Path] = []
341
+ primary_files = [
342
+ feature_dir / "spec.md",
343
+ feature_dir / "plan.md",
344
+ feature_dir / "quickstart.md",
345
+ feature_dir / "tasks.md",
346
+ feature_dir / "research.md",
347
+ feature_dir / "data-model.md",
348
+ ]
349
+ candidates.extend(p for p in primary_files if p.exists())
350
+
351
+ for subdir in [feature_dir / "tasks", feature_dir / "research", feature_dir / "checklists"]:
352
+ if subdir.exists():
353
+ candidates.extend(path for path in subdir.rglob("*.md"))
354
+
355
+ rewritten: List[Path] = []
356
+ seen: Set[Path] = set()
357
+ for path in candidates:
358
+ if path in seen or not path.exists():
359
+ continue
360
+ seen.add(path)
361
+ data = path.read_bytes()
362
+ try:
363
+ data.decode("utf-8")
364
+ continue
365
+ except UnicodeDecodeError:
366
+ pass
367
+
368
+ text: Optional[str] = None
369
+ for encoding in ("cp1252", "latin-1"):
370
+ try:
371
+ text = data.decode(encoding)
372
+ break
373
+ except UnicodeDecodeError:
374
+ continue
375
+ if text is None:
376
+ text = data.decode("utf-8", errors="replace")
377
+
378
+ # Strip UTF-8 BOM if present in the text
379
+ text = text.lstrip('\ufeff')
380
+
381
+ # Normalize Unicode characters to ASCII equivalents
382
+ for unicode_char, ascii_replacement in NORMALIZE_MAP.items():
383
+ text = text.replace(unicode_char, ascii_replacement)
384
+
385
+ path.write_text(text, encoding="utf-8")
386
+ rewritten.append(path)
387
+
388
+ return rewritten
389
+
390
+
391
+ def collect_feature_summary(
392
+ repo_root: Path,
393
+ feature: str,
394
+ *,
395
+ strict_metadata: bool = True,
396
+ ) -> AcceptanceSummary:
397
+ feature_dir = repo_root / "kitty-specs" / feature
398
+ tasks_dir = feature_dir / "tasks"
399
+ if not feature_dir.exists():
400
+ raise AcceptanceError(f"Feature directory not found: {feature_dir}")
401
+
402
+ branch: Optional[str] = None
403
+ try:
404
+ branch_value = (
405
+ run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_root, check=True)
406
+ .stdout.strip()
407
+ )
408
+ if branch_value and branch_value != "HEAD":
409
+ branch = branch_value
410
+ except TaskCliError:
411
+ branch = None
412
+
413
+ try:
414
+ worktree_root = Path(
415
+ run_git(["rev-parse", "--show-toplevel"], cwd=repo_root, check=True)
416
+ .stdout.strip()
417
+ ).resolve()
418
+ except TaskCliError:
419
+ worktree_root = repo_root
420
+
421
+ try:
422
+ git_common_dir = Path(
423
+ run_git(["rev-parse", "--git-common-dir"], cwd=repo_root, check=True)
424
+ .stdout.strip()
425
+ ).resolve()
426
+ primary_repo_root = git_common_dir.parent
427
+ except TaskCliError:
428
+ primary_repo_root = repo_root
429
+
430
+ lanes: Dict[str, List[str]] = {lane: [] for lane in LANES}
431
+ work_packages: List[WorkPackageState] = []
432
+ metadata_issues: List[str] = []
433
+ activity_issues: List[str] = []
434
+
435
+ for wp in _iter_work_packages(repo_root, feature):
436
+ wp_id = wp.work_package_id or wp.path.stem
437
+ title = (wp.title or "").strip('"')
438
+ lanes[wp.current_lane].append(wp_id)
439
+
440
+ entries = activity_entries(wp.body)
441
+ lanes_logged = {entry["lane"] for entry in entries}
442
+ latest_lane = entries[-1]["lane"] if entries else None
443
+ has_lane_entry = wp.current_lane in lanes_logged
444
+
445
+ metadata: Dict[str, Optional[str]] = {
446
+ "lane": wp.lane,
447
+ "agent": wp.agent,
448
+ "assignee": wp.assignee,
449
+ "shell_pid": wp.shell_pid,
450
+ }
451
+
452
+ if strict_metadata:
453
+ lane_value = (wp.lane or "").strip()
454
+ if not lane_value:
455
+ metadata_issues.append(f"{wp_id}: missing lane in frontmatter")
456
+ elif lane_value != wp.current_lane:
457
+ metadata_issues.append(
458
+ f"{wp_id}: frontmatter lane '{lane_value}' does not match expected '{wp.current_lane}'"
459
+ )
460
+
461
+ if not wp.agent:
462
+ metadata_issues.append(f"{wp_id}: missing agent in frontmatter")
463
+ if wp.current_lane in {"doing", "for_review", "done"} and not wp.assignee:
464
+ metadata_issues.append(f"{wp_id}: missing assignee in frontmatter")
465
+ if not wp.shell_pid:
466
+ metadata_issues.append(f"{wp_id}: missing shell_pid in frontmatter")
467
+
468
+ if not entries:
469
+ activity_issues.append(f"{wp_id}: Activity Log missing entries")
470
+ else:
471
+ if wp.current_lane not in lanes_logged:
472
+ activity_issues.append(
473
+ f"{wp_id}: Activity Log missing entry for lane={wp.current_lane}"
474
+ )
475
+ if wp.current_lane == "done" and entries[-1]["lane"] != "done":
476
+ activity_issues.append(f"{wp_id}: latest Activity Log entry not lane=done")
477
+
478
+ work_packages.append(
479
+ WorkPackageState(
480
+ work_package_id=wp_id,
481
+ lane=wp.current_lane,
482
+ title=title,
483
+ path=str(wp.path.relative_to(repo_root)),
484
+ has_lane_entry=has_lane_entry,
485
+ latest_lane=latest_lane,
486
+ metadata=metadata,
487
+ )
488
+ )
489
+
490
+ unchecked_tasks = _find_unchecked_tasks(feature_dir / "tasks.md")
491
+ needs_clarification = _check_needs_clarification(
492
+ [
493
+ feature_dir / "spec.md",
494
+ feature_dir / "plan.md",
495
+ feature_dir / "quickstart.md",
496
+ feature_dir / "tasks.md",
497
+ feature_dir / "research.md",
498
+ feature_dir / "data-model.md",
499
+ ]
500
+ )
501
+ missing_required, missing_optional = _missing_artifacts(feature_dir)
502
+
503
+ try:
504
+ git_dirty = git_status_lines(repo_root)
505
+ except TaskCliError:
506
+ git_dirty = []
507
+
508
+ warnings: List[str] = []
509
+ if missing_optional:
510
+ warnings.append("Optional artifacts missing: " + ", ".join(missing_optional))
511
+
512
+ return AcceptanceSummary(
513
+ feature=feature,
514
+ repo_root=repo_root,
515
+ feature_dir=feature_dir,
516
+ tasks_dir=tasks_dir,
517
+ branch=branch,
518
+ worktree_root=worktree_root,
519
+ primary_repo_root=primary_repo_root,
520
+ lanes=lanes,
521
+ work_packages=work_packages,
522
+ metadata_issues=metadata_issues,
523
+ activity_issues=activity_issues,
524
+ unchecked_tasks=unchecked_tasks if unchecked_tasks != ["tasks.md missing"] else [],
525
+ needs_clarification=needs_clarification,
526
+ missing_artifacts=missing_required,
527
+ optional_missing=missing_optional,
528
+ git_dirty=git_dirty,
529
+ warnings=warnings,
530
+ )
531
+
532
+
533
+ def choose_mode(preference: Optional[str], repo_root: Path) -> AcceptanceMode:
534
+ if preference in {"pr", "local", "checklist"}:
535
+ return preference
536
+ try:
537
+ remotes = (
538
+ run_git(["remote"], cwd=repo_root, check=False).stdout.strip().splitlines()
539
+ )
540
+ if remotes:
541
+ return "pr"
542
+ except TaskCliError:
543
+ pass
544
+ return "local"
545
+
546
+
547
+ def perform_acceptance(
548
+ summary: AcceptanceSummary,
549
+ *,
550
+ mode: AcceptanceMode,
551
+ actor: Optional[str],
552
+ tests: Optional[Sequence[str]] = None,
553
+ auto_commit: bool = True,
554
+ ) -> AcceptanceResult:
555
+ if mode != "checklist" and not summary.ok:
556
+ raise AcceptanceError(
557
+ "Acceptance checks failed; run verify to see outstanding issues."
558
+ )
559
+
560
+ actor_name = (actor or os.getenv("USER") or os.getenv("USERNAME") or "system").strip()
561
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
562
+
563
+ parent_commit: Optional[str] = None
564
+ accept_commit: Optional[str] = None
565
+
566
+ if auto_commit and mode != "checklist":
567
+ try:
568
+ parent_commit = (
569
+ run_git(["rev-parse", "HEAD"], cwd=summary.repo_root, check=False)
570
+ .stdout.strip()
571
+ or None
572
+ )
573
+ except TaskCliError:
574
+ parent_commit = None
575
+
576
+ meta_path = summary.feature_dir / "meta.json"
577
+ if meta_path.exists():
578
+ meta = json.loads(_read_text_strict(meta_path))
579
+ else:
580
+ meta = {}
581
+
582
+ acceptance_record: Dict[str, object] = {
583
+ "accepted_at": timestamp,
584
+ "accepted_by": actor_name,
585
+ "mode": mode,
586
+ "branch": summary.branch,
587
+ "accepted_from_commit": parent_commit,
588
+ }
589
+ if tests:
590
+ acceptance_record["validation_commands"] = list(tests)
591
+
592
+ meta["accepted_at"] = timestamp
593
+ meta["accepted_by"] = actor_name
594
+ meta["acceptance_mode"] = mode
595
+ meta["accepted_from_commit"] = parent_commit
596
+ meta["accept_commit"] = None
597
+
598
+ history: List[Dict[str, object]] = meta.setdefault("acceptance_history", [])
599
+ history.append(acceptance_record)
600
+ if len(history) > 20:
601
+ meta["acceptance_history"] = history[-20:]
602
+
603
+ meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
604
+ run_git(
605
+ ["add", str(meta_path.relative_to(summary.repo_root))],
606
+ cwd=summary.repo_root,
607
+ check=True,
608
+ )
609
+
610
+ status = run_git(["diff", "--cached", "--name-only"], cwd=summary.repo_root, check=True)
611
+ staged_files = [line.strip() for line in status.stdout.splitlines() if line.strip()]
612
+ commit_created = False
613
+ if staged_files:
614
+ commit_msg = f"Accept {summary.feature}"
615
+ run_git(["commit", "-m", commit_msg], cwd=summary.repo_root, check=True)
616
+ commit_created = True
617
+ try:
618
+ accept_commit = (
619
+ run_git(["rev-parse", "HEAD"], cwd=summary.repo_root, check=True)
620
+ .stdout.strip()
621
+ )
622
+ except TaskCliError:
623
+ accept_commit = None
624
+ else:
625
+ commit_created = False
626
+ else:
627
+ commit_created = False
628
+
629
+ instructions: List[str] = []
630
+ cleanup_instructions: List[str] = []
631
+
632
+ branch = summary.branch or summary.feature
633
+ if mode == "pr":
634
+ instructions.extend(
635
+ [
636
+ f"Review the acceptance commit on branch `{branch}`.",
637
+ f"Push your branch: `git push origin {branch}`",
638
+ "Open a pull request referencing spec/plan/tasks artifacts.",
639
+ "Include acceptance summary and test evidence in the PR description.",
640
+ ]
641
+ )
642
+ elif mode == "local":
643
+ instructions.extend(
644
+ [
645
+ "Switch to your integration branch (e.g., `git checkout main`).",
646
+ "Synchronize it (e.g., `git pull --ff-only`).",
647
+ f"Merge the feature: `git merge {branch}`",
648
+ ]
649
+ )
650
+ else: # checklist
651
+ instructions.append(
652
+ "All checks passed. Proceed with your manual acceptance workflow."
653
+ )
654
+
655
+ if summary.worktree_root != summary.primary_repo_root:
656
+ cleanup_instructions.append(
657
+ f"After merging, remove the worktree: `git worktree remove {summary.worktree_root}`"
658
+ )
659
+ cleanup_instructions.append(f"Delete the feature branch when done: `git branch -d {branch}`")
660
+
661
+ notes: List[str] = []
662
+ if accept_commit:
663
+ notes.append(f"Acceptance commit: {accept_commit}")
664
+ if parent_commit:
665
+ notes.append(f"Accepted from parent commit: {parent_commit}")
666
+ if tests:
667
+ notes.append("Validation commands:")
668
+ notes.extend(f" - {cmd}" for cmd in tests)
669
+
670
+ return AcceptanceResult(
671
+ summary=summary,
672
+ mode=mode,
673
+ accepted_at=timestamp,
674
+ accepted_by=actor_name,
675
+ parent_commit=parent_commit,
676
+ accept_commit=accept_commit,
677
+ commit_created=commit_created,
678
+ instructions=instructions,
679
+ cleanup_instructions=cleanup_instructions,
680
+ notes=notes,
681
+ )
682
+
683
+
684
+ __all__ = [
685
+ "AcceptanceError",
686
+ "ArtifactEncodingError",
687
+ "AcceptanceResult",
688
+ "AcceptanceSummary",
689
+ "AcceptanceMode",
690
+ "collect_feature_summary",
691
+ "detect_feature_slug",
692
+ "choose_mode",
693
+ "perform_acceptance",
694
+ "normalize_feature_encoding",
695
+ ]