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,541 @@
1
+ """Dashboard lifecycle and health management utilities.
2
+
3
+ This module handles starting, stopping, and monitoring the dashboard server.
4
+ Uses psutil for cross-platform process management (Windows, Linux, macOS).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import secrets
13
+ import socket
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from pathlib import Path
19
+ from typing import Optional, Tuple
20
+
21
+ import psutil
22
+
23
+ from .server import find_free_port, start_dashboard
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ __all__ = [
28
+ "ensure_dashboard_running",
29
+ "stop_dashboard",
30
+ "_parse_dashboard_file",
31
+ "_write_dashboard_file",
32
+ "_check_dashboard_health",
33
+ ]
34
+
35
+
36
+ def _parse_dashboard_file(dashboard_file: Path) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[int]]:
37
+ """Read dashboard metadata from disk.
38
+
39
+ Format:
40
+ Line 1: URL (http://127.0.0.1:port)
41
+ Line 2: Port (integer)
42
+ Line 3: Token (optional)
43
+ Line 4: PID (optional, for process tracking)
44
+ """
45
+ try:
46
+ content = dashboard_file.read_text(encoding='utf-8')
47
+ except Exception:
48
+ return None, None, None, None
49
+
50
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
51
+ if not lines:
52
+ return None, None, None, None
53
+
54
+ url = lines[0] if lines else None
55
+ port = None
56
+ token = None
57
+ pid = None
58
+
59
+ if len(lines) >= 2:
60
+ try:
61
+ port = int(lines[1])
62
+ except ValueError:
63
+ port = None
64
+
65
+ if len(lines) >= 3:
66
+ token = lines[2] or None
67
+
68
+ if len(lines) >= 4:
69
+ try:
70
+ pid = int(lines[3])
71
+ except ValueError:
72
+ pid = None
73
+
74
+ return url, port, token, pid
75
+
76
+
77
+ def _write_dashboard_file(
78
+ dashboard_file: Path,
79
+ url: str,
80
+ port: int,
81
+ token: Optional[str],
82
+ pid: Optional[int] = None,
83
+ ) -> None:
84
+ """Persist dashboard metadata to disk.
85
+
86
+ Args:
87
+ dashboard_file: Path to .dashboard metadata file
88
+ url: Dashboard URL (http://127.0.0.1:port)
89
+ port: Port number
90
+ token: Security token (optional)
91
+ pid: Process ID of background dashboard (optional)
92
+ """
93
+ dashboard_file.parent.mkdir(parents=True, exist_ok=True)
94
+ lines = [url, str(port)]
95
+ if token:
96
+ lines.append(token)
97
+ if pid is not None:
98
+ lines.append(str(pid))
99
+ dashboard_file.write_text("\n".join(lines) + "\n", encoding='utf-8')
100
+
101
+
102
+ def _is_process_alive(pid: int) -> bool:
103
+ """Check if a process with the given PID is alive.
104
+
105
+ Uses psutil.Process() which works across all platforms (Linux, macOS, Windows).
106
+ This replaces the POSIX-only os.kill(pid, 0) approach.
107
+
108
+ Args:
109
+ pid: Process ID to check
110
+
111
+ Returns:
112
+ True if process exists and is running, False otherwise
113
+ """
114
+ try:
115
+ proc = psutil.Process(pid)
116
+ return proc.is_running()
117
+ except psutil.NoSuchProcess:
118
+ # Process doesn't exist
119
+ return False
120
+ except psutil.AccessDenied:
121
+ # Process exists but we don't have permission to access it
122
+ # Consider this as "alive" since process exists
123
+ return True
124
+ except Exception:
125
+ # Unexpected error, assume process dead
126
+ return False
127
+
128
+
129
+ def _is_spec_kitty_dashboard(port: int, timeout: float = 0.3) -> bool:
130
+ """Check if the process on the given port is a spec-kitty dashboard.
131
+
132
+ Uses health check endpoint fingerprinting to safely identify spec-kitty dashboards.
133
+ Only returns True if we can confirm it's a spec-kitty dashboard.
134
+
135
+ Args:
136
+ port: Port number to check
137
+ timeout: Health check timeout in seconds
138
+
139
+ Returns:
140
+ True if confirmed to be a spec-kitty dashboard, False otherwise
141
+ """
142
+ health_url = f"http://127.0.0.1:{port}/api/health"
143
+ try:
144
+ with urllib.request.urlopen(health_url, timeout=timeout) as response:
145
+ if response.status != 200:
146
+ return False
147
+ payload = response.read()
148
+ except Exception:
149
+ # Can't reach or parse - not a spec-kitty dashboard (or dead)
150
+ return False
151
+
152
+ try:
153
+ data = json.loads(payload.decode('utf-8'))
154
+ # Verify this is actually a spec-kitty dashboard by checking for expected fields
155
+ return 'project_path' in data and 'status' in data
156
+ except Exception:
157
+ return False
158
+
159
+
160
+ def _cleanup_orphaned_dashboards_in_range(start_port: int = 9237, port_count: int = 100) -> int:
161
+ """Clean up orphaned spec-kitty dashboard processes in the port range.
162
+
163
+ This function safely identifies spec-kitty dashboard processes via health check
164
+ fingerprinting and kills only confirmed spec-kitty processes. This handles orphans
165
+ that have no .dashboard file (e.g., from failed startups or deleted temp projects).
166
+
167
+ Args:
168
+ start_port: Starting port number (default: 9237)
169
+ port_count: Number of ports to check (default: 100)
170
+
171
+ Returns:
172
+ Number of orphaned processes killed
173
+ """
174
+ import subprocess
175
+
176
+ killed_count = 0
177
+
178
+ for port in range(start_port, start_port + port_count):
179
+ # Check if port is occupied
180
+ try:
181
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
182
+ sock.settimeout(0.1)
183
+ if sock.connect_ex(('127.0.0.1', port)) != 0:
184
+ # Port is free, skip
185
+ continue
186
+ except Exception:
187
+ continue
188
+
189
+ # Port is occupied - check if it's a spec-kitty dashboard
190
+ if _is_spec_kitty_dashboard(port):
191
+ # It's a spec-kitty dashboard - try to find and kill the process
192
+ try:
193
+ # Use lsof to find PID listening on this port
194
+ result = subprocess.run(
195
+ ['lsof', '-ti', f':{port}', '-sTCP:LISTEN'],
196
+ capture_output=True,
197
+ text=True,
198
+ timeout=2,
199
+ )
200
+ if result.returncode == 0 and result.stdout.strip():
201
+ pids = [int(pid) for pid in result.stdout.strip().split('\n') if pid.strip()]
202
+ for pid in pids:
203
+ try:
204
+ proc = psutil.Process(pid)
205
+ proc.kill()
206
+ killed_count += 1
207
+ except psutil.NoSuchProcess:
208
+ pass # Already dead
209
+ except psutil.AccessDenied:
210
+ pass # Can't kill (permissions)
211
+ except Exception:
212
+ # Can't use lsof or failed to kill - skip this port
213
+ pass
214
+
215
+ return killed_count
216
+
217
+
218
+ def _check_dashboard_health(
219
+ port: int,
220
+ project_dir: Path,
221
+ expected_token: Optional[str],
222
+ timeout: float = 0.5,
223
+ ) -> bool:
224
+ """Verify that the dashboard on the port belongs to the provided project."""
225
+ health_url = f"http://127.0.0.1:{port}/api/health"
226
+ try:
227
+ with urllib.request.urlopen(health_url, timeout=timeout) as response:
228
+ if response.status != 200:
229
+ return False
230
+ payload = response.read()
231
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ConnectionError, socket.error):
232
+ return False
233
+ except Exception:
234
+ return False
235
+
236
+ try:
237
+ data = json.loads(payload.decode('utf-8'))
238
+ except (UnicodeDecodeError, json.JSONDecodeError):
239
+ return False
240
+
241
+ remote_path = data.get('project_path')
242
+ if not remote_path:
243
+ return False
244
+
245
+ try:
246
+ remote_resolved = str(Path(remote_path).resolve())
247
+ except Exception:
248
+ remote_resolved = str(remote_path)
249
+
250
+ try:
251
+ expected_path = str(project_dir.resolve())
252
+ except Exception:
253
+ expected_path = str(project_dir)
254
+
255
+ if remote_resolved != expected_path:
256
+ return False
257
+
258
+ remote_token = data.get('token')
259
+ if expected_token:
260
+ return remote_token == expected_token
261
+
262
+ return True
263
+
264
+
265
+ def ensure_dashboard_running(
266
+ project_dir: Path,
267
+ preferred_port: Optional[int] = None,
268
+ background_process: bool = True,
269
+ ) -> Tuple[str, int, bool]:
270
+ """
271
+ Ensure a dashboard server is running for the provided project directory.
272
+
273
+ This function:
274
+ 1. Checks if a dashboard is already running (health check)
275
+ 2. Cleans up this project's orphaned process if stored PID is dead
276
+ 3. If starting new dashboard fails due to port exhaustion, cleans up orphaned
277
+ spec-kitty dashboards across the entire port range and retries
278
+ 4. Starts a new dashboard if needed
279
+ 5. Stores the PID for future cleanup
280
+
281
+ Returns:
282
+ Tuple of (url, port, started) where started is True when a new server was launched.
283
+ """
284
+ project_dir_resolved = project_dir.resolve()
285
+ dashboard_file = project_dir_resolved / '.kittify' / '.dashboard'
286
+
287
+ existing_url = None
288
+ existing_port = None
289
+ existing_token = None
290
+ existing_pid = None
291
+
292
+ # STEP 1: Check if we have a stale .dashboard file from a dead process
293
+ if dashboard_file.exists():
294
+ existing_url, existing_port, existing_token, existing_pid = _parse_dashboard_file(dashboard_file)
295
+
296
+ # First, try health check - if dashboard is healthy, reuse it
297
+ if existing_port is not None and _check_dashboard_health(existing_port, project_dir_resolved, existing_token):
298
+ url = existing_url or f"http://127.0.0.1:{existing_port}"
299
+ return url, existing_port, False
300
+
301
+ # Dashboard not responding - clean up orphaned process if we have a PID
302
+ if existing_pid is not None and not _is_process_alive(existing_pid):
303
+ # Process is dead, clean up the metadata file
304
+ dashboard_file.unlink(missing_ok=True)
305
+ elif existing_pid is not None and existing_port is not None:
306
+ # PID is alive but port not responding - kill the orphan
307
+ try:
308
+ proc = psutil.Process(existing_pid)
309
+ proc.kill()
310
+ dashboard_file.unlink(missing_ok=True)
311
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
312
+ # Already dead or can't kill - just clean up metadata
313
+ dashboard_file.unlink(missing_ok=True)
314
+ else:
315
+ # No PID recorded - just clean up metadata file
316
+ dashboard_file.unlink(missing_ok=True)
317
+
318
+ # STEP 2: Try to start a new dashboard
319
+ if preferred_port is not None:
320
+ try:
321
+ port_to_use = find_free_port(preferred_port, max_attempts=1)
322
+ except RuntimeError:
323
+ port_to_use = None
324
+ else:
325
+ port_to_use = None
326
+
327
+ token = secrets.token_hex(16)
328
+
329
+ # Try starting dashboard - if it fails due to port exhaustion, cleanup and retry
330
+ try:
331
+ port, pid = start_dashboard(
332
+ project_dir_resolved,
333
+ port=port_to_use,
334
+ background_process=background_process,
335
+ project_token=token,
336
+ )
337
+ except RuntimeError as e:
338
+ # If port exhaustion, try cleaning up orphaned dashboards and retry once
339
+ if "Could not find free port" in str(e):
340
+ killed = _cleanup_orphaned_dashboards_in_range()
341
+ if killed > 0:
342
+ # Cleanup succeeded, retry starting dashboard
343
+ port, pid = start_dashboard(
344
+ project_dir_resolved,
345
+ port=port_to_use,
346
+ background_process=background_process,
347
+ project_token=token,
348
+ )
349
+ else:
350
+ # No orphans found or couldn't clean up - re-raise original error
351
+ raise
352
+ else:
353
+ # Different error - re-raise
354
+ raise
355
+
356
+ url = f"http://127.0.0.1:{port}"
357
+
358
+ # Wait for dashboard to become healthy (20 seconds with exponential backoff)
359
+ # Start with quick checks, then slow down for slower systems
360
+ retry_delays = [0.1] * 10 + [0.25] * 40 + [0.5] * 20 # ~20 seconds total
361
+ for delay in retry_delays:
362
+ if _check_dashboard_health(port, project_dir_resolved, token):
363
+ _write_dashboard_file(dashboard_file, url, port, token, pid)
364
+ return url, port, True
365
+ time.sleep(delay)
366
+
367
+ # Dashboard started but never became healthy
368
+ # Check if port has an orphaned dashboard from a different project
369
+ if _is_spec_kitty_dashboard(port):
370
+ # Port has a spec-kitty dashboard but for wrong project - orphan detected
371
+ # Clean up the failed process we just started
372
+ if pid is not None:
373
+ try:
374
+ proc = psutil.Process(pid)
375
+ proc.kill()
376
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
377
+ pass
378
+
379
+ # Cleanup orphaned dashboards and retry ONCE
380
+ killed = _cleanup_orphaned_dashboards_in_range()
381
+ if killed > 0:
382
+ # Retry starting dashboard after cleanup
383
+ token = secrets.token_hex(16)
384
+ port, pid = start_dashboard(
385
+ project_dir_resolved,
386
+ port=port_to_use,
387
+ background_process=background_process,
388
+ project_token=token,
389
+ )
390
+ url = f"http://127.0.0.1:{port}"
391
+
392
+ # Wait for health check again
393
+ for delay in retry_delays:
394
+ if _check_dashboard_health(port, project_dir_resolved, token):
395
+ _write_dashboard_file(dashboard_file, url, port, token, pid)
396
+ return url, port, True
397
+ time.sleep(delay)
398
+
399
+ # Still failed - clean up and raise error
400
+ if pid is not None:
401
+ try:
402
+ proc = psutil.Process(pid)
403
+ proc.kill()
404
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
405
+ pass
406
+
407
+ raise RuntimeError(f"Dashboard failed to start on port {port} for project {project_dir_resolved}")
408
+
409
+
410
+ def stop_dashboard(project_dir: Path, timeout: float = 5.0) -> Tuple[bool, str]:
411
+ """
412
+ Attempt to stop the dashboard server for the provided project directory.
413
+
414
+ Tries graceful HTTP shutdown first, then falls back to killing by PID if needed.
415
+
416
+ Returns:
417
+ Tuple[bool, str]: (stopped, message)
418
+ """
419
+ project_dir_resolved = project_dir.resolve()
420
+ dashboard_file = project_dir_resolved / '.kittify' / '.dashboard'
421
+
422
+ if not dashboard_file.exists():
423
+ return False, "No dashboard metadata found."
424
+
425
+ _, port, token, pid = _parse_dashboard_file(dashboard_file)
426
+ if port is None:
427
+ dashboard_file.unlink(missing_ok=True)
428
+ return False, "Dashboard metadata was invalid and has been cleared."
429
+
430
+ if not _check_dashboard_health(port, project_dir_resolved, token):
431
+ dashboard_file.unlink(missing_ok=True)
432
+ return False, "Dashboard was already stopped. Metadata has been cleared."
433
+
434
+ shutdown_url = f"http://127.0.0.1:{port}/api/shutdown"
435
+
436
+ def _attempt_get() -> Tuple[bool, Optional[str]]:
437
+ params = {}
438
+ if token:
439
+ params['token'] = token
440
+ query = urllib.parse.urlencode(params)
441
+ request_url = f"{shutdown_url}?{query}" if query else shutdown_url
442
+ try:
443
+ urllib.request.urlopen(request_url, timeout=1)
444
+ return True, None
445
+ except urllib.error.HTTPError as exc:
446
+ if exc.code == 403:
447
+ return False, "Dashboard refused shutdown (token mismatch)."
448
+ if exc.code in (404, 405, 501):
449
+ return False, None
450
+ return False, f"Dashboard shutdown failed with HTTP {exc.code}."
451
+ except (urllib.error.URLError, TimeoutError, ConnectionError, socket.error) as exc:
452
+ return False, f"Dashboard shutdown request failed: {exc}"
453
+ except Exception as exc:
454
+ return False, f"Unexpected error during shutdown: {exc}"
455
+
456
+ def _attempt_post() -> Tuple[bool, Optional[str]]:
457
+ payload = json.dumps({'token': token}).encode('utf-8')
458
+ request = urllib.request.Request(
459
+ shutdown_url,
460
+ data=payload,
461
+ headers={'Content-Type': 'application/json'},
462
+ method='POST',
463
+ )
464
+ try:
465
+ urllib.request.urlopen(request, timeout=1)
466
+ return True, None
467
+ except urllib.error.HTTPError as exc:
468
+ if exc.code == 403:
469
+ return False, "Dashboard refused shutdown (token mismatch)."
470
+ if exc.code == 501:
471
+ return False, "Dashboard does not support remote shutdown (upgrade required)."
472
+ return False, f"Dashboard shutdown failed with HTTP {exc.code}."
473
+ except (urllib.error.URLError, TimeoutError, ConnectionError, socket.error) as exc:
474
+ return False, f"Dashboard shutdown request failed: {exc}"
475
+ except Exception as exc:
476
+ return False, f"Unexpected error during shutdown: {exc}"
477
+
478
+ # Try graceful HTTP shutdown first
479
+ ok, error_message = _attempt_get()
480
+ if not ok and error_message is None:
481
+ ok, error_message = _attempt_post()
482
+
483
+ # If HTTP shutdown failed but we have a PID, try killing the process
484
+ if not ok and pid is not None:
485
+ try:
486
+ proc = psutil.Process(pid)
487
+
488
+ # Try graceful termination first (SIGTERM on POSIX, equivalent on Windows)
489
+ proc.terminate()
490
+
491
+ # Wait up to 3 seconds for graceful shutdown
492
+ try:
493
+ proc.wait(timeout=3.0)
494
+ # Process exited gracefully
495
+ dashboard_file.unlink(missing_ok=True)
496
+ return True, f"Dashboard stopped via process termination (PID {pid})."
497
+ except psutil.TimeoutExpired:
498
+ # Timeout expired, process still running, force kill
499
+ proc.kill()
500
+ time.sleep(0.2)
501
+ dashboard_file.unlink(missing_ok=True)
502
+ return True, f"Dashboard force killed after graceful termination timeout (PID {pid})."
503
+
504
+ except psutil.NoSuchProcess:
505
+ # Process already dead (common race condition)
506
+ dashboard_file.unlink(missing_ok=True)
507
+ return True, f"Dashboard was already dead (PID {pid})."
508
+ except psutil.AccessDenied:
509
+ # Can't access process (permissions issue)
510
+ return False, f"Permission denied to kill dashboard process (PID {pid})."
511
+ except Exception as e:
512
+ # Unexpected error
513
+ logger.error(f"Unexpected error stopping dashboard process {pid}: {e}")
514
+ return False, f"Failed to kill dashboard process (PID {pid}): {e}"
515
+
516
+ if not ok:
517
+ return False, error_message or "Dashboard shutdown failed."
518
+
519
+ # Wait for graceful shutdown to complete
520
+ deadline = time.monotonic() + timeout
521
+ while time.monotonic() < deadline:
522
+ if not _check_dashboard_health(port, project_dir_resolved, token):
523
+ dashboard_file.unlink(missing_ok=True)
524
+ return True, f"Dashboard stopped and metadata cleared (port {port})."
525
+ time.sleep(0.1)
526
+
527
+ # Timeout - try killing by PID as last resort
528
+ if pid is not None:
529
+ try:
530
+ proc = psutil.Process(pid)
531
+ proc.kill()
532
+ dashboard_file.unlink(missing_ok=True)
533
+ return True, f"Dashboard forced stopped (force kill, PID {pid}) after {timeout}s timeout."
534
+ except psutil.NoSuchProcess:
535
+ # Process died between health check and kill attempt
536
+ dashboard_file.unlink(missing_ok=True)
537
+ return True, f"Dashboard process ended (PID {pid})."
538
+ except Exception as e:
539
+ logger.error(f"Failed to force kill dashboard process {pid}: {e}")
540
+
541
+ return False, f"Dashboard did not stop within {timeout} seconds."