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,506 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone helpers for Spec Kitty task prompt management."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ # IMPORTANT: Keep in sync with src/specify_cli/tasks_support.py
16
+ LANES: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
17
+
18
+ TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
19
+
20
+ # Lane directories that indicate legacy format when they contain .md files
21
+ LEGACY_LANE_DIRS: List[str] = ["planned", "doing", "for_review", "done"]
22
+
23
+
24
+ def is_legacy_format(feature_path: Path) -> bool:
25
+ """Check if feature uses legacy directory-based lanes.
26
+
27
+ A feature is considered to use legacy format if:
28
+ - It has a tasks/ subdirectory
29
+ - Any of the lane subdirectories (planned/, doing/, for_review/, done/)
30
+ exist AND contain at least one .md file
31
+
32
+ Args:
33
+ feature_path: Path to the feature directory (e.g., kitty-specs/007-feature/)
34
+
35
+ Returns:
36
+ True if legacy directory-based lanes detected, False otherwise.
37
+
38
+ Note:
39
+ Empty lane directories (containing only .gitkeep) are NOT considered
40
+ legacy format - only directories with actual .md work package files.
41
+ """
42
+ tasks_dir = feature_path / "tasks"
43
+ if not tasks_dir.exists():
44
+ return False
45
+
46
+ for lane in LEGACY_LANE_DIRS:
47
+ lane_path = tasks_dir / lane
48
+ if lane_path.is_dir():
49
+ # Check if there are any .md files (not just .gitkeep)
50
+ md_files = list(lane_path.glob("*.md"))
51
+ if md_files:
52
+ return True
53
+
54
+ return False
55
+
56
+
57
+ class TaskCliError(RuntimeError):
58
+ """Raised when task operations cannot be completed safely."""
59
+
60
+
61
+ def find_repo_root(start: Optional[Path] = None) -> Path:
62
+ """Find the MAIN repository root, even when inside a worktree.
63
+
64
+ This function correctly handles git worktrees by detecting when .git is a
65
+ file (worktree pointer) vs a directory (main repo), and following the
66
+ pointer back to the main repository.
67
+
68
+ Args:
69
+ start: Starting directory for search (defaults to cwd)
70
+
71
+ Returns:
72
+ Path to the main repository root
73
+
74
+ Raises:
75
+ TaskCliError: If repository root cannot be found
76
+ """
77
+ current = (start or Path.cwd()).resolve()
78
+
79
+ for candidate in [current, *current.parents]:
80
+ git_path = candidate / ".git"
81
+
82
+ if git_path.is_file():
83
+ # This is a worktree! The .git file contains a pointer to the main repo.
84
+ # Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
85
+ try:
86
+ content = git_path.read_text().strip()
87
+ if content.startswith("gitdir:"):
88
+ gitdir = Path(content.split(":", 1)[1].strip())
89
+ # Navigate: .git/worktrees/name -> .git -> main repo root
90
+ # gitdir points to .git/worktrees/xxx, so .parent.parent is .git
91
+ main_git_dir = gitdir.parent.parent
92
+ main_repo = main_git_dir.parent
93
+ if main_repo.exists():
94
+ return main_repo
95
+ except (OSError, ValueError):
96
+ # If we can't read or parse the .git file, continue searching
97
+ pass
98
+
99
+ elif git_path.is_dir():
100
+ # This is the main repo (or a regular git repo)
101
+ return candidate
102
+
103
+ # Also check for .kittify marker (fallback for non-git scenarios)
104
+ if (candidate / ".kittify").exists():
105
+ return candidate
106
+
107
+ raise TaskCliError("Unable to locate repository root (missing .git or .kittify).")
108
+
109
+
110
+ def run_git(args: List[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
111
+ """Run a git command inside the repository."""
112
+ try:
113
+ return subprocess.run(
114
+ ["git", *args],
115
+ cwd=str(cwd),
116
+ check=check,
117
+ text=True,
118
+ capture_output=True,
119
+ )
120
+ except FileNotFoundError as exc:
121
+ raise TaskCliError("git is not available on PATH.") from exc
122
+ except subprocess.CalledProcessError as exc:
123
+ if check:
124
+ message = exc.stderr.strip() or exc.stdout.strip() or "Unknown git error"
125
+ raise TaskCliError(message)
126
+ return exc
127
+
128
+
129
+ def ensure_lane(value: str) -> str:
130
+ lane = value.strip().lower()
131
+ if lane not in LANES:
132
+ raise TaskCliError(f"Invalid lane '{value}'. Expected one of {', '.join(LANES)}.")
133
+ return lane
134
+
135
+
136
+ def now_utc() -> str:
137
+ return datetime.now(timezone.utc).strftime(TIMESTAMP_FORMAT)
138
+
139
+
140
+ def git_status_lines(repo_root: Path) -> List[str]:
141
+ result = run_git(["status", "--porcelain"], cwd=repo_root, check=True)
142
+ return [line for line in result.stdout.splitlines() if line.strip()]
143
+
144
+
145
+ def _normalize_status_path(raw: str) -> str:
146
+ candidate = raw.split(" -> ", 1)[0].strip()
147
+ candidate = candidate.lstrip("./")
148
+ return candidate.replace("\\", "/")
149
+
150
+
151
+ def path_has_changes(status_lines: List[str], path: Path) -> bool:
152
+ """Return True if git status indicates modifications for the given path."""
153
+ normalized = _normalize_status_path(str(path))
154
+ for line in status_lines:
155
+ if len(line) < 4:
156
+ continue
157
+ candidate = _normalize_status_path(line[3:])
158
+ if candidate == normalized:
159
+ return True
160
+ return False
161
+
162
+
163
+ def normalize_note(note: Optional[str], target_lane: str) -> str:
164
+ default = f"Moved to {target_lane}"
165
+ cleaned = (note or default).strip()
166
+ return cleaned or default
167
+
168
+
169
+ def detect_conflicting_wp_status(
170
+ status_lines: List[str], feature: str, old_path: Path, new_path: Path
171
+ ) -> List[str]:
172
+ """Return staged work-package entries unrelated to the requested move."""
173
+ base_path = Path("kitty-specs") / feature / "tasks"
174
+ prefix = f"{base_path.as_posix()}/"
175
+ allowed = {
176
+ str(old_path).lstrip("./"),
177
+ str(new_path).lstrip("./"),
178
+ }
179
+
180
+ def _wp_suffix(path: Path) -> Optional[str]:
181
+ try:
182
+ relative = path.relative_to(base_path)
183
+ except ValueError:
184
+ return None
185
+ parts = relative.parts
186
+ if not parts:
187
+ return None
188
+ if len(parts) == 1:
189
+ return parts[0]
190
+ return Path(*parts[1:]).as_posix()
191
+
192
+ suffixes = {suffix for suffix in (_wp_suffix(old_path), _wp_suffix(new_path)) if suffix}
193
+ conflicts = []
194
+ for line in status_lines:
195
+ path = line[3:] if len(line) > 3 else ""
196
+ if not path.startswith(prefix):
197
+ continue
198
+ clean = path.strip()
199
+ if clean not in allowed:
200
+ if suffixes and line and line[0] == "D":
201
+ for suffix in suffixes:
202
+ if clean.endswith(suffix):
203
+ break
204
+ else:
205
+ conflicts.append(line)
206
+ continue
207
+ continue
208
+ conflicts.append(line)
209
+ return conflicts
210
+
211
+
212
+ def match_frontmatter_line(frontmatter: str, key: str) -> Optional[re.Match]:
213
+ pattern = re.compile(
214
+ rf"^({re.escape(key)}:\s*)(\".*?\"|'.*?'|[^#\n]*)(.*)$",
215
+ flags=re.MULTILINE,
216
+ )
217
+ return pattern.search(frontmatter)
218
+
219
+
220
+ def extract_scalar(frontmatter: str, key: str) -> Optional[str]:
221
+ match = match_frontmatter_line(frontmatter, key)
222
+ if not match:
223
+ return None
224
+ raw_value = match.group(2).strip()
225
+ if raw_value.startswith('"') and raw_value.endswith('"'):
226
+ return raw_value[1:-1]
227
+ if raw_value.startswith("'") and raw_value.endswith("'"):
228
+ return raw_value[1:-1]
229
+ return raw_value.strip() or None
230
+
231
+
232
+ def set_scalar(frontmatter: str, key: str, value: str) -> str:
233
+ """Replace or insert a scalar value while preserving trailing comments."""
234
+ match = match_frontmatter_line(frontmatter, key)
235
+ replacement_line = f'{key}: "{value}"'
236
+ if match:
237
+ prefix = match.group(1)
238
+ comment = match.group(3)
239
+ comment_suffix = f"{comment}" if comment else ""
240
+ return (
241
+ frontmatter[: match.start()]
242
+ + f'{prefix}"{value}"{comment_suffix}'
243
+ + frontmatter[match.end() :]
244
+ )
245
+
246
+ insertion = f"{replacement_line}\n"
247
+ history_match = re.compile(r"^\s*history:\s*$", flags=re.MULTILINE).search(frontmatter)
248
+ if history_match:
249
+ idx = history_match.start()
250
+ return frontmatter[:idx] + insertion + frontmatter[idx:]
251
+
252
+ if frontmatter and not frontmatter.endswith("\n"):
253
+ frontmatter += "\n"
254
+ return frontmatter + insertion
255
+
256
+
257
+ def split_frontmatter(text: str) -> Tuple[str, str, str]:
258
+ """Return (frontmatter, body, padding) while preserving spacing after frontmatter."""
259
+ normalized = text.replace("\r\n", "\n")
260
+ if not normalized.startswith("---\n"):
261
+ return "", normalized, ""
262
+
263
+ closing_idx = normalized.find("\n---", 4)
264
+ if closing_idx == -1:
265
+ return "", normalized, ""
266
+
267
+ front = normalized[4:closing_idx]
268
+ tail = normalized[closing_idx + 4 :]
269
+ padding = ""
270
+ while tail.startswith("\n"):
271
+ padding += "\n"
272
+ tail = tail[1:]
273
+ return front, tail, padding
274
+
275
+
276
+ def build_document(frontmatter: str, body: str, padding: str) -> str:
277
+ frontmatter = frontmatter.rstrip("\n")
278
+ doc = f"---\n{frontmatter}\n---"
279
+ if padding or body:
280
+ doc += padding or "\n"
281
+ doc += body
282
+ if not doc.endswith("\n"):
283
+ doc += "\n"
284
+ return doc
285
+
286
+
287
+ def append_activity_log(body: str, entry: str) -> str:
288
+ header = "## Activity Log"
289
+ if header not in body:
290
+ block = f"{header}\n\n{entry}\n"
291
+ if body and not body.endswith("\n\n"):
292
+ return body.rstrip() + "\n\n" + block
293
+ return body + "\n" + block if body else block
294
+
295
+ pattern = re.compile(r"(## Activity Log.*?)(?=\n## |\Z)", flags=re.DOTALL)
296
+ match = pattern.search(body)
297
+ if not match:
298
+ return body + ("\n" if not body.endswith("\n") else "") + entry + "\n"
299
+
300
+ section = match.group(1).rstrip()
301
+ if not section.endswith("\n"):
302
+ section += "\n"
303
+ section += f"{entry}\n"
304
+ return body[: match.start(1)] + section + body[match.end(1) :]
305
+
306
+
307
+ def activity_entries(body: str) -> List[Dict[str, str]]:
308
+ # Match both en-dash (–) and hyphen (-) as separators
309
+ # The separator is always surrounded by whitespace, so we match non-whitespace for fields
310
+ pattern = re.compile(
311
+ r"^\s*-\s*"
312
+ r"(?P<timestamp>[0-9T:-]+Z)\s+[–-]\s+"
313
+ r"(?P<agent>\S+(?:\s+\S+)*?)\s+[–-]\s+"
314
+ r"(?:shell_pid=(?P<shell>\S*)\s+[–-]\s+)?"
315
+ r"lane=(?P<lane>[a-z_]+)\s+[–-]\s+"
316
+ r"(?P<note>.*)$",
317
+ flags=re.MULTILINE,
318
+ )
319
+ entries: List[Dict[str, str]] = []
320
+ for match in pattern.finditer(body):
321
+ entries.append(
322
+ {
323
+ "timestamp": match.group("timestamp").strip(),
324
+ "agent": match.group("agent").strip(),
325
+ "lane": match.group("lane").strip(),
326
+ "note": match.group("note").strip(),
327
+ "shell_pid": (match.group("shell") or "").strip(),
328
+ }
329
+ )
330
+ return entries
331
+
332
+
333
+ @dataclass
334
+ class WorkPackage:
335
+ feature: str
336
+ path: Path
337
+ current_lane: str
338
+ relative_subpath: Path
339
+ frontmatter: str
340
+ body: str
341
+ padding: str
342
+
343
+ @property
344
+ def work_package_id(self) -> Optional[str]:
345
+ return extract_scalar(self.frontmatter, "work_package_id")
346
+
347
+ @property
348
+ def title(self) -> Optional[str]:
349
+ return extract_scalar(self.frontmatter, "title")
350
+
351
+ @property
352
+ def assignee(self) -> Optional[str]:
353
+ return extract_scalar(self.frontmatter, "assignee")
354
+
355
+ @property
356
+ def agent(self) -> Optional[str]:
357
+ return extract_scalar(self.frontmatter, "agent")
358
+
359
+ @property
360
+ def shell_pid(self) -> Optional[str]:
361
+ return extract_scalar(self.frontmatter, "shell_pid")
362
+
363
+ @property
364
+ def lane(self) -> Optional[str]:
365
+ return extract_scalar(self.frontmatter, "lane")
366
+
367
+
368
+ def locate_work_package(repo_root: Path, feature: str, wp_id: str) -> WorkPackage:
369
+ """Locate a work package by ID, supporting both legacy and new formats.
370
+
371
+ Legacy format: WP files in tasks/{lane}/ subdirectories
372
+ New format: WP files in flat tasks/ directory with lane in frontmatter
373
+ """
374
+ feature_path = repo_root / "kitty-specs" / feature
375
+ tasks_root = feature_path / "tasks"
376
+ if not tasks_root.exists():
377
+ raise TaskCliError(f"Feature '{feature}' has no tasks directory at {tasks_root}.")
378
+
379
+ # Use exact WP ID matching with word boundary to avoid WP04 matching WP04b
380
+ # Matches: WP04.md, WP04-something.md, WP04_something.md
381
+ # Does NOT match: WP04b.md, WP04b-something.md
382
+ wp_pattern = re.compile(rf"^{re.escape(wp_id)}(?:[-_.]|\.md$)")
383
+
384
+ use_legacy = is_legacy_format(feature_path)
385
+ candidates = []
386
+
387
+ if use_legacy:
388
+ # Legacy format: search lane subdirectories
389
+ for lane_dir in tasks_root.iterdir():
390
+ if not lane_dir.is_dir():
391
+ continue
392
+ lane = lane_dir.name
393
+ for path in lane_dir.rglob("*.md"):
394
+ if wp_pattern.match(path.name):
395
+ candidates.append((lane, path, lane_dir))
396
+ else:
397
+ # New format: search flat tasks/ directory
398
+ for path in tasks_root.glob("*.md"):
399
+ if path.name.lower() == "readme.md":
400
+ continue
401
+ if wp_pattern.match(path.name):
402
+ # Get lane from frontmatter
403
+ lane = get_lane_from_frontmatter(path, warn_on_missing=False)
404
+ candidates.append((lane, path, tasks_root))
405
+
406
+ if not candidates:
407
+ raise TaskCliError(f"Work package '{wp_id}' not found under kitty-specs/{feature}/tasks.")
408
+ if len(candidates) > 1:
409
+ joined = "\n".join(str(item[1].relative_to(repo_root)) for item in candidates)
410
+ raise TaskCliError(
411
+ f"Multiple files matched '{wp_id}'. Refine the ID or clean duplicates:\n{joined}"
412
+ )
413
+
414
+ lane, path, base_dir = candidates[0]
415
+ text = path.read_text(encoding="utf-8-sig")
416
+ front, body, padding = split_frontmatter(text)
417
+ relative = path.relative_to(base_dir)
418
+ return WorkPackage(
419
+ feature=feature,
420
+ path=path,
421
+ current_lane=lane,
422
+ relative_subpath=relative,
423
+ frontmatter=front,
424
+ body=body,
425
+ padding=padding,
426
+ )
427
+
428
+
429
+ def load_meta(meta_path: Path) -> Dict:
430
+ if not meta_path.exists():
431
+ raise TaskCliError(f"Meta file not found at {meta_path}")
432
+ return json.loads(meta_path.read_text(encoding="utf-8-sig"))
433
+
434
+
435
+ def get_lane_from_frontmatter(wp_path: Path, warn_on_missing: bool = True) -> str:
436
+ """Extract lane from WP file frontmatter.
437
+
438
+ This is the authoritative way to determine a work package's lane
439
+ in the frontmatter-only lane system.
440
+
441
+ Args:
442
+ wp_path: Path to the work package markdown file
443
+ warn_on_missing: If True, print warning when lane field is missing
444
+
445
+ Returns:
446
+ Lane value (planned, doing, for_review, done)
447
+
448
+ Raises:
449
+ ValueError: If lane value is not in LANES
450
+ """
451
+ content = wp_path.read_text(encoding="utf-8-sig")
452
+ frontmatter, _, _ = split_frontmatter(content)
453
+
454
+ lane = extract_scalar(frontmatter, "lane")
455
+
456
+ if lane is None:
457
+ if warn_on_missing:
458
+ # Import here to avoid circular dependency issues
459
+ try:
460
+ from rich.console import Console
461
+ console = Console(stderr=True)
462
+ console.print(
463
+ f"[yellow]Warning: {wp_path.name} missing lane field, "
464
+ f"defaulting to 'planned'[/yellow]"
465
+ )
466
+ except ImportError:
467
+ import sys
468
+ print(
469
+ f"Warning: {wp_path.name} missing lane field, defaulting to 'planned'",
470
+ file=sys.stderr
471
+ )
472
+ return "planned"
473
+
474
+ if lane not in LANES:
475
+ raise ValueError(
476
+ f"Invalid lane '{lane}' in {wp_path.name}. "
477
+ f"Valid lanes: {', '.join(LANES)}"
478
+ )
479
+
480
+ return lane
481
+
482
+
483
+ __all__ = [
484
+ "LANES",
485
+ "TIMESTAMP_FORMAT",
486
+ "TaskCliError",
487
+ "WorkPackage",
488
+ "append_activity_log",
489
+ "activity_entries",
490
+ "build_document",
491
+ "detect_conflicting_wp_status",
492
+ "ensure_lane",
493
+ "extract_scalar",
494
+ "find_repo_root",
495
+ "get_lane_from_frontmatter",
496
+ "git_status_lines",
497
+ "is_legacy_format",
498
+ "load_meta",
499
+ "locate_work_package",
500
+ "normalize_note",
501
+ "now_utc",
502
+ "path_has_changes",
503
+ "run_git",
504
+ "set_scalar",
505
+ "split_frontmatter",
506
+ ]