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,447 @@
1
+ #!/usr/bin/env python3
2
+ """Shared utilities for manipulating Spec Kitty task prompts.
3
+
4
+ DEPRECATED: This module is deprecated as of v0.10.0.
5
+ Use `spec-kitty agent tasks` commands instead.
6
+
7
+ This file will be removed in the next release.
8
+ See: src/specify_cli/cli/commands/agent/tasks.py
9
+
10
+ Migration Guide:
11
+ - tasks_cli.py update → spec-kitty agent tasks move-task
12
+ - For all other operations, use the new agent tasks commands
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import subprocess
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ from specify_cli.legacy_detector import is_legacy_format
26
+
27
+ # IMPORTANT: Keep in sync with scripts/tasks/task_helpers.py
28
+ LANES: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
29
+ TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
30
+
31
+
32
+ class TaskCliError(RuntimeError):
33
+ """Raised when task operations cannot be completed safely."""
34
+
35
+
36
+ def find_repo_root(start: Optional[Path] = None) -> Path:
37
+ """Find the MAIN repository root, even when inside a worktree.
38
+
39
+ This function correctly handles git worktrees by detecting when .git is a
40
+ file (worktree pointer) vs a directory (main repo), and following the
41
+ pointer back to the main repository.
42
+
43
+ Args:
44
+ start: Starting directory for search (defaults to cwd)
45
+
46
+ Returns:
47
+ Path to the main repository root
48
+
49
+ Raises:
50
+ TaskCliError: If repository root cannot be found
51
+ """
52
+ current = (start or Path.cwd()).resolve()
53
+
54
+ for candidate in [current, *current.parents]:
55
+ git_path = candidate / ".git"
56
+
57
+ if git_path.is_file():
58
+ # This is a worktree! The .git file contains a pointer to the main repo.
59
+ # Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
60
+ try:
61
+ content = git_path.read_text().strip()
62
+ if content.startswith("gitdir:"):
63
+ gitdir = Path(content.split(":", 1)[1].strip())
64
+ # Navigate: .git/worktrees/name -> .git -> main repo root
65
+ # gitdir points to .git/worktrees/xxx, so .parent.parent is .git
66
+ main_git_dir = gitdir.parent.parent
67
+ main_repo = main_git_dir.parent
68
+ if main_repo.exists():
69
+ return main_repo
70
+ except (OSError, ValueError):
71
+ # If we can't read or parse the .git file, continue searching
72
+ pass
73
+
74
+ elif git_path.is_dir():
75
+ # This is the main repo (or a regular git repo)
76
+ return candidate
77
+
78
+ # Also check for .kittify marker (fallback for non-git scenarios)
79
+ if (candidate / ".kittify").exists():
80
+ return candidate
81
+
82
+ raise TaskCliError("Unable to locate repository root (missing .git or .kittify).")
83
+
84
+
85
+ def run_git(args: List[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
86
+ """Run a git command inside the repository."""
87
+ try:
88
+ return subprocess.run(
89
+ ["git", *args],
90
+ cwd=str(cwd),
91
+ check=check,
92
+ text=True,
93
+ capture_output=True,
94
+ )
95
+ except FileNotFoundError as exc:
96
+ raise TaskCliError("git is not available on PATH.") from exc
97
+ except subprocess.CalledProcessError as exc:
98
+ if check:
99
+ message = exc.stderr.strip() or exc.stdout.strip() or "Unknown git error"
100
+ raise TaskCliError(message)
101
+ return exc
102
+
103
+
104
+ def ensure_lane(value: str) -> str:
105
+ lane = value.strip().lower()
106
+ if lane not in LANES:
107
+ raise TaskCliError(f"Invalid lane '{value}'. Expected one of {', '.join(LANES)}.")
108
+ return lane
109
+
110
+
111
+ def now_utc() -> str:
112
+ return datetime.now(timezone.utc).strftime(TIMESTAMP_FORMAT)
113
+
114
+
115
+ def git_status_lines(repo_root: Path) -> List[str]:
116
+ result = run_git(["status", "--porcelain"], cwd=repo_root, check=True)
117
+ return [line for line in result.stdout.splitlines() if line.strip()]
118
+
119
+
120
+ def normalize_note(note: Optional[str], target_lane: str) -> str:
121
+ default = f"Moved to {target_lane}"
122
+ cleaned = (note or default).strip()
123
+ return cleaned or default
124
+
125
+
126
+ def detect_conflicting_wp_status(
127
+ status_lines: List[str], feature: str, old_path: Path, new_path: Path
128
+ ) -> List[str]:
129
+ """Return staged work-package entries unrelated to the requested move."""
130
+ prefix = f"kitty-specs/{feature}/tasks/"
131
+ allowed = {
132
+ str(old_path).lstrip("./"),
133
+ str(new_path).lstrip("./"),
134
+ }
135
+ conflicts = []
136
+ for line in status_lines:
137
+ path = line[3:] if len(line) > 3 else ""
138
+ if not path.startswith(prefix):
139
+ continue
140
+ clean = path.strip()
141
+ if clean not in allowed:
142
+ conflicts.append(line)
143
+ return conflicts
144
+
145
+
146
+ def match_frontmatter_line(frontmatter: str, key: str) -> Optional[re.Match]:
147
+ pattern = re.compile(
148
+ rf"^({re.escape(key)}:\s*)(\".*?\"|'.*?'|[^#\n]*)(.*)$",
149
+ flags=re.MULTILINE,
150
+ )
151
+ return pattern.search(frontmatter)
152
+
153
+
154
+ def extract_scalar(frontmatter: str, key: str) -> Optional[str]:
155
+ match = match_frontmatter_line(frontmatter, key)
156
+ if not match:
157
+ return None
158
+ raw_value = match.group(2).strip()
159
+ if raw_value.startswith('"') and raw_value.endswith('"'):
160
+ return raw_value[1:-1]
161
+ if raw_value.startswith("'") and raw_value.endswith("'"):
162
+ return raw_value[1:-1]
163
+ return raw_value.strip() or None
164
+
165
+
166
+ def set_scalar(frontmatter: str, key: str, value: str) -> str:
167
+ """Replace or insert a scalar value while preserving trailing comments."""
168
+ match = match_frontmatter_line(frontmatter, key)
169
+ replacement_line = f'{key}: "{value}"'
170
+ if match:
171
+ prefix = match.group(1)
172
+ comment = match.group(3)
173
+ comment_suffix = f"{comment}" if comment else ""
174
+ return (
175
+ frontmatter[: match.start()]
176
+ + f'{prefix}"{value}"{comment_suffix}'
177
+ + frontmatter[match.end() :]
178
+ )
179
+
180
+ insertion = f"{replacement_line}\n"
181
+ history_match = re.search(r"^\s*history:\s*$", frontmatter, flags=re.MULTILINE)
182
+ if history_match:
183
+ idx = history_match.start()
184
+ return frontmatter[:idx] + insertion + frontmatter[idx:]
185
+
186
+ if frontmatter and not frontmatter.endswith("\n"):
187
+ frontmatter += "\n"
188
+ return frontmatter + insertion
189
+
190
+
191
+ def split_frontmatter(text: str) -> Tuple[str, str, str]:
192
+ """Return (frontmatter, body, padding) while preserving spacing after frontmatter."""
193
+ normalized = text.replace("\r\n", "\n")
194
+ if not normalized.startswith("---\n"):
195
+ return "", normalized, ""
196
+
197
+ closing_idx = normalized.find("\n---", 4)
198
+ if closing_idx == -1:
199
+ return "", normalized, ""
200
+
201
+ front = normalized[4:closing_idx]
202
+ tail = normalized[closing_idx + 4 :]
203
+ padding = ""
204
+ while tail.startswith("\n"):
205
+ padding += "\n"
206
+ tail = tail[1:]
207
+ return front, tail, padding
208
+
209
+
210
+ def build_document(frontmatter: str, body: str, padding: str) -> str:
211
+ frontmatter = frontmatter.rstrip("\n")
212
+ doc = f"---\n{frontmatter}\n---"
213
+ if padding or body:
214
+ doc += padding or "\n"
215
+ doc += body
216
+ if not doc.endswith("\n"):
217
+ doc += "\n"
218
+ return doc
219
+
220
+
221
+ def append_activity_log(body: str, entry: str) -> str:
222
+ header = "## Activity Log"
223
+ if header not in body:
224
+ block = f"{header}\n\n{entry}\n"
225
+ if body and not body.endswith("\n\n"):
226
+ return body.rstrip() + "\n\n" + block
227
+ return body + "\n" + block if body else block
228
+
229
+ pattern = re.compile(r"(## Activity Log.*?)(?=\n## |\Z)", flags=re.DOTALL)
230
+ match = pattern.search(body)
231
+ if not match:
232
+ return body + ("\n" if not body.endswith("\n") else "") + entry + "\n"
233
+
234
+ section = match.group(1).rstrip()
235
+ if not section.endswith("\n"):
236
+ section += "\n"
237
+ section += f"{entry}\n"
238
+ return body[: match.start(1)] + section + body[match.end(1) :]
239
+
240
+
241
+ def activity_entries(body: str) -> List[Dict[str, str]]:
242
+ pattern = re.compile(
243
+ r"^\s*-\s*"
244
+ r"(?P<timestamp>[0-9T:-]+Z)\s*[–-]\s*"
245
+ r"(?P<agent>[^–-]+?)\s*[–-]\s*"
246
+ r"(?:shell_pid=(?P<shell>[^–-]*?)\s*[–-]\s*)?"
247
+ r"lane=(?P<lane>[a-z_]+)\s*[–-]\s*"
248
+ r"(?P<note>.*)$",
249
+ flags=re.MULTILINE,
250
+ )
251
+ entries: List[Dict[str, str]] = []
252
+ for match in pattern.finditer(body):
253
+ entries.append(
254
+ {
255
+ "timestamp": match.group("timestamp").strip(),
256
+ "agent": match.group("agent").strip(),
257
+ "lane": match.group("lane").strip(),
258
+ "note": match.group("note").strip(),
259
+ "shell_pid": (match.group("shell") or "").strip(),
260
+ }
261
+ )
262
+ return entries
263
+
264
+
265
+ @dataclass
266
+ class WorkPackage:
267
+ feature: str
268
+ path: Path
269
+ current_lane: str
270
+ relative_subpath: Path
271
+ frontmatter: str
272
+ body: str
273
+ padding: str
274
+
275
+ @property
276
+ def work_package_id(self) -> Optional[str]:
277
+ return extract_scalar(self.frontmatter, "work_package_id")
278
+
279
+ @property
280
+ def title(self) -> Optional[str]:
281
+ return extract_scalar(self.frontmatter, "title")
282
+
283
+ @property
284
+ def assignee(self) -> Optional[str]:
285
+ return extract_scalar(self.frontmatter, "assignee")
286
+
287
+ @property
288
+ def agent(self) -> Optional[str]:
289
+ return extract_scalar(self.frontmatter, "agent")
290
+
291
+ @property
292
+ def shell_pid(self) -> Optional[str]:
293
+ return extract_scalar(self.frontmatter, "shell_pid")
294
+
295
+ @property
296
+ def lane(self) -> Optional[str]:
297
+ return extract_scalar(self.frontmatter, "lane")
298
+
299
+
300
+ def locate_work_package(repo_root: Path, feature: str, wp_id: str) -> WorkPackage:
301
+ """Locate a work package by ID, supporting both legacy and new formats.
302
+
303
+ Always uses main repo's kitty-specs/ regardless of current directory.
304
+ Worktrees should not contain kitty-specs/ (excluded via sparse checkout).
305
+
306
+ Legacy format: WP files in tasks/{lane}/ subdirectories
307
+ New format: WP files in flat tasks/ directory with lane in frontmatter
308
+ """
309
+ from specify_cli.core.paths import get_main_repo_root
310
+
311
+ # Always use main repo's kitty-specs - it's the source of truth
312
+ # This fixes the bug where worktree's stale kitty-specs/ would be used
313
+ main_root = get_main_repo_root(repo_root)
314
+ feature_path = main_root / "kitty-specs" / feature
315
+
316
+ tasks_root = feature_path / "tasks"
317
+ if not tasks_root.exists():
318
+ raise TaskCliError(f"Feature '{feature}' has no tasks directory at {tasks_root}.")
319
+
320
+ # Use exact WP ID matching with word boundary to avoid WP04 matching WP04b
321
+ # Matches: WP04.md, WP04-something.md, WP04_something.md
322
+ # Does NOT match: WP04b.md, WP04b-something.md
323
+ wp_pattern = re.compile(rf"^{re.escape(wp_id)}(?:[-_.]|\.md$)")
324
+
325
+ use_legacy = is_legacy_format(feature_path)
326
+ candidates = []
327
+
328
+ if use_legacy:
329
+ # Legacy format: search lane subdirectories
330
+ for lane_dir in tasks_root.iterdir():
331
+ if not lane_dir.is_dir():
332
+ continue
333
+ lane = lane_dir.name
334
+ for path in lane_dir.rglob("*.md"):
335
+ if wp_pattern.match(path.name):
336
+ candidates.append((lane, path, lane_dir))
337
+ else:
338
+ # New format: search flat tasks/ directory
339
+ for path in tasks_root.glob("*.md"):
340
+ if path.name.lower() == "readme.md":
341
+ continue
342
+ if wp_pattern.match(path.name):
343
+ # Get lane from frontmatter
344
+ lane = get_lane_from_frontmatter(path, warn_on_missing=False)
345
+ candidates.append((lane, path, tasks_root))
346
+
347
+ if not candidates:
348
+ raise TaskCliError(f"Work package '{wp_id}' not found under kitty-specs/{feature}/tasks.")
349
+ if len(candidates) > 1:
350
+ joined = "\n".join(str(item[1].relative_to(repo_root)) for item in candidates)
351
+ raise TaskCliError(
352
+ f"Multiple files matched '{wp_id}'. Refine the ID or clean duplicates:\n{joined}"
353
+ )
354
+
355
+ lane, path, base_dir = candidates[0]
356
+ text = path.read_text(encoding="utf-8-sig")
357
+ front, body, padding = split_frontmatter(text)
358
+ relative = path.relative_to(base_dir)
359
+ return WorkPackage(
360
+ feature=feature,
361
+ path=path,
362
+ current_lane=lane,
363
+ relative_subpath=relative,
364
+ frontmatter=front,
365
+ body=body,
366
+ padding=padding,
367
+ )
368
+
369
+
370
+ def load_meta(meta_path: Path) -> Dict:
371
+ if not meta_path.exists():
372
+ raise TaskCliError(f"Meta file not found at {meta_path}")
373
+ return json.loads(meta_path.read_text(encoding="utf-8-sig"))
374
+
375
+
376
+ def get_lane_from_frontmatter(wp_path: Path, warn_on_missing: bool = True) -> str:
377
+ """Extract lane from WP file frontmatter.
378
+
379
+ This is the authoritative way to determine a work package's lane
380
+ in the frontmatter-only lane system.
381
+
382
+ Args:
383
+ wp_path: Path to the work package markdown file
384
+ warn_on_missing: If True, print warning when lane field is missing
385
+
386
+ Returns:
387
+ Lane value (planned, doing, for_review, done)
388
+
389
+ Raises:
390
+ ValueError: If lane value is not in LANES
391
+ """
392
+ content = wp_path.read_text(encoding="utf-8-sig")
393
+ frontmatter, _, _ = split_frontmatter(content)
394
+
395
+ lane = extract_scalar(frontmatter, "lane")
396
+
397
+ if lane is None:
398
+ if warn_on_missing:
399
+ # Import here to avoid circular dependency issues
400
+ try:
401
+ from rich.console import Console
402
+ console = Console(stderr=True)
403
+ console.print(
404
+ f"[yellow]Warning: {wp_path.name} missing lane field, "
405
+ f"defaulting to 'planned'[/yellow]"
406
+ )
407
+ except ImportError:
408
+ import sys
409
+ print(
410
+ f"Warning: {wp_path.name} missing lane field, defaulting to 'planned'",
411
+ file=sys.stderr
412
+ )
413
+ return "planned"
414
+
415
+ if lane not in LANES:
416
+ raise ValueError(
417
+ f"Invalid lane '{lane}' in {wp_path.name}. "
418
+ f"Valid lanes: {', '.join(LANES)}"
419
+ )
420
+
421
+ return lane
422
+
423
+
424
+ __all__ = [
425
+ "LANES",
426
+ "TIMESTAMP_FORMAT",
427
+ "TaskCliError",
428
+ "WorkPackage",
429
+ "append_activity_log",
430
+ "activity_entries",
431
+ "build_document",
432
+ "detect_conflicting_wp_status",
433
+ "ensure_lane",
434
+ "extract_scalar",
435
+ "find_repo_root",
436
+ "get_lane_from_frontmatter",
437
+ "git_status_lines",
438
+ "is_legacy_format",
439
+ "load_meta",
440
+ "locate_work_package",
441
+ "match_frontmatter_line",
442
+ "normalize_note",
443
+ "now_utc",
444
+ "run_git",
445
+ "set_scalar",
446
+ "split_frontmatter",
447
+ ]
@@ -0,0 +1,47 @@
1
+ """Template management for spec-kitty."""
2
+
3
+ from .manager import (
4
+ copy_package_tree,
5
+ copy_specify_base_from_local,
6
+ copy_specify_base_from_package,
7
+ get_local_repo_root,
8
+ )
9
+ from .renderer import (
10
+ DEFAULT_PATH_PATTERNS,
11
+ parse_frontmatter,
12
+ render_template,
13
+ rewrite_paths,
14
+ )
15
+ from .asset_generator import (
16
+ generate_agent_assets,
17
+ prepare_command_templates,
18
+ render_command_template,
19
+ )
20
+ from .github_client import (
21
+ GitHubClientError,
22
+ SSL_CONTEXT,
23
+ build_http_client,
24
+ download_and_extract_template,
25
+ download_template_from_github,
26
+ parse_repo_slug,
27
+ )
28
+
29
+ __all__ = [
30
+ "GitHubClientError",
31
+ "SSL_CONTEXT",
32
+ "build_http_client",
33
+ "copy_package_tree",
34
+ "copy_specify_base_from_local",
35
+ "copy_specify_base_from_package",
36
+ "DEFAULT_PATH_PATTERNS",
37
+ "download_and_extract_template",
38
+ "download_template_from_github",
39
+ "generate_agent_assets",
40
+ "get_local_repo_root",
41
+ "parse_frontmatter",
42
+ "parse_repo_slug",
43
+ "prepare_command_templates",
44
+ "render_command_template",
45
+ "render_template",
46
+ "rewrite_paths",
47
+ ]
@@ -0,0 +1,206 @@
1
+ """Agent-specific asset rendering helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Dict, Mapping
9
+
10
+ from specify_cli.core.config import AGENT_COMMAND_CONFIG
11
+ from specify_cli.template.renderer import render_template, rewrite_paths
12
+
13
+
14
+ def prepare_command_templates(
15
+ base_templates_dir: Path,
16
+ mission_templates_dir: Path | None,
17
+ ) -> Path:
18
+ """Prepare command templates with mission overrides applied.
19
+
20
+ Returns a directory containing base templates, with any mission templates
21
+ overlaid to enhance/override the central command set.
22
+ """
23
+ if not mission_templates_dir or not mission_templates_dir.exists():
24
+ return base_templates_dir
25
+
26
+ merged_dir = base_templates_dir.parent / f".merged-{mission_templates_dir.parent.name}"
27
+ if merged_dir.exists():
28
+ shutil.rmtree(merged_dir)
29
+
30
+ shutil.copytree(base_templates_dir, merged_dir)
31
+ for template_path in mission_templates_dir.glob("*.md"):
32
+ shutil.copy2(template_path, merged_dir / template_path.name)
33
+
34
+ return merged_dir
35
+
36
+
37
+ def generate_agent_assets(command_templates_dir: Path, project_path: Path, agent_key: str, script_type: str) -> None:
38
+ """Render every command template for the selected agent."""
39
+ config = AGENT_COMMAND_CONFIG[agent_key]
40
+ output_dir = project_path / config["dir"]
41
+ if output_dir.exists():
42
+ shutil.rmtree(output_dir)
43
+ output_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ if not command_templates_dir.exists():
46
+ _raise_template_discovery_error(command_templates_dir)
47
+
48
+ for template_path in sorted(command_templates_dir.glob("*.md")):
49
+ rendered = render_command_template(
50
+ template_path,
51
+ script_type,
52
+ agent_key,
53
+ config["arg_format"],
54
+ config["ext"],
55
+ )
56
+ ext = config["ext"]
57
+ stem = template_path.stem
58
+ if agent_key == "codex":
59
+ stem = stem.replace("-", "_")
60
+ filename = f"spec-kitty.{stem}.{ext}" if ext else f"spec-kitty.{stem}"
61
+ (output_dir / filename).write_text(rendered, encoding="utf-8")
62
+
63
+ if agent_key == "copilot":
64
+ vscode_settings = command_templates_dir.parent / "vscode-settings.json"
65
+ if vscode_settings.exists():
66
+ vscode_dest = project_path / ".vscode"
67
+ vscode_dest.mkdir(parents=True, exist_ok=True)
68
+ shutil.copy2(vscode_settings, vscode_dest / "settings.json")
69
+
70
+
71
+ def render_command_template(
72
+ template_path: Path,
73
+ script_type: str,
74
+ agent_key: str,
75
+ arg_format: str,
76
+ extension: str,
77
+ ) -> str:
78
+ """Render a single command template for an agent."""
79
+
80
+ def build_variables(metadata: Dict[str, object]) -> Mapping[str, str]:
81
+ scripts = metadata.get("scripts") or {}
82
+ agent_scripts = metadata.get("agent_scripts") or {}
83
+ if not isinstance(scripts, dict):
84
+ scripts = {}
85
+ if not isinstance(agent_scripts, dict):
86
+ agent_scripts = {}
87
+ script_command = scripts.get(
88
+ script_type, f"(Missing script command for {script_type})"
89
+ )
90
+ agent_script_command = agent_scripts.get(script_type)
91
+ return {
92
+ "{SCRIPT}": script_command,
93
+ "{AGENT_SCRIPT}": agent_script_command or "",
94
+ "{ARGS}": arg_format,
95
+ "__AGENT__": agent_key,
96
+ }
97
+
98
+ metadata, rendered_body, raw_frontmatter = render_template(
99
+ template_path, variables=build_variables
100
+ )
101
+ description = str(metadata.get("description", "")).strip()
102
+
103
+ frontmatter_clean = _filter_frontmatter(raw_frontmatter)
104
+ if frontmatter_clean:
105
+ frontmatter_clean = rewrite_paths(frontmatter_clean)
106
+
107
+ if extension == "toml":
108
+ # Convert Markdown variable syntax to TOML/Gemini variable syntax
109
+ # Gemini CLI uses {{args}} instead of $ARGUMENTS
110
+ # See: https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md
111
+ rendered_body = _convert_markdown_syntax_to_format(rendered_body, "toml")
112
+
113
+ description_value = description
114
+ if description_value.startswith('"') and description_value.endswith('"'):
115
+ description_value = description_value[1:-1]
116
+ description_value = description_value.replace('"', '\\"')
117
+ body_text = rendered_body
118
+ if not body_text.endswith("\n"):
119
+ body_text += "\n"
120
+ return f'description = "{description_value}"\n\nprompt = """\n{body_text}"""\n'
121
+
122
+ if frontmatter_clean:
123
+ result = f"---\n{frontmatter_clean}\n---\n\n{rendered_body}"
124
+ else:
125
+ result = rendered_body
126
+ return result if result.endswith("\n") else result + "\n"
127
+
128
+
129
+ def _convert_markdown_syntax_to_format(content: str, target_format: str) -> str:
130
+ """Convert Markdown variable syntax to target format syntax.
131
+
132
+ Args:
133
+ content: Rendered template content in Markdown syntax
134
+ target_format: Target format (e.g., "toml" for Gemini)
135
+
136
+ Returns:
137
+ Content with variable syntax converted to target format
138
+
139
+ Conversion rules:
140
+ - Markdown (Claude/Codex): $ARGUMENTS, $AGENT_SCRIPT
141
+ - TOML (Gemini): {{args}} (per https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md)
142
+ """
143
+ if target_format == "toml":
144
+ # Convert Claude/Codex Markdown variable syntax to Gemini TOML syntax
145
+ # $ARGUMENTS → {{args}}
146
+ content = content.replace("$ARGUMENTS", "{{args}}")
147
+ return content
148
+
149
+ # For other formats, return unchanged
150
+ return content
151
+
152
+
153
+ def _filter_frontmatter(frontmatter_text: str) -> str:
154
+ filtered_lines: list[str] = []
155
+ skipping_block = False
156
+ for line in frontmatter_text.splitlines():
157
+ stripped = line.strip()
158
+ if skipping_block:
159
+ if line.startswith((" ", "\t")):
160
+ continue
161
+ skipping_block = False
162
+ if stripped in {"scripts:", "agent_scripts:"}:
163
+ skipping_block = True
164
+ continue
165
+ filtered_lines.append(line)
166
+ return "\n".join(filtered_lines)
167
+
168
+
169
+ def _raise_template_discovery_error(commands_dir: Path) -> None:
170
+ """Raise an informative error about template discovery failure."""
171
+ env_root = os.environ.get("SPEC_KITTY_TEMPLATE_ROOT")
172
+ remote_repo = os.environ.get("SPECIFY_TEMPLATE_REPO")
173
+
174
+ error_msg = (
175
+ "Templates could not be found in any of the expected locations:\n\n"
176
+ "Checked paths (in order):\n"
177
+ f" ✗ Packaged resources (bundled with CLI)\n"
178
+ f" ✗ Environment variable SPEC_KITTY_TEMPLATE_ROOT" +
179
+ (f" = {env_root}" if env_root else " (not set)") + "\n" +
180
+ f" ✗ Remote repository SPECIFY_TEMPLATE_REPO" +
181
+ (f" = {remote_repo}" if remote_repo else " (not configured)") + "\n\n"
182
+ "To fix this, try one of these approaches:\n\n"
183
+ "1. Reinstall from PyPI (recommended for end users):\n"
184
+ " pip install --upgrade spec-kitty-cli\n\n"
185
+ "2. Use --template-root flag (for development):\n"
186
+ " spec-kitty init . --template-root=/path/to/spec-kitty\n\n"
187
+ "3. Set environment variable (for development):\n"
188
+ " export SPEC_KITTY_TEMPLATE_ROOT=/path/to/spec-kitty\n"
189
+ " spec-kitty init .\n\n"
190
+ "4. Configure remote repository:\n"
191
+ " export SPECIFY_TEMPLATE_REPO=owner/repo\n"
192
+ " spec-kitty init .\n\n"
193
+ "For development installs from source, use:\n"
194
+ " export SPEC_KITTY_TEMPLATE_ROOT=$(git rev-parse --show-toplevel)\n"
195
+ " spec-kitty init . --ai=claude"
196
+ )
197
+
198
+ raise FileNotFoundError(error_msg)
199
+
200
+
201
+ __all__ = [
202
+ "generate_agent_assets",
203
+ "prepare_command_templates",
204
+ "render_command_template",
205
+ "_convert_markdown_syntax_to_format",
206
+ ]