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,110 @@
1
+ """Project path resolution helpers for Spec Kitty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ from specify_cli.core.config import DEFAULT_MISSION_KEY
12
+
13
+ ConsoleType = Console | None
14
+
15
+
16
+ def _resolve_console(console: ConsoleType) -> Console:
17
+ return console if console is not None else Console()
18
+
19
+
20
+ def locate_project_root(start: Path | None = None) -> Optional[Path]:
21
+ """Walk upwards from *start* (or CWD) to find the directory that owns .kittify."""
22
+ current = (start or Path.cwd()).resolve()
23
+ for candidate in [current, *current.parents]:
24
+ if (candidate / ".kittify").is_dir():
25
+ return candidate
26
+ return None
27
+
28
+
29
+ def resolve_template_path(project_root: Path, mission_key: str, template_subpath: str | Path) -> Optional[Path]:
30
+ """Resolve a template path, preferring mission overrides before global templates."""
31
+ subpath = Path(template_subpath)
32
+ candidates = [
33
+ project_root / ".kittify" / "missions" / mission_key / "templates" / subpath,
34
+ project_root / ".kittify" / "templates" / subpath,
35
+ project_root / "templates" / subpath,
36
+ ]
37
+ for candidate in candidates:
38
+ if candidate.exists():
39
+ return candidate
40
+ return None
41
+
42
+
43
+ def resolve_worktree_aware_feature_dir(
44
+ repo_root: Path,
45
+ feature_slug: str,
46
+ cwd: Path | None = None,
47
+ console: ConsoleType = None,
48
+ ) -> Path:
49
+ """Resolve the correct feature directory, preferring worktree locations when available."""
50
+ resolved_console = _resolve_console(console)
51
+ current_dir = (cwd or Path.cwd()).resolve()
52
+
53
+ parts = current_dir.parts
54
+ for idx, part in enumerate(parts):
55
+ if part == ".worktrees" and idx + 1 < len(parts) and parts[idx + 1] == feature_slug:
56
+ worktree_root = Path(*parts[: idx + 2])
57
+ feature_dir = worktree_root / "kitty-specs" / feature_slug
58
+ resolved_console.print(f"[green]✓[/green] Using worktree location: {feature_dir}")
59
+ return feature_dir
60
+
61
+ worktree_path = repo_root / ".worktrees" / feature_slug
62
+ if worktree_path.exists():
63
+ feature_dir = worktree_path / "kitty-specs" / feature_slug
64
+ resolved_console.print(f"[green]✓[/green] Found worktree, using: {feature_dir}")
65
+ resolved_console.print(f"[yellow]Tip:[/yellow] Run commands from {worktree_path} for better isolation")
66
+ return feature_dir
67
+
68
+ feature_dir = repo_root / "kitty-specs" / feature_slug
69
+ resolved_console.print(f"[yellow]⚠[/yellow] No worktree found, using root location: {feature_dir}")
70
+ resolved_console.print(
71
+ f"[yellow]Tip:[/yellow] Consider creating a worktree with: git worktree add .worktrees/{feature_slug} {feature_slug}"
72
+ )
73
+ return feature_dir
74
+
75
+
76
+ def get_active_mission_key(project_path: Path) -> str:
77
+ """Return the mission key stored in .kittify/active-mission, falling back to default."""
78
+ active_path = project_path / ".kittify" / "active-mission"
79
+ if not active_path.exists():
80
+ return DEFAULT_MISSION_KEY
81
+
82
+ if active_path.is_symlink():
83
+ try:
84
+ target = Path(os.readlink(active_path))
85
+ key = target.name
86
+ if key:
87
+ return key
88
+ except OSError:
89
+ pass
90
+ resolved = active_path.resolve()
91
+ if resolved.parent.name == "missions":
92
+ return resolved.name
93
+
94
+ if active_path.is_file():
95
+ try:
96
+ key = active_path.read_text(encoding="utf-8-sig").strip()
97
+ if key:
98
+ return key
99
+ except OSError:
100
+ pass
101
+
102
+ return DEFAULT_MISSION_KEY
103
+
104
+
105
+ __all__ = [
106
+ "get_active_mission_key",
107
+ "locate_project_root",
108
+ "resolve_template_path",
109
+ "resolve_worktree_aware_feature_dir",
110
+ ]
@@ -0,0 +1,263 @@
1
+ """
2
+ Stale Work Package Detection
3
+ ============================
4
+
5
+ Detects work packages that are in "doing" lane but have no recent VCS activity,
6
+ indicating the agent may have stopped without transitioning the WP.
7
+
8
+ Uses git/jj commit timestamps as a "heartbeat" - if no commits for a threshold
9
+ period, the WP is considered stale.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from specify_cli.core.vcs import get_vcs, VCSError
19
+
20
+
21
+ @dataclass
22
+ class StaleCheckResult:
23
+ """Result of checking a work package for staleness."""
24
+
25
+ wp_id: str
26
+ is_stale: bool
27
+ last_commit_time: datetime | None
28
+ minutes_since_commit: float | None
29
+ worktree_exists: bool
30
+ error: str | None = None
31
+
32
+
33
+ def get_last_meaningful_commit_time(worktree_path: Path) -> tuple[datetime | None, bool]:
34
+ """
35
+ Get the timestamp of the most recent meaningful commit in a worktree.
36
+
37
+ A "meaningful" commit is one made ON THIS BRANCH since it diverged from main.
38
+ This prevents false staleness when a worktree is just created but no commits
39
+ have been made yet (HEAD points to parent branch's old commit).
40
+
41
+ For worktrees, we always use git to check the branch-specific history,
42
+ even in jj colocated repos. This is because:
43
+ - jj's shared history includes commits from ALL workspaces
44
+ - jj continuously auto-snapshots the working copy
45
+ - We need the last commit on THIS worktree's branch, not the shared history
46
+
47
+ Args:
48
+ worktree_path: Path to the worktree
49
+
50
+ Returns:
51
+ Tuple of (datetime of last commit on this branch, has_own_commits).
52
+ has_own_commits is False if the branch has no commits since diverging from main.
53
+ """
54
+ import subprocess
55
+
56
+ if not worktree_path.exists():
57
+ return None, False
58
+
59
+ try:
60
+ # First, check if this branch has any commits since diverging from main
61
+ # This prevents false staleness when worktree was just created
62
+ merge_base_result = subprocess.run(
63
+ ["git", "merge-base", "HEAD", "main"],
64
+ cwd=worktree_path,
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=10,
68
+ )
69
+
70
+ if merge_base_result.returncode == 0:
71
+ merge_base = merge_base_result.stdout.strip()
72
+
73
+ # Count commits on this branch since the merge base
74
+ count_result = subprocess.run(
75
+ ["git", "rev-list", "--count", f"{merge_base}..HEAD"],
76
+ cwd=worktree_path,
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=10,
80
+ )
81
+
82
+ if count_result.returncode == 0:
83
+ commit_count = int(count_result.stdout.strip())
84
+ if commit_count == 0:
85
+ # No commits on this branch yet - worktree just created
86
+ # Don't flag as stale since agent just started
87
+ return None, False
88
+
89
+ # Get the last commit time on this branch
90
+ result = subprocess.run(
91
+ ["git", "log", "-1", "--format=%cI"],
92
+ cwd=worktree_path,
93
+ capture_output=True,
94
+ text=True,
95
+ timeout=10,
96
+ )
97
+
98
+ if result.returncode != 0 or not result.stdout.strip():
99
+ return None, False
100
+
101
+ # Parse ISO format timestamp
102
+ timestamp_str = result.stdout.strip()
103
+ return datetime.fromisoformat(timestamp_str), True
104
+
105
+ except subprocess.TimeoutExpired:
106
+ return None, False
107
+ except Exception:
108
+ return None, False
109
+
110
+
111
+ def check_wp_staleness(
112
+ wp_id: str,
113
+ worktree_path: Path,
114
+ threshold_minutes: int = 10,
115
+ ) -> StaleCheckResult:
116
+ """
117
+ Check if a work package is stale based on VCS activity.
118
+
119
+ A WP is considered stale if:
120
+ - Its worktree exists
121
+ - The branch has commits since diverging from main (agent has done work)
122
+ - The last commit is older than threshold_minutes
123
+
124
+ A WP with a worktree but NO commits since diverging is NOT stale - the agent
125
+ just started and hasn't committed yet.
126
+
127
+ Args:
128
+ wp_id: Work package ID (e.g., "WP01")
129
+ worktree_path: Path to the WP's worktree
130
+ threshold_minutes: Minutes of inactivity before considered stale
131
+
132
+ Returns:
133
+ StaleCheckResult with staleness status
134
+ """
135
+ if not worktree_path.exists():
136
+ return StaleCheckResult(
137
+ wp_id=wp_id,
138
+ is_stale=False,
139
+ last_commit_time=None,
140
+ minutes_since_commit=None,
141
+ worktree_exists=False,
142
+ )
143
+
144
+ try:
145
+ last_commit, has_own_commits = get_last_meaningful_commit_time(worktree_path)
146
+
147
+ if last_commit is None:
148
+ # Can't determine commit time, or no commits on this branch yet
149
+ # If no commits yet (has_own_commits=False), agent just started - not stale
150
+ return StaleCheckResult(
151
+ wp_id=wp_id,
152
+ is_stale=False,
153
+ last_commit_time=None,
154
+ minutes_since_commit=None,
155
+ worktree_exists=True,
156
+ error=None if not has_own_commits else "Could not determine last commit time",
157
+ )
158
+
159
+ now = datetime.now(timezone.utc)
160
+ # Ensure last_commit is timezone-aware
161
+ if last_commit.tzinfo is None:
162
+ last_commit = last_commit.replace(tzinfo=timezone.utc)
163
+
164
+ delta = now - last_commit
165
+ minutes_since = delta.total_seconds() / 60
166
+
167
+ is_stale = minutes_since > threshold_minutes
168
+
169
+ return StaleCheckResult(
170
+ wp_id=wp_id,
171
+ is_stale=is_stale,
172
+ last_commit_time=last_commit,
173
+ minutes_since_commit=round(minutes_since, 1),
174
+ worktree_exists=True,
175
+ )
176
+
177
+ except Exception as e:
178
+ return StaleCheckResult(
179
+ wp_id=wp_id,
180
+ is_stale=False,
181
+ last_commit_time=None,
182
+ minutes_since_commit=None,
183
+ worktree_exists=True,
184
+ error=str(e),
185
+ )
186
+
187
+
188
+ def find_worktree_for_wp(
189
+ main_repo_root: Path,
190
+ feature_slug: str,
191
+ wp_id: str,
192
+ ) -> Path | None:
193
+ """
194
+ Find the worktree path for a given work package.
195
+
196
+ Args:
197
+ main_repo_root: Root of the main repository
198
+ feature_slug: Feature slug (e.g., "001-my-feature")
199
+ wp_id: Work package ID (e.g., "WP01")
200
+
201
+ Returns:
202
+ Path to worktree if found, None otherwise
203
+ """
204
+ worktrees_dir = main_repo_root / ".worktrees"
205
+ if not worktrees_dir.exists():
206
+ return None
207
+
208
+ # Expected pattern: feature_slug-WP01
209
+ expected_name = f"{feature_slug}-{wp_id}"
210
+ worktree_path = worktrees_dir / expected_name
211
+
212
+ if worktree_path.exists():
213
+ return worktree_path
214
+
215
+ # Try case-insensitive search
216
+ for item in worktrees_dir.iterdir():
217
+ if item.is_dir() and item.name.lower() == expected_name.lower():
218
+ return item
219
+
220
+ return None
221
+
222
+
223
+ def check_doing_wps_for_staleness(
224
+ main_repo_root: Path,
225
+ feature_slug: str,
226
+ doing_wps: list[dict],
227
+ threshold_minutes: int = 10,
228
+ ) -> dict[str, StaleCheckResult]:
229
+ """
230
+ Check all WPs in "doing" lane for staleness.
231
+
232
+ Args:
233
+ main_repo_root: Root of the main repository
234
+ feature_slug: Feature slug
235
+ doing_wps: List of WP dicts with at least 'id' key
236
+ threshold_minutes: Minutes of inactivity threshold
237
+
238
+ Returns:
239
+ Dict mapping WP ID to StaleCheckResult
240
+ """
241
+ results = {}
242
+
243
+ for wp in doing_wps:
244
+ wp_id = wp.get("id") or wp.get("work_package_id")
245
+ if not wp_id:
246
+ continue
247
+
248
+ worktree_path = find_worktree_for_wp(main_repo_root, feature_slug, wp_id)
249
+
250
+ if worktree_path:
251
+ result = check_wp_staleness(wp_id, worktree_path, threshold_minutes)
252
+ else:
253
+ result = StaleCheckResult(
254
+ wp_id=wp_id,
255
+ is_stale=False,
256
+ last_commit_time=None,
257
+ minutes_since_commit=None,
258
+ worktree_exists=False,
259
+ )
260
+
261
+ results[wp_id] = result
262
+
263
+ return results
@@ -0,0 +1,79 @@
1
+ """Tool availability helpers for Spec Kitty."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Dict, Mapping, Tuple, TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from specify_cli.cli import StepTracker
13
+
14
+ from specify_cli.core.config import AGENT_TOOL_REQUIREMENTS, IDE_AGENTS
15
+
16
+ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
17
+
18
+
19
+ def check_tool_for_tracker(tool: str, tracker: "StepTracker") -> bool:
20
+ """Check if a tool is installed and update the provided StepTracker instance."""
21
+ if shutil.which(tool):
22
+ tracker.complete(tool, "available")
23
+ return True
24
+ tracker.error(tool, "not found")
25
+ return False
26
+
27
+
28
+ def check_tool(tool: str, install_hint: str, agent_name: str | None = None) -> bool:
29
+ """Return True when the tool is available on PATH (with Claude CLI override and IDE agent bypass).
30
+
31
+ IDE-integrated agents (cursor, windsurf, copilot, kilocode) don't require CLI
32
+ installation, so we skip the availability check for them.
33
+ """
34
+ # Skip CLI checks for IDE agents - they run within the IDE, not as CLI tools
35
+ if agent_name and agent_name in IDE_AGENTS:
36
+ return True
37
+
38
+ # Special case: Claude local installation
39
+ if tool == "claude" and CLAUDE_LOCAL_PATH.exists() and CLAUDE_LOCAL_PATH.is_file():
40
+ return True
41
+
42
+ return shutil.which(tool) is not None
43
+
44
+
45
+ def get_tool_version(command: str) -> str | None:
46
+ """Return the version string for a tool if the convention '<tool> --version' succeeds."""
47
+ try:
48
+ result = subprocess.run(
49
+ [command, "--version"],
50
+ check=True,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
55
+ return None
56
+
57
+ output = (result.stdout or result.stderr or "").strip()
58
+ return output or None
59
+
60
+
61
+ def check_all_tools(
62
+ requirements: Mapping[str, Tuple[str, str]] | None = None,
63
+ ) -> Dict[str, Tuple[bool, str]]:
64
+ """Check tool availability for all known agents, returning {agent: (ok, detail)}."""
65
+ results: Dict[str, Tuple[bool, str]] = {}
66
+ entries = requirements or AGENT_TOOL_REQUIREMENTS
67
+ for agent, (tool, url) in entries.items():
68
+ ok = check_tool(tool, url)
69
+ detail = get_tool_version(tool) if ok else url
70
+ results[agent] = (ok, detail or url)
71
+ return results
72
+
73
+
74
+ __all__ = [
75
+ "check_all_tools",
76
+ "check_tool",
77
+ "check_tool_for_tracker",
78
+ "get_tool_version",
79
+ ]
@@ -0,0 +1,43 @@
1
+ """Shared utility helpers used across Spec Kitty modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def format_path(path: Path, relative_to: Path | None = None) -> str:
11
+ """Return a string path, optionally relative to another directory."""
12
+ target = path
13
+ if relative_to is not None:
14
+ try:
15
+ target = path.relative_to(relative_to)
16
+ except ValueError:
17
+ target = path
18
+ return str(target)
19
+
20
+
21
+ def ensure_directory(path: Path) -> Path:
22
+ """Create a directory (and parents) if it does not exist and return the Path."""
23
+ path.mkdir(parents=True, exist_ok=True)
24
+ return path
25
+
26
+
27
+ def safe_remove(path: Path) -> bool:
28
+ """Remove a file or directory tree if it exists, returning True when something was removed."""
29
+ if not path.exists():
30
+ return False
31
+ if path.is_dir() and not path.is_symlink():
32
+ shutil.rmtree(path)
33
+ else:
34
+ path.unlink()
35
+ return True
36
+
37
+
38
+ def get_platform() -> str:
39
+ """Return the current platform identifier (linux/darwin/win32)."""
40
+ return sys.platform
41
+
42
+
43
+ __all__ = ["format_path", "ensure_directory", "safe_remove", "get_platform"]
@@ -0,0 +1,114 @@
1
+ """
2
+ VCS Abstraction Package
3
+ =======================
4
+
5
+ This package provides a unified interface for Version Control System operations,
6
+ supporting both Git and Jujutsu (jj) backends.
7
+
8
+ Usage:
9
+ from specify_cli.core.vcs import (
10
+ get_vcs,
11
+ VCSProtocol,
12
+ VCSBackend,
13
+ VCSCapabilities,
14
+ GIT_CAPABILITIES,
15
+ JJ_CAPABILITIES,
16
+ is_jj_available,
17
+ is_git_available,
18
+ )
19
+
20
+ # Get appropriate VCS implementation
21
+ vcs = get_vcs(feature_path) # Auto-detect, prefers jj
22
+ vcs = get_vcs(feature_path, backend=VCSBackend.GIT) # Explicit git
23
+
24
+ See kitty-specs/015-first-class-jujutsu-vcs-integration/ for full documentation.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ # Enums
30
+ from .types import (
31
+ ConflictType,
32
+ SyncStatus,
33
+ VCSBackend,
34
+ )
35
+
36
+ # Dataclasses
37
+ from .types import (
38
+ ChangeInfo,
39
+ ConflictInfo,
40
+ FeatureVCSConfig,
41
+ OperationInfo,
42
+ ProjectVCSConfig,
43
+ SyncResult,
44
+ VCSCapabilities,
45
+ WorkspaceCreateResult,
46
+ WorkspaceInfo,
47
+ )
48
+
49
+ # Capability constants
50
+ from .types import (
51
+ GIT_CAPABILITIES,
52
+ JJ_CAPABILITIES,
53
+ )
54
+
55
+ # Protocol
56
+ from .protocol import VCSProtocol
57
+
58
+ # Exceptions
59
+ from .exceptions import (
60
+ VCSBackendMismatchError,
61
+ VCSCapabilityError,
62
+ VCSConflictError,
63
+ VCSError,
64
+ VCSLockError,
65
+ VCSNotFoundError,
66
+ VCSSyncError,
67
+ )
68
+
69
+ # Detection and factory functions
70
+ from .detection import (
71
+ detect_available_backends,
72
+ get_git_version,
73
+ get_jj_version,
74
+ get_vcs,
75
+ is_git_available,
76
+ is_jj_available,
77
+ )
78
+
79
+ __all__ = [
80
+ # Enums
81
+ "VCSBackend",
82
+ "SyncStatus",
83
+ "ConflictType",
84
+ # Dataclasses
85
+ "VCSCapabilities",
86
+ "ChangeInfo",
87
+ "ConflictInfo",
88
+ "SyncResult",
89
+ "WorkspaceInfo",
90
+ "OperationInfo",
91
+ "WorkspaceCreateResult",
92
+ "ProjectVCSConfig",
93
+ "FeatureVCSConfig",
94
+ # Capability constants
95
+ "GIT_CAPABILITIES",
96
+ "JJ_CAPABILITIES",
97
+ # Protocol
98
+ "VCSProtocol",
99
+ # Exceptions
100
+ "VCSError",
101
+ "VCSNotFoundError",
102
+ "VCSCapabilityError",
103
+ "VCSBackendMismatchError",
104
+ "VCSLockError",
105
+ "VCSConflictError",
106
+ "VCSSyncError",
107
+ # Detection and factory
108
+ "get_vcs",
109
+ "is_jj_available",
110
+ "is_git_available",
111
+ "get_jj_version",
112
+ "get_git_version",
113
+ "detect_available_backends",
114
+ ]