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,129 @@
1
+ """Git and subprocess helpers for the Spec Kitty CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from rich.console import Console
11
+
12
+ ConsoleType = Console | None
13
+
14
+
15
+ def _resolve_console(console: ConsoleType) -> Console:
16
+ """Return the provided console or lazily create one."""
17
+ return console if console is not None else Console()
18
+
19
+
20
+ def run_command(
21
+ cmd: Sequence[str] | str,
22
+ *,
23
+ check_return: bool = True,
24
+ capture: bool = False,
25
+ shell: bool = False,
26
+ console: ConsoleType = None,
27
+ cwd: Path | str | None = None,
28
+ ) -> tuple[int, str, str]:
29
+ """Run a shell command and return (returncode, stdout, stderr).
30
+
31
+ Args:
32
+ cmd: Command to run
33
+ check_return: If True, raise on non-zero exit
34
+ capture: If True, capture stdout/stderr
35
+ shell: If True, run through shell
36
+ console: Rich console for output
37
+ cwd: Working directory for command execution
38
+
39
+ Returns:
40
+ Tuple of (returncode, stdout, stderr)
41
+ """
42
+ try:
43
+ result = subprocess.run(
44
+ cmd,
45
+ check=check_return,
46
+ capture_output=capture,
47
+ text=True,
48
+ shell=shell,
49
+ cwd=str(cwd) if cwd else None,
50
+ )
51
+ stdout = (result.stdout or "").strip() if capture else ""
52
+ stderr = (result.stderr or "").strip() if capture else ""
53
+ return result.returncode, stdout, stderr
54
+ except subprocess.CalledProcessError as exc:
55
+ if check_return:
56
+ resolved_console = _resolve_console(console)
57
+ resolved_console.print(f"[red]Error running command:[/red] {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
58
+ resolved_console.print(f"[red]Exit code:[/red] {exc.returncode}")
59
+ if exc.stderr:
60
+ resolved_console.print(f"[red]Error output:[/red] {exc.stderr.strip()}")
61
+ raise
62
+
63
+
64
+ def is_git_repo(path: Path | None = None) -> bool:
65
+ """Return True when the provided path lives inside a git repository."""
66
+ target = (path or Path.cwd()).resolve()
67
+ if not target.is_dir():
68
+ return False
69
+ try:
70
+ subprocess.run(
71
+ ["git", "rev-parse", "--is-inside-work-tree"],
72
+ check=True,
73
+ capture_output=True,
74
+ cwd=target,
75
+ )
76
+ return True
77
+ except (subprocess.CalledProcessError, FileNotFoundError):
78
+ return False
79
+
80
+
81
+ def init_git_repo(project_path: Path, quiet: bool = False, console: ConsoleType = None) -> bool:
82
+ """Initialize a git repository with an initial commit."""
83
+ resolved_console = _resolve_console(console)
84
+ original_cwd = Path.cwd()
85
+ try:
86
+ os.chdir(project_path)
87
+ if not quiet:
88
+ resolved_console.print("[cyan]Initializing git repository...[/cyan]")
89
+ subprocess.run(["git", "init"], check=True, capture_output=True)
90
+ subprocess.run(["git", "add", "."], check=True, capture_output=True)
91
+ subprocess.run(
92
+ ["git", "commit", "-m", "Initial commit from Specify template"],
93
+ check=True,
94
+ capture_output=True,
95
+ )
96
+ if not quiet:
97
+ resolved_console.print("[green]✓[/green] Git repository initialized")
98
+ return True
99
+ except subprocess.CalledProcessError as exc:
100
+ if not quiet:
101
+ resolved_console.print(f"[red]Error initializing git repository:[/red] {exc}")
102
+ return False
103
+ finally:
104
+ os.chdir(original_cwd)
105
+
106
+
107
+ def get_current_branch(path: Path | None = None) -> str | None:
108
+ """Return the current git branch name for the provided repository path."""
109
+ repo_path = (path or Path.cwd()).resolve()
110
+ try:
111
+ result = subprocess.run(
112
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
113
+ check=True,
114
+ capture_output=True,
115
+ text=True,
116
+ cwd=repo_path,
117
+ )
118
+ branch = result.stdout.strip()
119
+ return branch or None
120
+ except (subprocess.CalledProcessError, FileNotFoundError):
121
+ return None
122
+
123
+
124
+ __all__ = [
125
+ "get_current_branch",
126
+ "init_git_repo",
127
+ "is_git_repo",
128
+ "run_command",
129
+ ]
@@ -0,0 +1,323 @@
1
+ """Multi-parent dependency handling via automatic merge commits.
2
+
3
+ This module provides deterministic base branch calculation for work packages
4
+ with multiple dependencies. Instead of forcing the user to pick one dependency
5
+ as base and manually merge others, we automatically create a merge commit
6
+ combining all dependencies.
7
+
8
+ Example:
9
+ WP04 depends on both WP02 and WP03:
10
+
11
+ Before (ambiguous):
12
+ spec-kitty implement WP04 --base WP03 # Why WP03? Why not WP02?
13
+ cd .worktrees/010-feature-WP04/
14
+ git merge 010-feature-WP02 # Manual merge required
15
+
16
+ After (deterministic):
17
+ spec-kitty implement WP04 # Auto-detects multi-parent, creates merge
18
+ # WP04 branches from merge commit combining WP02 + WP03
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from rich.console import Console
28
+
29
+ console = Console()
30
+
31
+
32
+ @dataclass
33
+ class MergeResult:
34
+ """Result of creating a multi-parent merge base."""
35
+
36
+ success: bool
37
+ branch_name: str | None # Temporary branch name (e.g., "010-feature-WP04-merge-base")
38
+ commit_sha: str | None # SHA of merge commit
39
+ error: str | None # Error message if failed
40
+ conflicts: list[str] # List of files with conflicts (if any)
41
+
42
+
43
+ def create_multi_parent_base(
44
+ feature_slug: str,
45
+ wp_id: str,
46
+ dependencies: list[str],
47
+ repo_root: Path,
48
+ ) -> MergeResult:
49
+ """Create a merge commit combining all dependencies for a work package.
50
+
51
+ This function:
52
+ 1. Creates a temporary branch from the first dependency
53
+ 2. Merges all remaining dependencies into it
54
+ 3. Returns the merge commit SHA for use as base branch
55
+
56
+ Args:
57
+ feature_slug: Feature slug (e.g., "010-workspace-per-wp")
58
+ wp_id: Work package ID (e.g., "WP04")
59
+ dependencies: List of dependency WP IDs (e.g., ["WP02", "WP03"])
60
+ repo_root: Repository root path
61
+
62
+ Returns:
63
+ MergeResult with success status and merge commit details
64
+
65
+ Example:
66
+ result = create_multi_parent_base(
67
+ feature_slug="010-feature",
68
+ wp_id="WP04",
69
+ dependencies=["WP02", "WP03"],
70
+ repo_root=Path("."),
71
+ )
72
+ if result.success:
73
+ # Branch WP04 from result.branch_name or result.commit_sha
74
+ print(f"Merge base: {result.commit_sha}")
75
+ """
76
+ if len(dependencies) < 2:
77
+ return MergeResult(
78
+ success=False,
79
+ branch_name=None,
80
+ commit_sha=None,
81
+ error="Multi-parent merge requires at least 2 dependencies",
82
+ conflicts=[],
83
+ )
84
+
85
+ # Sort dependencies for deterministic ordering
86
+ sorted_deps = sorted(dependencies)
87
+
88
+ # Temporary branch name
89
+ temp_branch = f"{feature_slug}-{wp_id}-merge-base"
90
+
91
+ # Dependency branch names
92
+ dep_branches = [f"{feature_slug}-{dep}" for dep in sorted_deps]
93
+
94
+ try:
95
+ # Step 1: Validate all dependency branches exist
96
+ for dep, branch in zip(sorted_deps, dep_branches):
97
+ result = subprocess.run(
98
+ ["git", "rev-parse", "--verify", branch],
99
+ cwd=repo_root,
100
+ capture_output=True,
101
+ text=True,
102
+ check=False,
103
+ )
104
+ if result.returncode != 0:
105
+ return MergeResult(
106
+ success=False,
107
+ branch_name=None,
108
+ commit_sha=None,
109
+ error=f"Dependency branch {branch} does not exist (implement {dep} first)",
110
+ conflicts=[],
111
+ )
112
+
113
+ # Step 2: Check if temp branch already exists (cleanup from previous run)
114
+ result = subprocess.run(
115
+ ["git", "rev-parse", "--verify", temp_branch],
116
+ cwd=repo_root,
117
+ capture_output=True,
118
+ check=False,
119
+ )
120
+ if result.returncode == 0:
121
+ # Delete existing temp branch
122
+ subprocess.run(
123
+ ["git", "branch", "-D", temp_branch],
124
+ cwd=repo_root,
125
+ capture_output=True,
126
+ check=False,
127
+ )
128
+
129
+ # Step 3: Create temp branch from first dependency
130
+ base_branch = dep_branches[0]
131
+ result = subprocess.run(
132
+ ["git", "branch", temp_branch, base_branch],
133
+ cwd=repo_root,
134
+ capture_output=True,
135
+ text=True,
136
+ check=False,
137
+ )
138
+ if result.returncode != 0:
139
+ return MergeResult(
140
+ success=False,
141
+ branch_name=None,
142
+ commit_sha=None,
143
+ error=f"Failed to create temp branch from {base_branch}: {result.stderr}",
144
+ conflicts=[],
145
+ )
146
+
147
+ # Step 4: Checkout temp branch
148
+ result = subprocess.run(
149
+ ["git", "checkout", temp_branch],
150
+ cwd=repo_root,
151
+ capture_output=True,
152
+ text=True,
153
+ check=False,
154
+ )
155
+ if result.returncode != 0:
156
+ return MergeResult(
157
+ success=False,
158
+ branch_name=None,
159
+ commit_sha=None,
160
+ error=f"Failed to checkout temp branch: {result.stderr}",
161
+ conflicts=[],
162
+ )
163
+
164
+ # Step 5: Merge remaining dependencies
165
+ conflicts = []
166
+ for dep_branch in dep_branches[1:]:
167
+ result = subprocess.run(
168
+ ["git", "merge", "--no-edit", dep_branch, "-m",
169
+ f"Merge {dep_branch} into multi-parent base for {wp_id}"],
170
+ cwd=repo_root,
171
+ capture_output=True,
172
+ text=True,
173
+ check=False,
174
+ )
175
+
176
+ if result.returncode != 0:
177
+ # Check if merge conflicts
178
+ conflict_result = subprocess.run(
179
+ ["git", "diff", "--name-only", "--diff-filter=U"],
180
+ cwd=repo_root,
181
+ capture_output=True,
182
+ text=True,
183
+ check=False,
184
+ )
185
+ if conflict_result.returncode == 0 and conflict_result.stdout.strip():
186
+ conflicts = conflict_result.stdout.strip().split("\n")
187
+
188
+ # Abort merge
189
+ subprocess.run(
190
+ ["git", "merge", "--abort"],
191
+ cwd=repo_root,
192
+ capture_output=True,
193
+ check=False,
194
+ )
195
+
196
+ # Cleanup: delete temp branch
197
+ subprocess.run(
198
+ ["git", "checkout", "-"], # Return to previous branch
199
+ cwd=repo_root,
200
+ capture_output=True,
201
+ check=False,
202
+ )
203
+ subprocess.run(
204
+ ["git", "branch", "-D", temp_branch],
205
+ cwd=repo_root,
206
+ capture_output=True,
207
+ check=False,
208
+ )
209
+
210
+ return MergeResult(
211
+ success=False,
212
+ branch_name=None,
213
+ commit_sha=None,
214
+ error=f"Merge conflict when merging {dep_branch}",
215
+ conflicts=conflicts,
216
+ )
217
+
218
+ # Step 6: Get merge commit SHA
219
+ result = subprocess.run(
220
+ ["git", "rev-parse", "HEAD"],
221
+ cwd=repo_root,
222
+ capture_output=True,
223
+ text=True,
224
+ check=False,
225
+ )
226
+ if result.returncode != 0:
227
+ return MergeResult(
228
+ success=False,
229
+ branch_name=temp_branch,
230
+ commit_sha=None,
231
+ error="Failed to get merge commit SHA",
232
+ conflicts=[],
233
+ )
234
+
235
+ merge_commit_sha = result.stdout.strip()
236
+
237
+ # Step 7: Return to previous branch
238
+ subprocess.run(
239
+ ["git", "checkout", "-"],
240
+ cwd=repo_root,
241
+ capture_output=True,
242
+ check=False,
243
+ )
244
+
245
+ console.print(f"[cyan]→ Created merge base: {temp_branch} ({merge_commit_sha[:7]})[/cyan]")
246
+ console.print(f"[cyan] Combined dependencies: {', '.join(sorted_deps)}[/cyan]")
247
+
248
+ return MergeResult(
249
+ success=True,
250
+ branch_name=temp_branch,
251
+ commit_sha=merge_commit_sha,
252
+ error=None,
253
+ conflicts=[],
254
+ )
255
+
256
+ except Exception as e:
257
+ # Cleanup on exception
258
+ subprocess.run(
259
+ ["git", "checkout", "-"],
260
+ cwd=repo_root,
261
+ capture_output=True,
262
+ check=False,
263
+ )
264
+ subprocess.run(
265
+ ["git", "branch", "-D", temp_branch],
266
+ cwd=repo_root,
267
+ capture_output=True,
268
+ check=False,
269
+ )
270
+
271
+ return MergeResult(
272
+ success=False,
273
+ branch_name=None,
274
+ commit_sha=None,
275
+ error=f"Unexpected error: {e}",
276
+ conflicts=[],
277
+ )
278
+
279
+
280
+ def cleanup_merge_base_branch(
281
+ feature_slug: str,
282
+ wp_id: str,
283
+ repo_root: Path,
284
+ ) -> bool:
285
+ """Delete temporary merge base branch after workspace creation.
286
+
287
+ Args:
288
+ feature_slug: Feature slug (e.g., "010-workspace-per-wp")
289
+ wp_id: Work package ID (e.g., "WP04")
290
+ repo_root: Repository root path
291
+
292
+ Returns:
293
+ True if deleted, False if branch didn't exist
294
+ """
295
+ temp_branch = f"{feature_slug}-{wp_id}-merge-base"
296
+
297
+ # Check if branch exists
298
+ result = subprocess.run(
299
+ ["git", "rev-parse", "--verify", temp_branch],
300
+ cwd=repo_root,
301
+ capture_output=True,
302
+ check=False,
303
+ )
304
+
305
+ if result.returncode != 0:
306
+ return False # Branch doesn't exist
307
+
308
+ # Delete branch
309
+ result = subprocess.run(
310
+ ["git", "branch", "-D", temp_branch],
311
+ cwd=repo_root,
312
+ capture_output=True,
313
+ check=False,
314
+ )
315
+
316
+ return result.returncode == 0
317
+
318
+
319
+ __all__ = [
320
+ "MergeResult",
321
+ "create_multi_parent_base",
322
+ "cleanup_merge_base_branch",
323
+ ]
@@ -0,0 +1,260 @@
1
+ """Enhanced path resolution for spec-kitty CLI with worktree detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+ from typing import Optional, Tuple
9
+
10
+
11
+ def locate_project_root(start: Path | None = None) -> Optional[Path]:
12
+ """
13
+ Locate the MAIN spec-kitty project root directory, even from within worktrees.
14
+
15
+ This function correctly handles git worktrees by detecting when .git is a
16
+ file (worktree pointer) vs a directory (main repo), and following the
17
+ pointer back to the main repository.
18
+
19
+ Resolution order:
20
+ 1. SPECIFY_REPO_ROOT environment variable (highest priority)
21
+ 2. Walk up directory tree, detecting worktree .git files and following to main repo
22
+ 3. Fall back to .kittify/ marker search
23
+
24
+ Args:
25
+ start: Starting directory for search (defaults to current working directory)
26
+
27
+ Returns:
28
+ Path to MAIN project root (not worktree), or None if not found
29
+
30
+ Examples:
31
+ >>> # From main repo
32
+ >>> root = locate_project_root()
33
+ >>> assert (root / ".kittify").exists()
34
+
35
+ >>> # From worktree - returns MAIN repo, not worktree
36
+ >>> root = locate_project_root(Path(".worktrees/my-feature"))
37
+ >>> assert ".worktrees" not in str(root)
38
+ """
39
+ # Tier 1: Check environment variable (allows override for CI/CD)
40
+ if env_root := os.getenv("SPECIFY_REPO_ROOT"):
41
+ env_path = Path(env_root).resolve()
42
+ if env_path.exists() and (env_path / ".kittify").is_dir():
43
+ return env_path
44
+ # Invalid env var - fall through to other methods
45
+
46
+ # Tier 2: Walk up directory tree, handling worktree .git files
47
+ current = (start or Path.cwd()).resolve()
48
+
49
+ for candidate in [current, *current.parents]:
50
+ git_path = candidate / ".git"
51
+
52
+ if git_path.is_file():
53
+ # This is a worktree! The .git file contains a pointer to the main repo.
54
+ # Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
55
+ try:
56
+ content = git_path.read_text().strip()
57
+ if content.startswith("gitdir:"):
58
+ gitdir = Path(content.split(":", 1)[1].strip())
59
+ # Navigate: .git/worktrees/name -> .git -> main repo root
60
+ main_git_dir = gitdir.parent.parent
61
+ main_repo = main_git_dir.parent
62
+ if main_repo.exists() and (main_repo / ".kittify").is_dir():
63
+ return main_repo
64
+ except (OSError, ValueError):
65
+ # If we can't read or parse the .git file, continue searching
66
+ pass
67
+
68
+ elif git_path.is_dir():
69
+ # This is the main repo (or a regular git repo)
70
+ if (candidate / ".kittify").is_dir():
71
+ return candidate
72
+
73
+ # Also check for .kittify marker (fallback for non-git scenarios)
74
+ kittify_path = candidate / ".kittify"
75
+ if kittify_path.is_symlink() and not kittify_path.exists():
76
+ # Broken symlink - skip this candidate
77
+ continue
78
+ if kittify_path.is_dir():
79
+ return candidate
80
+
81
+ return None
82
+
83
+
84
+ def is_worktree_context(path: Path) -> bool:
85
+ """
86
+ Detect if the given path is within a git worktree directory.
87
+
88
+ Checks if '.worktrees' appears in the path hierarchy, indicating
89
+ execution from within a feature worktree.
90
+
91
+ Args:
92
+ path: Path to check (typically current working directory)
93
+
94
+ Returns:
95
+ True if path is within .worktrees/ directory, False otherwise
96
+
97
+ Examples:
98
+ >>> is_worktree_context(Path("/repo/.worktrees/feature-001"))
99
+ True
100
+ >>> is_worktree_context(Path("/repo/kitty-specs"))
101
+ False
102
+ """
103
+ return ".worktrees" in path.parts
104
+
105
+
106
+ def resolve_with_context(start: Path | None = None) -> Tuple[Optional[Path], bool]:
107
+ """
108
+ Resolve project root and detect worktree context in one call.
109
+
110
+ Args:
111
+ start: Starting directory for search (defaults to current working directory)
112
+
113
+ Returns:
114
+ Tuple of (project_root, is_worktree)
115
+ - project_root: Path to repo root or None if not found
116
+ - is_worktree: True if executing from within .worktrees/
117
+
118
+ Examples:
119
+ >>> # From main repo
120
+ >>> root, in_worktree = resolve_with_context()
121
+ >>> assert in_worktree is False
122
+
123
+ >>> # From worktree
124
+ >>> root, in_worktree = resolve_with_context(Path(".worktrees/my-feature"))
125
+ >>> assert in_worktree is True
126
+ """
127
+ current = (start or Path.cwd()).resolve()
128
+ root = locate_project_root(current)
129
+ in_worktree = is_worktree_context(current)
130
+ return root, in_worktree
131
+
132
+
133
+ def check_broken_symlink(path: Path) -> bool:
134
+ """
135
+ Check if a path is a broken symlink (symlink pointing to non-existent target).
136
+
137
+ This helper is useful for graceful error handling when dealing with
138
+ worktree symlinks that may become invalid.
139
+
140
+ Args:
141
+ path: Path to check
142
+
143
+ Returns:
144
+ True if path is a broken symlink, False otherwise
145
+
146
+ Note:
147
+ A broken symlink returns True for is_symlink() but False for exists().
148
+ Always check is_symlink() before exists() to detect this condition.
149
+ """
150
+ return path.is_symlink() and not path.exists()
151
+
152
+
153
+ def get_main_repo_root(current_path: Path) -> Path:
154
+ """
155
+ Get the main repository root, even if called from a worktree.
156
+
157
+ When in a worktree, .git is a file pointing to the main repo's .git directory.
158
+ This function follows that pointer to find the main repo root.
159
+
160
+ Args:
161
+ current_path: Current repo root (may be worktree or main repo)
162
+
163
+ Returns:
164
+ Path to the main repository root (resolves worktree pointers)
165
+
166
+ Examples:
167
+ >>> # From main repo - returns same path
168
+ >>> get_main_repo_root(Path("/repo"))
169
+ Path('/repo')
170
+
171
+ >>> # From worktree - returns main repo
172
+ >>> get_main_repo_root(Path("/repo/.worktrees/feature-001"))
173
+ Path('/repo')
174
+ """
175
+ git_file = current_path / ".git"
176
+
177
+ if git_file.is_file():
178
+ try:
179
+ git_content = git_file.read_text().strip()
180
+ if git_content.startswith("gitdir:"):
181
+ gitdir = Path(git_content.split(":", 1)[1].strip())
182
+ # Navigate: .git/worktrees/name -> .git -> main repo root
183
+ main_git_dir = gitdir.parent.parent
184
+ main_repo_root = main_git_dir.parent
185
+ return main_repo_root
186
+ except (OSError, ValueError):
187
+ pass
188
+
189
+ return current_path
190
+
191
+
192
+ def find_feature_slug(repo_root: Path) -> Optional[str]:
193
+ """
194
+ Auto-detect feature slug from git branch or highest-numbered feature in kitty-specs.
195
+
196
+ Detection strategies (in order):
197
+ 1. Git branch name matching pattern ###-slug (strips -WPxx suffix)
198
+ 2. Highest-numbered directory in kitty-specs/
199
+
200
+ Args:
201
+ repo_root: Repository root path (can be worktree or main repo)
202
+
203
+ Returns:
204
+ Feature slug (e.g., "016-jujutsu-vcs-documentation") or None if not found
205
+
206
+ Examples:
207
+ >>> find_feature_slug(Path("/repo"))
208
+ '016-jujutsu-vcs-documentation'
209
+ """
210
+ import re
211
+
212
+ # Get main repo root for kitty-specs access
213
+ main_repo_root = get_main_repo_root(repo_root)
214
+
215
+ # Strategy 1: Get from git branch name
216
+ try:
217
+ result = subprocess.run(
218
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
219
+ cwd=repo_root,
220
+ capture_output=True,
221
+ text=True,
222
+ check=True
223
+ )
224
+ branch_name = result.stdout.strip()
225
+
226
+ # Strip -WPxx suffix if present (worktree branches)
227
+ branch_name = re.sub(r'-WP\d+$', '', branch_name)
228
+
229
+ # Validate format: ###-slug
230
+ if len(branch_name) >= 3 and branch_name[:3].isdigit():
231
+ return branch_name
232
+
233
+ except (subprocess.CalledProcessError, FileNotFoundError):
234
+ pass
235
+
236
+ # Strategy 2: Auto-detect highest-numbered feature in kitty-specs
237
+ kitty_specs_dir = main_repo_root / "kitty-specs"
238
+ if kitty_specs_dir.is_dir():
239
+ candidates = []
240
+ for path in kitty_specs_dir.iterdir():
241
+ if not path.is_dir():
242
+ continue
243
+ match = re.match(r"^(\d{3})-", path.name)
244
+ if match:
245
+ candidates.append((int(match.group(1)), path.name))
246
+ if candidates:
247
+ _, slug = max(candidates, key=lambda item: item[0])
248
+ return slug
249
+
250
+ return None
251
+
252
+
253
+ __all__ = [
254
+ "locate_project_root",
255
+ "is_worktree_context",
256
+ "resolve_with_context",
257
+ "check_broken_symlink",
258
+ "get_main_repo_root",
259
+ "find_feature_slug",
260
+ ]