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,642 @@
1
+ """Executor for spawning and managing agent processes.
2
+
3
+ This module handles:
4
+ - Async process spawning with asyncio.create_subprocess_exec
5
+ - Stdin piping for prompts
6
+ - stdout/stderr capture to log files
7
+ - Timeout enforcement with proper cleanup
8
+ - Worktree creation integration
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import time
16
+ from asyncio.subprocess import Process
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from specify_cli.orchestrator.agents.base import AgentInvoker, BaseInvoker, InvocationResult
23
+
24
+ if TYPE_CHECKING:
25
+ pass
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # =============================================================================
31
+ # Constants
32
+ # =============================================================================
33
+
34
+
35
+ # Exit code returned when execution times out
36
+ TIMEOUT_EXIT_CODE = 124 # Same as Unix `timeout` command
37
+
38
+ # Grace period for process to terminate before force kill
39
+ TERMINATION_GRACE_SECONDS = 5.0
40
+
41
+ # Default logs directory under .kittify
42
+ LOGS_DIRNAME = "logs"
43
+
44
+
45
+ # =============================================================================
46
+ # Exceptions
47
+ # =============================================================================
48
+
49
+
50
+ class ExecutorError(Exception):
51
+ """Base exception for executor errors."""
52
+
53
+ pass
54
+
55
+
56
+ class WorktreeCreationError(ExecutorError):
57
+ """Raised when worktree creation fails."""
58
+
59
+ pass
60
+
61
+
62
+ class ProcessSpawnError(ExecutorError):
63
+ """Raised when process spawning fails."""
64
+
65
+ pass
66
+
67
+
68
+ class TimeoutError(ExecutorError):
69
+ """Raised when execution times out."""
70
+
71
+ pass
72
+
73
+
74
+ # =============================================================================
75
+ # Async Process Spawning (T027)
76
+ # =============================================================================
77
+
78
+
79
+ async def spawn_agent(
80
+ invoker: AgentInvoker | BaseInvoker,
81
+ prompt: str,
82
+ working_dir: Path,
83
+ role: str,
84
+ ) -> tuple[Process, list[str]]:
85
+ """Spawn agent process.
86
+
87
+ Creates an asyncio subprocess for the agent with stdin, stdout, and
88
+ stderr pipes configured for capture.
89
+
90
+ Args:
91
+ invoker: Agent invoker that knows how to build commands
92
+ prompt: The task prompt to send to the agent
93
+ working_dir: Directory where agent should execute
94
+ role: Either "implementation" or "review"
95
+
96
+ Returns:
97
+ Tuple of (process, command) for tracking
98
+
99
+ Raises:
100
+ ProcessSpawnError: If process creation fails
101
+ """
102
+ # Build command using invoker
103
+ cmd = invoker.build_command(prompt, working_dir, role)
104
+ logger.info(f"Spawning {invoker.agent_id}: {' '.join(cmd[:3])}...")
105
+
106
+ try:
107
+ process = await asyncio.create_subprocess_exec(
108
+ *cmd,
109
+ stdin=asyncio.subprocess.PIPE,
110
+ stdout=asyncio.subprocess.PIPE,
111
+ stderr=asyncio.subprocess.PIPE,
112
+ cwd=working_dir,
113
+ )
114
+ logger.debug(f"Process {process.pid} spawned for {invoker.agent_id}")
115
+ return process, cmd
116
+
117
+ except OSError as e:
118
+ raise ProcessSpawnError(
119
+ f"Failed to spawn {invoker.agent_id}: {e}"
120
+ ) from e
121
+ except Exception as e:
122
+ raise ProcessSpawnError(
123
+ f"Unexpected error spawning {invoker.agent_id}: {e}"
124
+ ) from e
125
+
126
+
127
+ # =============================================================================
128
+ # Timeout Handling (T030)
129
+ # =============================================================================
130
+
131
+
132
+ async def execute_with_timeout(
133
+ process: Process,
134
+ stdin_data: bytes | None,
135
+ timeout_seconds: int,
136
+ ) -> tuple[bytes, bytes, int]:
137
+ """Wait for process with timeout, kill if exceeded.
138
+
139
+ Implements graceful shutdown: SIGTERM first, then SIGKILL if needed.
140
+
141
+ Args:
142
+ process: Asyncio subprocess to wait on
143
+ stdin_data: Data to send to stdin (or None)
144
+ timeout_seconds: Maximum execution time
145
+
146
+ Returns:
147
+ Tuple of (stdout, stderr, exit_code)
148
+ Exit code is TIMEOUT_EXIT_CODE if timed out
149
+ """
150
+ try:
151
+ stdout, stderr = await asyncio.wait_for(
152
+ process.communicate(input=stdin_data),
153
+ timeout=timeout_seconds,
154
+ )
155
+ return stdout, stderr, process.returncode or 0
156
+
157
+ except asyncio.TimeoutError:
158
+ logger.warning(
159
+ f"Process {process.pid} timed out after {timeout_seconds}s"
160
+ )
161
+
162
+ # Graceful termination first
163
+ try:
164
+ process.terminate()
165
+ logger.debug(f"Sent SIGTERM to process {process.pid}")
166
+
167
+ # Wait for graceful shutdown
168
+ try:
169
+ await asyncio.wait_for(
170
+ process.wait(),
171
+ timeout=TERMINATION_GRACE_SECONDS,
172
+ )
173
+ logger.debug(f"Process {process.pid} terminated gracefully")
174
+ except asyncio.TimeoutError:
175
+ # Force kill if terminate didn't work
176
+ logger.warning(
177
+ f"Process {process.pid} didn't respond to SIGTERM, "
178
+ "sending SIGKILL"
179
+ )
180
+ process.kill()
181
+ await process.wait()
182
+ logger.debug(f"Process {process.pid} killed")
183
+
184
+ except ProcessLookupError:
185
+ # Process already dead
186
+ logger.debug(f"Process {process.pid} already terminated")
187
+
188
+ return (
189
+ b"",
190
+ f"Execution timed out after {timeout_seconds} seconds".encode(),
191
+ TIMEOUT_EXIT_CODE,
192
+ )
193
+
194
+
195
+ # =============================================================================
196
+ # Stdin Piping (T028)
197
+ # =============================================================================
198
+
199
+
200
+ async def execute_agent(
201
+ invoker: AgentInvoker | BaseInvoker,
202
+ prompt_content: str,
203
+ working_dir: Path,
204
+ role: str,
205
+ timeout_seconds: int,
206
+ ) -> InvocationResult:
207
+ """Execute agent with prompt.
208
+
209
+ Spawns the agent process, pipes prompt via stdin if needed,
210
+ waits for completion with timeout, and parses output.
211
+
212
+ Args:
213
+ invoker: Agent invoker that knows how to build commands
214
+ prompt_content: The task prompt content
215
+ working_dir: Directory where agent should execute
216
+ role: Either "implementation" or "review"
217
+ timeout_seconds: Maximum execution time
218
+
219
+ Returns:
220
+ InvocationResult with parsed output
221
+ """
222
+ # Spawn process
223
+ process, cmd = await spawn_agent(invoker, prompt_content, working_dir, role)
224
+
225
+ # Prepare stdin data if agent uses stdin
226
+ if invoker.uses_stdin:
227
+ stdin_data = prompt_content.encode("utf-8")
228
+ logger.debug(f"Piping {len(stdin_data)} bytes to stdin")
229
+ else:
230
+ stdin_data = None
231
+ logger.debug("Agent uses command-line args, no stdin")
232
+
233
+ # Execute with timeout
234
+ start_time = time.time()
235
+ stdout, stderr, exit_code = await execute_with_timeout(
236
+ process,
237
+ stdin_data,
238
+ timeout_seconds,
239
+ )
240
+ duration = time.time() - start_time
241
+
242
+ # Parse output
243
+ result = invoker.parse_output(
244
+ stdout.decode("utf-8", errors="replace"),
245
+ stderr.decode("utf-8", errors="replace"),
246
+ exit_code,
247
+ duration,
248
+ )
249
+
250
+ logger.info(
251
+ f"{invoker.agent_id} completed: exit={exit_code}, "
252
+ f"duration={duration:.1f}s, success={result.success}"
253
+ )
254
+
255
+ return result
256
+
257
+
258
+ # =============================================================================
259
+ # Log File Capture (T029)
260
+ # =============================================================================
261
+
262
+
263
+ def get_log_dir(repo_root: Path) -> Path:
264
+ """Get logs directory under .kittify.
265
+
266
+ Args:
267
+ repo_root: Repository root path
268
+
269
+ Returns:
270
+ Path to logs directory (created if needed)
271
+ """
272
+ logs_dir = repo_root / ".kittify" / LOGS_DIRNAME
273
+ logs_dir.mkdir(parents=True, exist_ok=True)
274
+ return logs_dir
275
+
276
+
277
+ def get_log_path(
278
+ repo_root: Path,
279
+ wp_id: str,
280
+ role: str,
281
+ timestamp: datetime | None = None,
282
+ ) -> Path:
283
+ """Get path for agent log file.
284
+
285
+ Args:
286
+ repo_root: Repository root path
287
+ wp_id: Work package ID (e.g., "WP01")
288
+ role: Either "implementation" or "review"
289
+ timestamp: Optional timestamp for uniqueness
290
+
291
+ Returns:
292
+ Path to log file
293
+ """
294
+ logs_dir = get_log_dir(repo_root)
295
+
296
+ # Include timestamp for uniqueness (useful for retries)
297
+ if timestamp:
298
+ ts = timestamp.strftime("%Y%m%d-%H%M%S")
299
+ filename = f"{wp_id}-{role}-{ts}.log"
300
+ else:
301
+ filename = f"{wp_id}-{role}.log"
302
+
303
+ return logs_dir / filename
304
+
305
+
306
+ def write_log_file(
307
+ log_path: Path,
308
+ agent_id: str,
309
+ role: str,
310
+ result: InvocationResult,
311
+ command: list[str] | None = None,
312
+ ) -> None:
313
+ """Write agent execution log to file.
314
+
315
+ Args:
316
+ log_path: Path to write log file
317
+ agent_id: Agent identifier
318
+ role: Either "implementation" or "review"
319
+ result: Execution result
320
+ command: Optional command that was executed
321
+ """
322
+ log_path.parent.mkdir(parents=True, exist_ok=True)
323
+
324
+ with open(log_path, "w") as f:
325
+ # Header
326
+ f.write("=" * 70 + "\n")
327
+ f.write(f"Agent: {agent_id}\n")
328
+ f.write(f"Role: {role}\n")
329
+ f.write(f"Exit code: {result.exit_code}\n")
330
+ f.write(f"Success: {result.success}\n")
331
+ f.write(f"Duration: {result.duration_seconds:.2f}s\n")
332
+ if command:
333
+ f.write(f"Command: {' '.join(command[:5])}...\n")
334
+ f.write("=" * 70 + "\n\n")
335
+
336
+ # Extracted data
337
+ if result.files_modified:
338
+ f.write("--- FILES MODIFIED ---\n")
339
+ for file in result.files_modified:
340
+ f.write(f" {file}\n")
341
+ f.write("\n")
342
+
343
+ if result.commits_made:
344
+ f.write("--- COMMITS ---\n")
345
+ for commit in result.commits_made:
346
+ f.write(f" {commit}\n")
347
+ f.write("\n")
348
+
349
+ if result.errors:
350
+ f.write("--- ERRORS ---\n")
351
+ for error in result.errors:
352
+ f.write(f" {error}\n")
353
+ f.write("\n")
354
+
355
+ if result.warnings:
356
+ f.write("--- WARNINGS ---\n")
357
+ for warning in result.warnings:
358
+ f.write(f" {warning}\n")
359
+ f.write("\n")
360
+
361
+ # Raw output
362
+ f.write("--- STDOUT ---\n")
363
+ f.write(result.stdout)
364
+ f.write("\n\n--- STDERR ---\n")
365
+ f.write(result.stderr)
366
+
367
+ logger.debug(f"Wrote log file: {log_path}")
368
+
369
+
370
+ async def execute_with_logging(
371
+ invoker: AgentInvoker | BaseInvoker,
372
+ prompt_content: str,
373
+ working_dir: Path,
374
+ role: str,
375
+ timeout_seconds: int,
376
+ log_path: Path,
377
+ ) -> InvocationResult:
378
+ """Execute agent and save output to log file.
379
+
380
+ Combines execution with log file writing.
381
+
382
+ Args:
383
+ invoker: Agent invoker
384
+ prompt_content: The task prompt content
385
+ working_dir: Directory where agent should execute
386
+ role: Either "implementation" or "review"
387
+ timeout_seconds: Maximum execution time
388
+ log_path: Path to write log file
389
+
390
+ Returns:
391
+ InvocationResult with parsed output
392
+ """
393
+ result = await execute_agent(
394
+ invoker,
395
+ prompt_content,
396
+ working_dir,
397
+ role,
398
+ timeout_seconds,
399
+ )
400
+
401
+ # Write log file
402
+ write_log_file(log_path, invoker.agent_id, role, result)
403
+
404
+ return result
405
+
406
+
407
+ # =============================================================================
408
+ # Worktree Creation (T031)
409
+ # =============================================================================
410
+
411
+
412
+ async def create_worktree(
413
+ feature_slug: str,
414
+ wp_id: str,
415
+ base_wp: str | None,
416
+ repo_root: Path,
417
+ ) -> Path:
418
+ """Create worktree for WP using spec-kitty CLI.
419
+
420
+ Args:
421
+ feature_slug: Feature identifier (e.g., "020-feature-name")
422
+ wp_id: Work package ID (e.g., "WP01")
423
+ base_wp: Optional base WP for --base flag
424
+ repo_root: Repository root path
425
+
426
+ Returns:
427
+ Path to created worktree
428
+
429
+ Raises:
430
+ WorktreeCreationError: If creation fails
431
+ """
432
+ # Build command
433
+ cmd = ["spec-kitty", "implement", wp_id, "--feature", feature_slug]
434
+ if base_wp:
435
+ cmd.extend(["--base", base_wp])
436
+
437
+ logger.info(f"Creating worktree for {wp_id}: {' '.join(cmd)}")
438
+
439
+ try:
440
+ process = await asyncio.create_subprocess_exec(
441
+ *cmd,
442
+ cwd=repo_root,
443
+ stdout=asyncio.subprocess.PIPE,
444
+ stderr=asyncio.subprocess.PIPE,
445
+ )
446
+ stdout, stderr = await process.communicate()
447
+
448
+ if process.returncode != 0:
449
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
450
+ stdout_text = stdout.decode("utf-8", errors="replace").strip()
451
+ # Combine both outputs for better error visibility
452
+ error_msg = stderr_text or stdout_text or "Unknown error (no output)"
453
+ raise WorktreeCreationError(
454
+ f"Failed to create worktree for {wp_id}: {error_msg}"
455
+ )
456
+
457
+ except FileNotFoundError:
458
+ raise WorktreeCreationError(
459
+ "spec-kitty command not found. Is spec-kitty-cli installed?"
460
+ )
461
+ except Exception as e:
462
+ raise WorktreeCreationError(
463
+ f"Unexpected error creating worktree: {e}"
464
+ ) from e
465
+
466
+ # Return worktree path
467
+ worktree_path = repo_root / ".worktrees" / f"{feature_slug}-{wp_id}"
468
+
469
+ if not worktree_path.exists():
470
+ raise WorktreeCreationError(
471
+ f"Worktree path {worktree_path} does not exist after creation"
472
+ )
473
+
474
+ logger.info(f"Created worktree: {worktree_path}")
475
+ return worktree_path
476
+
477
+
478
+ def get_worktree_path(
479
+ feature_slug: str,
480
+ wp_id: str,
481
+ repo_root: Path,
482
+ ) -> Path:
483
+ """Get expected worktree path for a WP.
484
+
485
+ Args:
486
+ feature_slug: Feature identifier
487
+ wp_id: Work package ID
488
+ repo_root: Repository root path
489
+
490
+ Returns:
491
+ Expected path to worktree
492
+ """
493
+ return repo_root / ".worktrees" / f"{feature_slug}-{wp_id}"
494
+
495
+
496
+ def worktree_exists(
497
+ feature_slug: str,
498
+ wp_id: str,
499
+ repo_root: Path,
500
+ ) -> bool:
501
+ """Check if worktree already exists.
502
+
503
+ Args:
504
+ feature_slug: Feature identifier
505
+ wp_id: Work package ID
506
+ repo_root: Repository root path
507
+
508
+ Returns:
509
+ True if worktree directory exists
510
+ """
511
+ return get_worktree_path(feature_slug, wp_id, repo_root).exists()
512
+
513
+
514
+ # =============================================================================
515
+ # Full Execution Pipeline
516
+ # =============================================================================
517
+
518
+
519
+ @dataclass
520
+ class ExecutionContext:
521
+ """Context for a single WP execution.
522
+
523
+ Groups together all parameters needed for execution.
524
+ """
525
+
526
+ wp_id: str
527
+ feature_slug: str
528
+ invoker: AgentInvoker | BaseInvoker
529
+ prompt_path: Path
530
+ role: str
531
+ timeout_seconds: int
532
+ repo_root: Path
533
+ working_dir: Path | None = None # Set after worktree creation
534
+
535
+
536
+ async def execute_wp(
537
+ ctx: ExecutionContext,
538
+ create_worktree_if_missing: bool = True,
539
+ base_wp: str | None = None,
540
+ ) -> tuple[InvocationResult, Path]:
541
+ """Execute a complete WP implementation or review.
542
+
543
+ This is the main entry point for WP execution. It:
544
+ 1. Creates worktree if needed
545
+ 2. Reads prompt file
546
+ 3. Executes agent with logging
547
+ 4. Returns result and log path
548
+
549
+ Args:
550
+ ctx: Execution context with all parameters
551
+ create_worktree_if_missing: Whether to create worktree if not exists
552
+ base_wp: Base WP for --base flag when creating worktree
553
+
554
+ Returns:
555
+ Tuple of (InvocationResult, log_path)
556
+
557
+ Raises:
558
+ WorktreeCreationError: If worktree creation fails
559
+ ProcessSpawnError: If agent spawn fails
560
+ FileNotFoundError: If prompt file doesn't exist
561
+ """
562
+ # Get or create worktree
563
+ if ctx.working_dir:
564
+ working_dir = ctx.working_dir
565
+ else:
566
+ worktree_path = get_worktree_path(
567
+ ctx.feature_slug,
568
+ ctx.wp_id,
569
+ ctx.repo_root,
570
+ )
571
+
572
+ if not worktree_path.exists():
573
+ if create_worktree_if_missing:
574
+ worktree_path = await create_worktree(
575
+ ctx.feature_slug,
576
+ ctx.wp_id,
577
+ base_wp,
578
+ ctx.repo_root,
579
+ )
580
+ else:
581
+ raise WorktreeCreationError(
582
+ f"Worktree {worktree_path} does not exist"
583
+ )
584
+
585
+ working_dir = worktree_path
586
+
587
+ # Read prompt content
588
+ if not ctx.prompt_path.exists():
589
+ raise FileNotFoundError(f"Prompt file not found: {ctx.prompt_path}")
590
+
591
+ prompt_content = ctx.prompt_path.read_text()
592
+
593
+ # Get log path
594
+ log_path = get_log_path(
595
+ ctx.repo_root,
596
+ ctx.wp_id,
597
+ ctx.role,
598
+ timestamp=datetime.now(),
599
+ )
600
+
601
+ # Execute with logging
602
+ result = await execute_with_logging(
603
+ ctx.invoker,
604
+ prompt_content,
605
+ working_dir,
606
+ ctx.role,
607
+ ctx.timeout_seconds,
608
+ log_path,
609
+ )
610
+
611
+ return result, log_path
612
+
613
+
614
+ __all__ = [
615
+ # Constants
616
+ "TIMEOUT_EXIT_CODE",
617
+ "TERMINATION_GRACE_SECONDS",
618
+ "LOGS_DIRNAME",
619
+ # Exceptions
620
+ "ExecutorError",
621
+ "WorktreeCreationError",
622
+ "ProcessSpawnError",
623
+ "TimeoutError",
624
+ # Process spawning (T027)
625
+ "spawn_agent",
626
+ # Stdin piping (T028)
627
+ "execute_agent",
628
+ # Log capture (T029)
629
+ "get_log_dir",
630
+ "get_log_path",
631
+ "write_log_file",
632
+ "execute_with_logging",
633
+ # Timeout handling (T030)
634
+ "execute_with_timeout",
635
+ # Worktree integration (T031)
636
+ "create_worktree",
637
+ "get_worktree_path",
638
+ "worktree_exists",
639
+ # Full pipeline
640
+ "ExecutionContext",
641
+ "execute_wp",
642
+ ]