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,362 @@
1
+ """Context validation for location-aware commands.
2
+
3
+ This module provides runtime validation to ensure commands are executed
4
+ in the correct location (main repository vs worktree). Prevents common
5
+ mistakes like running 'implement' from inside a worktree.
6
+
7
+ Example:
8
+ from specify_cli.core.context_validation import (
9
+ require_main_repo,
10
+ require_worktree,
11
+ get_current_context,
12
+ )
13
+
14
+ @require_main_repo
15
+ def implement(wp_id: str):
16
+ # This function can only run from main repo
17
+ pass
18
+
19
+ @require_worktree
20
+ def some_workspace_command():
21
+ # This function can only run from inside a worktree
22
+ pass
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ import os
29
+ from dataclasses import dataclass
30
+ from enum import Enum
31
+ from pathlib import Path
32
+ from typing import Callable, TypeVar
33
+
34
+ import typer
35
+ from rich.console import Console
36
+
37
+ console = Console()
38
+
39
+
40
+ class ExecutionContext(str, Enum):
41
+ """Execution context for a command."""
42
+
43
+ MAIN_REPO = "main" # Command runs in main repository
44
+ WORKTREE = "worktree" # Command runs inside a worktree
45
+ EITHER = "either" # Command can run in either location
46
+
47
+
48
+ @dataclass
49
+ class CurrentContext:
50
+ """Current execution context information."""
51
+
52
+ location: ExecutionContext
53
+ cwd: Path
54
+ repo_root: Path | None
55
+ worktree_name: str | None # e.g., "010-feature-WP02" if in worktree
56
+ worktree_path: Path | None # Absolute path to worktree directory
57
+
58
+
59
+ def detect_execution_context(cwd: Path | None = None) -> CurrentContext:
60
+ """Detect current execution context.
61
+
62
+ Args:
63
+ cwd: Current working directory (defaults to Path.cwd())
64
+
65
+ Returns:
66
+ CurrentContext with location, paths, and worktree info
67
+
68
+ Example:
69
+ >>> ctx = detect_execution_context()
70
+ >>> if ctx.location == ExecutionContext.WORKTREE:
71
+ ... print(f"In worktree: {ctx.worktree_name}")
72
+ """
73
+ if cwd is None:
74
+ cwd = Path.cwd().resolve()
75
+ else:
76
+ cwd = cwd.resolve()
77
+
78
+ # Check if .worktrees is in path
79
+ if ".worktrees" in cwd.parts:
80
+ # Extract worktree information
81
+ for i, part in enumerate(cwd.parts):
82
+ if part == ".worktrees" and i + 1 < len(cwd.parts):
83
+ worktree_name = cwd.parts[i + 1]
84
+ worktree_path = Path(*cwd.parts[: i + 2])
85
+ repo_root = Path(*cwd.parts[:i])
86
+
87
+ return CurrentContext(
88
+ location=ExecutionContext.WORKTREE,
89
+ cwd=cwd,
90
+ repo_root=repo_root,
91
+ worktree_name=worktree_name,
92
+ worktree_path=worktree_path,
93
+ )
94
+
95
+ # Not in worktree - assume main repo
96
+ # Try to find repo root (directory containing .kittify or .git)
97
+ repo_root = None
98
+ search_path = cwd
99
+ for _ in range(10): # Limit depth
100
+ if (search_path / ".kittify").exists() or (search_path / ".git").exists():
101
+ repo_root = search_path
102
+ break
103
+ if search_path.parent == search_path:
104
+ break # Reached filesystem root
105
+ search_path = search_path.parent
106
+
107
+ return CurrentContext(
108
+ location=ExecutionContext.MAIN_REPO,
109
+ cwd=cwd,
110
+ repo_root=repo_root,
111
+ worktree_name=None,
112
+ worktree_path=None,
113
+ )
114
+
115
+
116
+ def get_current_context() -> CurrentContext:
117
+ """Get current execution context.
118
+
119
+ Convenience function that detects context from current working directory.
120
+
121
+ Returns:
122
+ CurrentContext with location and path information
123
+ """
124
+ return detect_execution_context()
125
+
126
+
127
+ def format_location_error(
128
+ required: ExecutionContext,
129
+ actual: ExecutionContext,
130
+ command_name: str,
131
+ current_ctx: CurrentContext,
132
+ ) -> str:
133
+ """Format a clear error message for location mismatch.
134
+
135
+ Args:
136
+ required: Required execution context
137
+ actual: Actual execution context
138
+ command_name: Name of command being run
139
+ current_ctx: Current context information
140
+
141
+ Returns:
142
+ Formatted error message with actionable instructions
143
+ """
144
+ if required == ExecutionContext.MAIN_REPO and actual == ExecutionContext.WORKTREE:
145
+ # Command needs main repo, but in worktree
146
+ if current_ctx.repo_root:
147
+ return (
148
+ f"[bold red]Error:[/bold red] '{command_name}' must run from the main repository\n\n"
149
+ f"[yellow]Current location:[/yellow] Inside worktree [cyan]{current_ctx.worktree_name}[/cyan]\n"
150
+ f"[yellow]Required location:[/yellow] Main repository\n\n"
151
+ f"[bold]Change to main repository:[/bold]\n"
152
+ f" cd {current_ctx.repo_root}\n\n"
153
+ f"[dim]This command creates/manages worktrees and must run from the main repository.\n"
154
+ f"Running from inside a worktree would create nested worktrees, corrupting git state.[/dim]"
155
+ )
156
+ else:
157
+ return (
158
+ f"[bold red]Error:[/bold red] '{command_name}' must run from the main repository\n\n"
159
+ f"[yellow]Current location:[/yellow] Inside worktree [cyan]{current_ctx.worktree_name}[/cyan]\n"
160
+ f"[yellow]Required location:[/yellow] Main repository\n\n"
161
+ f"[bold]Change to main repository:[/bold]\n"
162
+ f" cd ../.. # Navigate up from worktree\n\n"
163
+ f"[dim]This command must run from the main repository.[/dim]"
164
+ )
165
+
166
+ elif required == ExecutionContext.WORKTREE and actual == ExecutionContext.MAIN_REPO:
167
+ # Command needs worktree, but in main repo
168
+ return (
169
+ f"[bold red]Error:[/bold red] '{command_name}' must run from inside a worktree\n\n"
170
+ f"[yellow]Current location:[/yellow] Main repository\n"
171
+ f"[yellow]Required location:[/yellow] Inside a worktree\n\n"
172
+ f"[bold]Change to a worktree:[/bold]\n"
173
+ f" cd .worktrees/###-feature-WP##/\n\n"
174
+ f"[dim]This command operates on workspace files and must run from inside a worktree.[/dim]"
175
+ )
176
+
177
+ else:
178
+ # Generic error
179
+ return (
180
+ f"[bold red]Error:[/bold red] '{command_name}' cannot run in current location\n\n"
181
+ f"[yellow]Current location:[/yellow] {actual.value}\n"
182
+ f"[yellow]Required location:[/yellow] {required.value}\n"
183
+ )
184
+
185
+
186
+ # Type variable for function decoration
187
+ F = TypeVar("F", bound=Callable)
188
+
189
+
190
+ def require_main_repo(func: F) -> F:
191
+ """Decorator to require command runs from main repository.
192
+
193
+ Prevents commands from running inside worktrees, which could cause
194
+ nested worktrees or other git corruption.
195
+
196
+ Example:
197
+ @require_main_repo
198
+ def implement(wp_id: str):
199
+ # Can only run from main repo
200
+ create_worktree(...)
201
+
202
+ Args:
203
+ func: Function to decorate
204
+
205
+ Returns:
206
+ Decorated function that validates location before executing
207
+ """
208
+
209
+ @functools.wraps(func)
210
+ def wrapper(*args, **kwargs):
211
+ ctx = get_current_context()
212
+
213
+ if ctx.location == ExecutionContext.WORKTREE:
214
+ error_msg = format_location_error(
215
+ required=ExecutionContext.MAIN_REPO,
216
+ actual=ctx.location,
217
+ command_name=func.__name__,
218
+ current_ctx=ctx,
219
+ )
220
+ console.print(error_msg)
221
+ raise typer.Exit(1)
222
+
223
+ return func(*args, **kwargs)
224
+
225
+ return wrapper # type: ignore
226
+
227
+
228
+ def require_worktree(func: F) -> F:
229
+ """Decorator to require command runs from inside a worktree.
230
+
231
+ Prevents commands from running in main repo when they need workspace context.
232
+
233
+ Example:
234
+ @require_worktree
235
+ def workspace_status():
236
+ # Can only run from inside worktree
237
+ show_workspace_info(...)
238
+
239
+ Args:
240
+ func: Function to decorate
241
+
242
+ Returns:
243
+ Decorated function that validates location before executing
244
+ """
245
+
246
+ @functools.wraps(func)
247
+ def wrapper(*args, **kwargs):
248
+ ctx = get_current_context()
249
+
250
+ if ctx.location == ExecutionContext.MAIN_REPO:
251
+ error_msg = format_location_error(
252
+ required=ExecutionContext.WORKTREE,
253
+ actual=ctx.location,
254
+ command_name=func.__name__,
255
+ current_ctx=ctx,
256
+ )
257
+ console.print(error_msg)
258
+ raise typer.Exit(1)
259
+
260
+ return func(*args, **kwargs)
261
+
262
+ return wrapper # type: ignore
263
+
264
+
265
+ def require_either(func: F) -> F:
266
+ """Decorator for commands that can run in either location.
267
+
268
+ This is primarily for documentation - the decorator doesn't enforce
269
+ anything, just marks the function as location-agnostic.
270
+
271
+ Example:
272
+ @require_either
273
+ def status():
274
+ # Can run from main repo or worktree
275
+ ctx = get_current_context()
276
+ if ctx.location == ExecutionContext.WORKTREE:
277
+ show_worktree_status()
278
+ else:
279
+ show_main_repo_status()
280
+
281
+ Args:
282
+ func: Function to decorate
283
+
284
+ Returns:
285
+ Original function (no validation added)
286
+ """
287
+ return func
288
+
289
+
290
+ def set_context_env_vars(ctx: CurrentContext) -> None:
291
+ """Set environment variables for current context.
292
+
293
+ Makes context information available to subprocesses and scripts.
294
+
295
+ Environment variables set:
296
+ SPEC_KITTY_CONTEXT: "main" or "worktree"
297
+ SPEC_KITTY_CWD: Current working directory
298
+ SPEC_KITTY_REPO_ROOT: Repository root (if detected)
299
+ SPEC_KITTY_WORKTREE_NAME: Worktree name (if in worktree)
300
+ SPEC_KITTY_WORKTREE_PATH: Worktree path (if in worktree)
301
+
302
+ Args:
303
+ ctx: Current context information
304
+
305
+ Example:
306
+ >>> ctx = get_current_context()
307
+ >>> set_context_env_vars(ctx)
308
+ >>> print(os.environ.get("SPEC_KITTY_CONTEXT"))
309
+ "main"
310
+ """
311
+ os.environ["SPEC_KITTY_CONTEXT"] = ctx.location.value
312
+ os.environ["SPEC_KITTY_CWD"] = str(ctx.cwd)
313
+
314
+ if ctx.repo_root:
315
+ os.environ["SPEC_KITTY_REPO_ROOT"] = str(ctx.repo_root)
316
+ else:
317
+ os.environ.pop("SPEC_KITTY_REPO_ROOT", None)
318
+
319
+ if ctx.worktree_name:
320
+ os.environ["SPEC_KITTY_WORKTREE_NAME"] = ctx.worktree_name
321
+ else:
322
+ os.environ.pop("SPEC_KITTY_WORKTREE_NAME", None)
323
+
324
+ if ctx.worktree_path:
325
+ os.environ["SPEC_KITTY_WORKTREE_PATH"] = str(ctx.worktree_path)
326
+ else:
327
+ os.environ.pop("SPEC_KITTY_WORKTREE_PATH", None)
328
+
329
+
330
+ def get_context_env_vars() -> dict[str, str]:
331
+ """Get current context environment variables.
332
+
333
+ Returns:
334
+ Dictionary of context environment variables
335
+ """
336
+ env_vars = {}
337
+
338
+ for key in [
339
+ "SPEC_KITTY_CONTEXT",
340
+ "SPEC_KITTY_CWD",
341
+ "SPEC_KITTY_REPO_ROOT",
342
+ "SPEC_KITTY_WORKTREE_NAME",
343
+ "SPEC_KITTY_WORKTREE_PATH",
344
+ ]:
345
+ value = os.environ.get(key)
346
+ if value:
347
+ env_vars[key] = value
348
+
349
+ return env_vars
350
+
351
+
352
+ __all__ = [
353
+ "ExecutionContext",
354
+ "CurrentContext",
355
+ "detect_execution_context",
356
+ "get_current_context",
357
+ "require_main_repo",
358
+ "require_worktree",
359
+ "require_either",
360
+ "set_context_env_vars",
361
+ "get_context_env_vars",
362
+ ]
@@ -0,0 +1,351 @@
1
+ """Dependency graph utilities for work package relationships.
2
+
3
+ This module provides functions for parsing, validating, and analyzing
4
+ dependency relationships between work packages in Spec Kitty features.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from specify_cli.frontmatter import FrontmatterError, read_frontmatter
14
+
15
+
16
+ def parse_wp_dependencies(wp_file: Path) -> list[str]:
17
+ """Parse dependencies from WP frontmatter.
18
+
19
+ Uses FrontmatterManager for consistent parsing across CLI.
20
+
21
+ Args:
22
+ wp_file: Path to work package markdown file
23
+
24
+ Returns:
25
+ List of WP IDs this WP depends on (e.g., ["WP01", "WP02"])
26
+ Returns empty list if no dependencies or parsing fails
27
+
28
+ Examples:
29
+ >>> wp_file = Path("tasks/WP02.md")
30
+ >>> deps = parse_wp_dependencies(wp_file)
31
+ >>> print(deps) # ["WP01"]
32
+ """
33
+ try:
34
+ # Use FrontmatterManager for consistent parsing
35
+ frontmatter, _ = read_frontmatter(wp_file)
36
+
37
+ # Extract dependencies field (FrontmatterManager already defaults to [])
38
+ dependencies = frontmatter.get("dependencies", [])
39
+
40
+ # Validate dependencies is a list
41
+ if not isinstance(dependencies, list):
42
+ return []
43
+
44
+ return dependencies
45
+
46
+ except (FrontmatterError, OSError):
47
+ # Return empty list on any parsing error
48
+ return []
49
+
50
+
51
+ def build_dependency_graph(feature_dir: Path) -> dict[str, list[str]]:
52
+ """Build dependency graph from all WPs in feature.
53
+
54
+ Scans tasks/ directory for WP files and parses their dependencies.
55
+ Validates that filename WP ID matches frontmatter work_package_id.
56
+
57
+ Args:
58
+ feature_dir: Path to feature directory (contains tasks/ subdirectory)
59
+ OR path to tasks directory directly
60
+
61
+ Returns:
62
+ Adjacency list mapping WP ID to list of dependencies
63
+ Example: {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01"]}
64
+
65
+ Examples:
66
+ >>> feature_dir = Path("kitty-specs/010-feature")
67
+ >>> graph = build_dependency_graph(feature_dir)
68
+ >>> print(graph) # {"WP01": [], "WP02": ["WP01"]}
69
+ """
70
+ graph = {}
71
+
72
+ # Support both feature_dir and tasks_dir as input
73
+ if feature_dir.name == "tasks":
74
+ # Already pointing to tasks directory
75
+ tasks_dir = feature_dir
76
+ else:
77
+ # Pointing to feature directory, append tasks/
78
+ tasks_dir = feature_dir / "tasks"
79
+
80
+ if not tasks_dir.exists():
81
+ return graph
82
+
83
+ # Find all WP markdown files
84
+ for wp_file in sorted(tasks_dir.glob("WP*.md")):
85
+ # Extract WP ID from filename (e.g., WP01-title.md → WP01)
86
+ filename_wp_id = extract_wp_id_from_filename(wp_file.name)
87
+ if not filename_wp_id:
88
+ continue
89
+
90
+ # Parse frontmatter to get canonical work_package_id
91
+ try:
92
+ frontmatter, _ = read_frontmatter(wp_file)
93
+ frontmatter_wp_id = frontmatter.get("work_package_id")
94
+
95
+ # Verify filename matches frontmatter (catch misnamed files)
96
+ if frontmatter_wp_id and frontmatter_wp_id != filename_wp_id:
97
+ raise ValueError(
98
+ f"WP ID mismatch: filename {filename_wp_id} vs frontmatter {frontmatter_wp_id} "
99
+ f"in {wp_file}"
100
+ )
101
+
102
+ wp_id = frontmatter_wp_id or filename_wp_id
103
+
104
+ except (FrontmatterError, OSError):
105
+ # If frontmatter read fails, skip this file
106
+ continue
107
+
108
+ # Parse dependencies from frontmatter
109
+ dependencies = parse_wp_dependencies(wp_file)
110
+ graph[wp_id] = dependencies
111
+
112
+ return graph
113
+
114
+
115
+ def extract_wp_id_from_filename(filename: str) -> Optional[str]:
116
+ """Extract WP ID from filename.
117
+
118
+ Args:
119
+ filename: WP file name (e.g., "WP01-title.md" or "WP02.md")
120
+
121
+ Returns:
122
+ WP ID (e.g., "WP01") or None if invalid format
123
+
124
+ Examples:
125
+ >>> extract_wp_id_from_filename("WP01-setup.md")
126
+ 'WP01'
127
+ >>> extract_wp_id_from_filename("invalid.md")
128
+ None
129
+ """
130
+ match = re.match(r"^(WP\d{2})", filename)
131
+ return match.group(1) if match else None
132
+
133
+
134
+ def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]] | None:
135
+ """Detect circular dependencies using DFS with coloring.
136
+
137
+ Uses depth-first search with three-color marking (white/gray/black)
138
+ to detect back edges, which indicate cycles.
139
+
140
+ Args:
141
+ graph: Adjacency list mapping WP ID to dependencies
142
+
143
+ Returns:
144
+ List of cycles (each cycle is a list of WP IDs) or None if acyclic
145
+
146
+ Complexity:
147
+ O(V + E) where V = vertices (WPs), E = edges (dependencies)
148
+
149
+ Examples:
150
+ >>> graph = {"WP01": ["WP02"], "WP02": ["WP01"]}
151
+ >>> cycles = detect_cycles(graph)
152
+ >>> print(cycles) # [["WP01", "WP02", "WP01"]]
153
+
154
+ >>> graph = {"WP01": [], "WP02": ["WP01"]}
155
+ >>> cycles = detect_cycles(graph)
156
+ >>> print(cycles) # None (acyclic)
157
+ """
158
+ WHITE, GRAY, BLACK = 0, 1, 2
159
+ color = {wp: WHITE for wp in graph}
160
+ cycles = []
161
+
162
+ def dfs(node: str, path: list[str]) -> None:
163
+ """DFS traversal with cycle detection."""
164
+ color[node] = GRAY
165
+ path.append(node)
166
+
167
+ for neighbor in graph.get(node, []):
168
+ neighbor_color = color.get(neighbor, WHITE)
169
+
170
+ if neighbor_color == GRAY:
171
+ # Back edge found - cycle detected
172
+ if neighbor in path:
173
+ cycle_start = path.index(neighbor)
174
+ cycles.append(path[cycle_start:] + [neighbor])
175
+ elif neighbor_color == WHITE:
176
+ dfs(neighbor, path)
177
+
178
+ path.pop()
179
+ color[node] = BLACK
180
+
181
+ # Run DFS from each unvisited node
182
+ for wp in graph:
183
+ if color[wp] == WHITE:
184
+ dfs(wp, [])
185
+
186
+ return cycles if cycles else None
187
+
188
+
189
+ def validate_dependencies(
190
+ wp_id: str,
191
+ declared_deps: list[str],
192
+ graph: dict[str, list[str]]
193
+ ) -> tuple[bool, list[str]]:
194
+ """Validate that WP's dependencies are valid.
195
+
196
+ Checks:
197
+ - Dependencies exist in graph
198
+ - No self-dependencies
199
+ - No circular dependencies
200
+ - Valid WP ID format
201
+
202
+ Args:
203
+ wp_id: Work package ID being validated
204
+ declared_deps: List of dependency WP IDs
205
+ graph: Complete dependency graph
206
+
207
+ Returns:
208
+ Tuple of (is_valid, error_messages)
209
+ - is_valid: True if all validations pass
210
+ - error_messages: List of error descriptions (empty if valid)
211
+
212
+ Examples:
213
+ >>> graph = {"WP01": [], "WP02": ["WP01"]}
214
+ >>> is_valid, errors = validate_dependencies("WP02", ["WP01"], graph)
215
+ >>> print(is_valid) # True
216
+
217
+ >>> is_valid, errors = validate_dependencies("WP02", ["WP99"], graph)
218
+ >>> print(is_valid) # False
219
+ >>> print(errors) # ["Dependency WP99 not found in graph"]
220
+ """
221
+ errors = []
222
+ wp_pattern = re.compile(r"^WP\d{2}$")
223
+
224
+ # Validate each dependency
225
+ for dep in declared_deps:
226
+ # Check format
227
+ if not wp_pattern.match(dep):
228
+ errors.append(f"Invalid WP ID format: {dep} (must be WP## like WP01)")
229
+ continue
230
+
231
+ # Check self-dependency
232
+ if dep == wp_id:
233
+ errors.append(f"Cannot depend on self: {wp_id} → {wp_id}")
234
+ continue
235
+
236
+ # Check dependency exists in graph
237
+ if dep not in graph:
238
+ errors.append(f"Dependency {dep} not found in graph")
239
+
240
+ # Check for circular dependencies
241
+ # Build temporary graph with this WP's dependencies to check for cycles
242
+ test_graph = graph.copy()
243
+ test_graph[wp_id] = declared_deps
244
+
245
+ cycles = detect_cycles(test_graph)
246
+ if cycles:
247
+ for cycle in cycles:
248
+ if wp_id in cycle:
249
+ errors.append(f"Circular dependency detected: {' → '.join(cycle)}")
250
+ break
251
+
252
+ is_valid = len(errors) == 0
253
+ return is_valid, errors
254
+
255
+
256
+ def topological_sort(graph: dict[str, list[str]]) -> list[str]:
257
+ """Return nodes in topological order (dependencies before dependents).
258
+
259
+ Uses Kahn's algorithm:
260
+ 1. Find all nodes with no incoming edges (no dependencies)
261
+ 2. Remove them from graph, add to result
262
+ 3. Repeat until graph is empty
263
+
264
+ Args:
265
+ graph: Adjacency list where graph[node] = [dependencies]
266
+ Note: This is REVERSE of typical adjacency (edges point to deps)
267
+
268
+ Returns:
269
+ List of node IDs in topological order
270
+
271
+ Raises:
272
+ ValueError: If graph contains a cycle (use detect_cycles() first)
273
+
274
+ Example:
275
+ >>> graph = {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01", "WP02"]}
276
+ >>> topological_sort(graph)
277
+ ['WP01', 'WP02', 'WP03']
278
+ """
279
+ # Build in-degree map and reverse adjacency
280
+ in_degree: dict[str, int] = {node: 0 for node in graph}
281
+ reverse_adj: dict[str, list[str]] = {node: [] for node in graph}
282
+
283
+ for node, deps in graph.items():
284
+ in_degree[node] = len(deps)
285
+ for dep in deps:
286
+ if dep in reverse_adj:
287
+ reverse_adj[dep].append(node)
288
+
289
+ # Start with nodes that have no dependencies
290
+ queue = [node for node, degree in in_degree.items() if degree == 0]
291
+ queue.sort() # Stable ordering for determinism
292
+
293
+ result = []
294
+ while queue:
295
+ node = queue.pop(0)
296
+ result.append(node)
297
+
298
+ # "Remove" this node by decrementing in-degree of dependents
299
+ for dependent in sorted(reverse_adj.get(node, [])):
300
+ in_degree[dependent] -= 1
301
+ if in_degree[dependent] == 0:
302
+ queue.append(dependent)
303
+ queue.sort() # Maintain sorted order
304
+
305
+ if len(result) != len(graph):
306
+ raise ValueError("Graph contains a cycle - cannot topologically sort")
307
+
308
+ return result
309
+
310
+
311
+ def get_dependents(wp_id: str, graph: dict[str, list[str]]) -> list[str]:
312
+ """Get list of WPs that depend on this WP (inverse graph query).
313
+
314
+ Builds inverse graph and returns direct dependents only (not transitive).
315
+
316
+ Args:
317
+ wp_id: Work package ID to query
318
+ graph: Dependency graph (adjacency list)
319
+
320
+ Returns:
321
+ List of WP IDs that directly depend on wp_id
322
+ Returns empty list if no dependents or WP not in graph
323
+
324
+ Examples:
325
+ >>> graph = {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01"]}
326
+ >>> deps = get_dependents("WP01", graph)
327
+ >>> print(sorted(deps)) # ["WP02", "WP03"]
328
+
329
+ >>> deps = get_dependents("WP02", graph)
330
+ >>> print(deps) # []
331
+ """
332
+ # Build inverse graph: wp -> list of wps that depend on it
333
+ inverse_graph: dict[str, list[str]] = {wp: [] for wp in graph}
334
+
335
+ for wp, dependencies in graph.items():
336
+ for dependency in dependencies:
337
+ if dependency not in inverse_graph:
338
+ inverse_graph[dependency] = []
339
+ inverse_graph[dependency].append(wp)
340
+
341
+ return inverse_graph.get(wp_id, [])
342
+
343
+
344
+ __all__ = [
345
+ "build_dependency_graph",
346
+ "detect_cycles",
347
+ "get_dependents",
348
+ "parse_wp_dependencies",
349
+ "topological_sort",
350
+ "validate_dependencies",
351
+ ]