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,898 @@
1
+ """Monitor for tracking execution completion and handling failures.
2
+
3
+ This module handles:
4
+ - Exit code detection and classification (T032)
5
+ - JSON output parsing from agents (T033)
6
+ - Retry logic with configurable limits (T034)
7
+ - Fallback strategy execution (T035)
8
+ - Lane status updates via existing commands (T036)
9
+ - Human escalation when all agents fail (T037)
10
+
11
+ Implemented in WP07.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
22
+
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+
26
+ from specify_cli.orchestrator.agents.base import InvocationResult
27
+ from specify_cli.orchestrator.config import (
28
+ AgentConfig,
29
+ FallbackStrategy,
30
+ OrchestrationStatus,
31
+ OrchestratorConfig,
32
+ WPStatus,
33
+ )
34
+ from specify_cli.orchestrator.state import (
35
+ OrchestrationRun,
36
+ WPExecution,
37
+ save_state,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ pass
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ # =============================================================================
47
+ # Constants
48
+ # =============================================================================
49
+
50
+
51
+ # Timeout exit code (same as Unix `timeout` command)
52
+ TIMEOUT_EXIT_CODE = 124
53
+
54
+ # Delay between retries (seconds)
55
+ RETRY_DELAY_SECONDS = 5
56
+
57
+ # Maximum error message length to store
58
+ MAX_ERROR_LENGTH = 500
59
+
60
+
61
+ # =============================================================================
62
+ # Failure Types (T032)
63
+ # =============================================================================
64
+
65
+
66
+ class FailureType(str, Enum):
67
+ """Classification of execution failures."""
68
+
69
+ TIMEOUT = "timeout"
70
+ AUTH_ERROR = "auth_error"
71
+ RATE_LIMIT = "rate_limit"
72
+ GENERAL_ERROR = "general_error"
73
+ NETWORK_ERROR = "network_error"
74
+
75
+
76
+ # =============================================================================
77
+ # Exit Code Detection (T032)
78
+ # =============================================================================
79
+
80
+
81
+ def is_success(result: InvocationResult) -> bool:
82
+ """Determine if invocation was successful.
83
+
84
+ Args:
85
+ result: The invocation result to check.
86
+
87
+ Returns:
88
+ True if the invocation succeeded (exit code 0 and success flag).
89
+ """
90
+ return result.exit_code == 0 and result.success
91
+
92
+
93
+ def classify_failure(result: InvocationResult, agent_id: str) -> FailureType:
94
+ """Classify the type of failure for appropriate handling.
95
+
96
+ Uses exit codes and stderr content to determine failure type,
97
+ which influences retry and fallback behavior.
98
+
99
+ Args:
100
+ result: The failed invocation result.
101
+ agent_id: The agent that produced the result.
102
+
103
+ Returns:
104
+ FailureType indicating the nature of the failure.
105
+ """
106
+ stderr_lower = result.stderr.lower()
107
+
108
+ # Timeout detection (exit code 124 is Unix timeout standard)
109
+ if result.exit_code == TIMEOUT_EXIT_CODE:
110
+ return FailureType.TIMEOUT
111
+
112
+ # Gemini-specific error codes
113
+ if agent_id == "gemini":
114
+ if result.exit_code == 41: # Gemini auth error
115
+ return FailureType.AUTH_ERROR
116
+ if result.exit_code == 42: # Gemini rate limit
117
+ return FailureType.RATE_LIMIT
118
+
119
+ # Claude-specific error patterns
120
+ if agent_id == "claude-code":
121
+ if "api key" in stderr_lower or "unauthorized" in stderr_lower:
122
+ return FailureType.AUTH_ERROR
123
+ if "rate limit" in stderr_lower or "429" in stderr_lower:
124
+ return FailureType.RATE_LIMIT
125
+
126
+ # Codex-specific patterns
127
+ if agent_id == "codex":
128
+ if "openai api key" in stderr_lower:
129
+ return FailureType.AUTH_ERROR
130
+ if "rate_limit_exceeded" in stderr_lower:
131
+ return FailureType.RATE_LIMIT
132
+
133
+ # Generic pattern matching for stderr content
134
+ if "authentication" in stderr_lower:
135
+ return FailureType.AUTH_ERROR
136
+ if "api key" in stderr_lower or "api_key" in stderr_lower:
137
+ return FailureType.AUTH_ERROR
138
+ if "unauthorized" in stderr_lower or "401" in stderr_lower:
139
+ return FailureType.AUTH_ERROR
140
+
141
+ if "rate limit" in stderr_lower or "rate_limit" in stderr_lower:
142
+ return FailureType.RATE_LIMIT
143
+ if "too many requests" in stderr_lower or "429" in stderr_lower:
144
+ return FailureType.RATE_LIMIT
145
+
146
+ if "network" in stderr_lower or "connection" in stderr_lower:
147
+ return FailureType.NETWORK_ERROR
148
+ if "timeout" in stderr_lower or "timed out" in stderr_lower:
149
+ return FailureType.NETWORK_ERROR
150
+
151
+ return FailureType.GENERAL_ERROR
152
+
153
+
154
+ def should_retry(failure_type: FailureType) -> bool:
155
+ """Determine if a failure type should be retried.
156
+
157
+ Some failures like auth errors won't be fixed by retrying.
158
+
159
+ Args:
160
+ failure_type: The classified failure type.
161
+
162
+ Returns:
163
+ True if retrying the same agent might succeed.
164
+ """
165
+ # Don't retry auth errors - they need user intervention
166
+ if failure_type == FailureType.AUTH_ERROR:
167
+ return False
168
+
169
+ # All other failures might be transient
170
+ return True
171
+
172
+
173
+ # =============================================================================
174
+ # JSON Output Parsing (T033)
175
+ # =============================================================================
176
+
177
+
178
+ def parse_json_output(stdout: str) -> dict | None:
179
+ """Parse JSON output from agent, handling JSONL format.
180
+
181
+ Agents may output JSON in different formats:
182
+ - Single JSON object
183
+ - JSONL (one JSON per line, final line is result)
184
+ - Embedded JSON in other output
185
+
186
+ Args:
187
+ stdout: Raw stdout from the agent.
188
+
189
+ Returns:
190
+ Parsed JSON dict or None if parsing fails.
191
+ """
192
+ if not stdout.strip():
193
+ return None
194
+
195
+ # Try parsing entire output as single JSON
196
+ try:
197
+ return json.loads(stdout)
198
+ except json.JSONDecodeError:
199
+ pass
200
+
201
+ # Try parsing last non-empty lines as JSON (JSONL format)
202
+ lines = stdout.strip().split("\n")
203
+ for line in reversed(lines):
204
+ line = line.strip()
205
+ if not line:
206
+ continue
207
+
208
+ # Only attempt parsing lines that look like JSON
209
+ if line.startswith("{") or line.startswith("["):
210
+ try:
211
+ return json.loads(line)
212
+ except json.JSONDecodeError:
213
+ continue
214
+
215
+ return None
216
+
217
+
218
+ def extract_result_data(json_data: dict | None) -> dict[str, Any]:
219
+ """Extract useful fields from parsed JSON.
220
+
221
+ Normalizes different agent output formats into a standard structure.
222
+
223
+ Args:
224
+ json_data: Parsed JSON from agent output.
225
+
226
+ Returns:
227
+ Dict with normalized fields (files_modified, commits_made, etc.)
228
+ """
229
+ if not json_data:
230
+ return {}
231
+
232
+ result: dict[str, Any] = {}
233
+
234
+ # Extract files modified - different agents use different keys
235
+ for key in ["files", "files_modified", "modified_files", "changedFiles"]:
236
+ if key in json_data and isinstance(json_data[key], list):
237
+ result["files_modified"] = [str(f) for f in json_data[key]]
238
+ break
239
+
240
+ # Extract commits - different agents use different keys
241
+ for key in ["commits", "commits_made", "commitShas", "commit_hashes"]:
242
+ if key in json_data and isinstance(json_data[key], list):
243
+ result["commits_made"] = [str(c) for c in json_data[key]]
244
+ break
245
+
246
+ # Extract errors
247
+ if "errors" in json_data:
248
+ errors = json_data["errors"]
249
+ if isinstance(errors, list):
250
+ result["errors"] = [str(e) for e in errors]
251
+ elif errors:
252
+ result["errors"] = [str(errors)]
253
+ elif "error" in json_data and json_data["error"]:
254
+ result["errors"] = [str(json_data["error"])]
255
+
256
+ # Extract warnings
257
+ if "warnings" in json_data:
258
+ warnings = json_data["warnings"]
259
+ if isinstance(warnings, list):
260
+ result["warnings"] = [str(w) for w in warnings]
261
+ elif warnings:
262
+ result["warnings"] = [str(warnings)]
263
+ elif "warning" in json_data and json_data["warning"]:
264
+ result["warnings"] = [str(json_data["warning"])]
265
+
266
+ return result
267
+
268
+
269
+ def analyze_output(result: InvocationResult) -> dict[str, Any]:
270
+ """Analyze agent output and extract structured data.
271
+
272
+ Combines exit code analysis with JSON parsing for comprehensive result.
273
+
274
+ Args:
275
+ result: The invocation result to analyze.
276
+
277
+ Returns:
278
+ Dict with analysis results including any extracted structured data.
279
+ """
280
+ analysis: dict[str, Any] = {
281
+ "success": is_success(result),
282
+ "exit_code": result.exit_code,
283
+ "duration_seconds": result.duration_seconds,
284
+ }
285
+
286
+ # Try to parse JSON from stdout
287
+ json_data = parse_json_output(result.stdout)
288
+ if json_data:
289
+ analysis["json_data"] = json_data
290
+ analysis.update(extract_result_data(json_data))
291
+
292
+ # Use data already extracted by invoker if no JSON found
293
+ if "files_modified" not in analysis and result.files_modified:
294
+ analysis["files_modified"] = result.files_modified
295
+ if "commits_made" not in analysis and result.commits_made:
296
+ analysis["commits_made"] = result.commits_made
297
+ if "errors" not in analysis and result.errors:
298
+ analysis["errors"] = result.errors
299
+ if "warnings" not in analysis and result.warnings:
300
+ analysis["warnings"] = result.warnings
301
+
302
+ return analysis
303
+
304
+
305
+ # =============================================================================
306
+ # Retry Logic (T034)
307
+ # =============================================================================
308
+
309
+
310
+ async def execute_with_retry(
311
+ executor_fn: Callable[[], Awaitable[InvocationResult]],
312
+ wp_execution: WPExecution,
313
+ config: OrchestratorConfig,
314
+ role: str,
315
+ agent_id: str,
316
+ ) -> InvocationResult:
317
+ """Execute with retry logic.
318
+
319
+ Retries failed invocations up to the configured limit, with a delay
320
+ between attempts. Updates the WP execution state with retry count.
321
+
322
+ Args:
323
+ executor_fn: Async function to execute (returns InvocationResult).
324
+ wp_execution: WP execution state to update.
325
+ config: Orchestrator config for retry limits.
326
+ role: "implementation" or "review".
327
+ agent_id: The agent being used (for failure classification).
328
+
329
+ Returns:
330
+ Final InvocationResult (success or last failure).
331
+ """
332
+ max_retries = config.max_retries
333
+ attempt = 0
334
+
335
+ # Get current retry count for this role
336
+ if role == "implementation":
337
+ retries = wp_execution.implementation_retries
338
+ else:
339
+ retries = wp_execution.review_retries
340
+
341
+ while attempt <= max_retries:
342
+ result = await executor_fn()
343
+
344
+ if is_success(result):
345
+ logger.info(
346
+ f"WP {wp_execution.wp_id} {role} succeeded on attempt {attempt + 1}"
347
+ )
348
+ return result
349
+
350
+ # Classify the failure
351
+ failure_type = classify_failure(result, agent_id)
352
+ logger.warning(
353
+ f"WP {wp_execution.wp_id} {role} failed: {failure_type.value}"
354
+ )
355
+
356
+ # Store the error (truncated)
357
+ error_msg = result.stderr[:MAX_ERROR_LENGTH] if result.stderr else ""
358
+ if not error_msg and result.errors:
359
+ error_msg = "; ".join(result.errors)[:MAX_ERROR_LENGTH]
360
+ wp_execution.last_error = error_msg
361
+
362
+ # Check if this failure type should be retried
363
+ if not should_retry(failure_type):
364
+ logger.warning(
365
+ f"WP {wp_execution.wp_id} {role} failure type {failure_type.value} "
366
+ "is not retryable"
367
+ )
368
+ break
369
+
370
+ attempt += 1
371
+ retries += 1
372
+
373
+ # Update retry count in state
374
+ if role == "implementation":
375
+ wp_execution.implementation_retries = retries
376
+ else:
377
+ wp_execution.review_retries = retries
378
+
379
+ if attempt <= max_retries:
380
+ logger.info(
381
+ f"WP {wp_execution.wp_id} {role} retrying "
382
+ f"(attempt {attempt + 1}/{max_retries + 1})..."
383
+ )
384
+ await asyncio.sleep(RETRY_DELAY_SECONDS)
385
+
386
+ return result
387
+
388
+
389
+ # =============================================================================
390
+ # Fallback Strategy Execution (T035)
391
+ # =============================================================================
392
+
393
+
394
+ def apply_fallback(
395
+ wp_id: str,
396
+ role: str,
397
+ failed_agent: str,
398
+ config: OrchestratorConfig,
399
+ state: OrchestrationRun,
400
+ ) -> str | None:
401
+ """Apply fallback strategy and return next agent to try.
402
+
403
+ Implements the configured fallback strategy to select the next
404
+ agent after a failure. Updates the WP execution state with
405
+ tried agents.
406
+
407
+ Args:
408
+ wp_id: Work package ID.
409
+ role: "implementation" or "review".
410
+ failed_agent: The agent that just failed.
411
+ config: Orchestrator config with fallback strategy.
412
+ state: Orchestration run state.
413
+
414
+ Returns:
415
+ Next agent ID to try, or None if no fallback available.
416
+ """
417
+ strategy = config.fallback_strategy
418
+ wp_execution = state.work_packages[wp_id]
419
+
420
+ logger.info(
421
+ f"Applying fallback strategy '{strategy.value}' for {wp_id} {role}"
422
+ )
423
+
424
+ if strategy == FallbackStrategy.FAIL:
425
+ # No fallback - fail immediately
426
+ logger.info("Fallback strategy is FAIL, no fallback attempt")
427
+ return None
428
+
429
+ if strategy == FallbackStrategy.SAME_AGENT:
430
+ # In single-agent mode or same_agent strategy, just fail after retries
431
+ logger.info("Fallback strategy is SAME_AGENT, no fallback to other agents")
432
+ return None
433
+
434
+ if strategy == FallbackStrategy.NEXT_IN_LIST:
435
+ # Track the failed agent
436
+ if failed_agent not in wp_execution.fallback_agents_tried:
437
+ wp_execution.fallback_agents_tried.append(failed_agent)
438
+
439
+ # Get candidates from defaults list for this role
440
+ candidates = config.defaults.get(role, [])
441
+
442
+ for agent_id in candidates:
443
+ # Skip agents we've already tried
444
+ if agent_id in wp_execution.fallback_agents_tried:
445
+ continue
446
+
447
+ # Check if agent is enabled
448
+ agent_config = config.agents.get(agent_id)
449
+ if agent_config is None:
450
+ continue
451
+ if not agent_config.enabled:
452
+ continue
453
+
454
+ # Check if agent supports this role
455
+ if role not in agent_config.roles:
456
+ continue
457
+
458
+ logger.info(f"Fallback: next agent for {wp_id} {role} is {agent_id}")
459
+ return agent_id
460
+
461
+ # All candidates exhausted
462
+ logger.warning(
463
+ f"Fallback: all agents exhausted for {wp_id} {role}. "
464
+ f"Tried: {wp_execution.fallback_agents_tried}"
465
+ )
466
+ return None
467
+
468
+ # Unknown strategy (shouldn't happen)
469
+ logger.error(f"Unknown fallback strategy: {strategy}")
470
+ return None
471
+
472
+
473
+ def get_available_fallback_agents(
474
+ wp_id: str,
475
+ role: str,
476
+ config: OrchestratorConfig,
477
+ state: OrchestrationRun,
478
+ ) -> list[str]:
479
+ """Get list of agents that haven't been tried yet for fallback.
480
+
481
+ Args:
482
+ wp_id: Work package ID.
483
+ role: "implementation" or "review".
484
+ config: Orchestrator config.
485
+ state: Orchestration run state.
486
+
487
+ Returns:
488
+ List of agent IDs that are available for fallback.
489
+ """
490
+ wp_execution = state.work_packages[wp_id]
491
+ tried = set(wp_execution.fallback_agents_tried)
492
+ candidates = config.defaults.get(role, [])
493
+
494
+ available = []
495
+ for agent_id in candidates:
496
+ if agent_id in tried:
497
+ continue
498
+ agent_config = config.agents.get(agent_id)
499
+ if agent_config and agent_config.enabled and role in agent_config.roles:
500
+ available.append(agent_id)
501
+
502
+ return available
503
+
504
+
505
+ # =============================================================================
506
+ # Lane Status Updates (T036)
507
+ # =============================================================================
508
+
509
+
510
+ async def update_wp_lane(
511
+ wp_id: str,
512
+ lane: str,
513
+ note: str,
514
+ repo_root: Path,
515
+ ) -> bool:
516
+ """Update WP lane status using spec-kitty command.
517
+
518
+ Calls the existing CLI command to update the lane, ensuring
519
+ proper integration with the rest of the spec-kitty workflow.
520
+
521
+ Args:
522
+ wp_id: Work package ID.
523
+ lane: Target lane (doing, for_review, done).
524
+ note: Status note for the history.
525
+ repo_root: Repository root for command execution.
526
+
527
+ Returns:
528
+ True if the command succeeded.
529
+ """
530
+ cmd = [
531
+ "spec-kitty",
532
+ "agent",
533
+ "tasks",
534
+ "move-task",
535
+ wp_id,
536
+ "--to",
537
+ lane,
538
+ "--note",
539
+ note,
540
+ ]
541
+
542
+ logger.info(f"Updating {wp_id} lane to '{lane}'")
543
+ logger.debug(f"Command: {' '.join(cmd)}")
544
+
545
+ try:
546
+ process = await asyncio.create_subprocess_exec(
547
+ *cmd,
548
+ cwd=repo_root,
549
+ stdout=asyncio.subprocess.PIPE,
550
+ stderr=asyncio.subprocess.PIPE,
551
+ )
552
+ stdout, stderr = await process.communicate()
553
+
554
+ if process.returncode == 0:
555
+ logger.info(f"Successfully updated {wp_id} to '{lane}'")
556
+ return True
557
+ else:
558
+ stderr_text = stderr.decode("utf-8", errors="replace")
559
+ logger.error(f"Failed to update {wp_id} lane: {stderr_text}")
560
+ return False
561
+
562
+ except FileNotFoundError:
563
+ logger.error("spec-kitty command not found. Is spec-kitty-cli installed?")
564
+ return False
565
+ except Exception as e:
566
+ logger.error(f"Unexpected error updating lane: {e}")
567
+ return False
568
+
569
+
570
+ async def mark_subtask_done(
571
+ subtask_id: str,
572
+ repo_root: Path,
573
+ ) -> bool:
574
+ """Mark a subtask as done using spec-kitty command.
575
+
576
+ Args:
577
+ subtask_id: Subtask ID (e.g., "T001").
578
+ repo_root: Repository root for command execution.
579
+
580
+ Returns:
581
+ True if the command succeeded.
582
+ """
583
+ cmd = [
584
+ "spec-kitty",
585
+ "agent",
586
+ "tasks",
587
+ "mark-status",
588
+ subtask_id,
589
+ "--status",
590
+ "done",
591
+ ]
592
+
593
+ logger.info(f"Marking subtask {subtask_id} as done")
594
+
595
+ try:
596
+ process = await asyncio.create_subprocess_exec(
597
+ *cmd,
598
+ cwd=repo_root,
599
+ stdout=asyncio.subprocess.PIPE,
600
+ stderr=asyncio.subprocess.PIPE,
601
+ )
602
+ await process.communicate()
603
+ return process.returncode == 0
604
+ except Exception as e:
605
+ logger.error(f"Failed to mark subtask done: {e}")
606
+ return False
607
+
608
+
609
+ # Lane transition helpers
610
+ LANE_TRANSITIONS = {
611
+ # (from_status, event) -> (to_status, to_lane)
612
+ # Starting implementation
613
+ (WPStatus.PENDING, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
614
+ (WPStatus.READY, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
615
+ # Idempotent: already implementing, stay in implementation
616
+ (WPStatus.IMPLEMENTATION, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
617
+ # Completing implementation
618
+ (WPStatus.IMPLEMENTATION, "complete_implementation"): (
619
+ WPStatus.REVIEW,
620
+ "for_review",
621
+ ),
622
+ # Idempotent: already in review, stay in review
623
+ (WPStatus.REVIEW, "complete_implementation"): (WPStatus.REVIEW, "for_review"),
624
+ # Completing review
625
+ (WPStatus.REVIEW, "complete_review"): (WPStatus.COMPLETED, "done"),
626
+ # Rework: going back to implementation
627
+ (WPStatus.REWORK, "start_implementation"): (WPStatus.IMPLEMENTATION, "doing"),
628
+ }
629
+
630
+
631
+ async def transition_wp_lane(
632
+ wp_execution: WPExecution,
633
+ event: str,
634
+ repo_root: Path,
635
+ ) -> bool:
636
+ """Transition WP to the next lane based on event.
637
+
638
+ Args:
639
+ wp_execution: The WP execution state.
640
+ event: The event triggering the transition.
641
+ repo_root: Repository root for command execution.
642
+
643
+ Returns:
644
+ True if transition succeeded.
645
+ """
646
+ current_status = wp_execution.status
647
+ key = (current_status, event)
648
+
649
+ if key not in LANE_TRANSITIONS:
650
+ logger.warning(
651
+ f"No transition defined for {wp_execution.wp_id} "
652
+ f"from {current_status.value} on event '{event}'"
653
+ )
654
+ return False
655
+
656
+ new_status, new_lane = LANE_TRANSITIONS[key]
657
+ note = f"Automated: {event.replace('_', ' ')}"
658
+
659
+ success = await update_wp_lane(
660
+ wp_execution.wp_id,
661
+ new_lane,
662
+ note,
663
+ repo_root,
664
+ )
665
+
666
+ if success:
667
+ wp_execution.status = new_status
668
+ logger.info(
669
+ f"{wp_execution.wp_id}: {current_status.value} -> {new_status.value}"
670
+ )
671
+
672
+ return success
673
+
674
+
675
+ # =============================================================================
676
+ # Human Escalation (T037)
677
+ # =============================================================================
678
+
679
+
680
+ async def escalate_to_human(
681
+ wp_id: str,
682
+ role: str,
683
+ state: OrchestrationRun,
684
+ repo_root: Path,
685
+ console: Console | None = None,
686
+ ) -> None:
687
+ """Pause orchestration and alert user when all agents fail.
688
+
689
+ Sets orchestration status to PAUSED and prints clear instructions
690
+ for the user on how to proceed.
691
+
692
+ Args:
693
+ wp_id: The WP that failed.
694
+ role: The role that failed ("implementation" or "review").
695
+ state: Orchestration run state to update.
696
+ repo_root: Repository root for state persistence.
697
+ console: Rich console for output (creates one if not provided).
698
+ """
699
+ if console is None:
700
+ console = Console()
701
+
702
+ wp_execution = state.work_packages[wp_id]
703
+
704
+ # Update state
705
+ state.status = OrchestrationStatus.PAUSED
706
+ wp_execution.status = WPStatus.FAILED
707
+ state.wps_failed += 1
708
+
709
+ # Save state for resume capability
710
+ save_state(state, repo_root)
711
+
712
+ # Get log file path if available
713
+ log_file_info = ""
714
+ if wp_execution.log_file:
715
+ log_file_info = f"\nLog file: {wp_execution.log_file}"
716
+
717
+ # Get tried agents info
718
+ tried_agents = wp_execution.fallback_agents_tried
719
+ tried_info = f"Agents tried: {', '.join(tried_agents)}" if tried_agents else ""
720
+
721
+ # Format the error message
722
+ last_error = wp_execution.last_error or "No error message captured"
723
+ if len(last_error) > 300:
724
+ last_error = last_error[:300] + "..."
725
+
726
+ # Print alert panel
727
+ console.print()
728
+ console.print(
729
+ Panel(
730
+ f"[bold red]Orchestration Paused[/bold red]\n\n"
731
+ f"Work package [bold]{wp_id}[/bold] failed during {role}.\n"
732
+ f"All agents exhausted after retries and fallbacks.\n"
733
+ f"{tried_info}\n\n"
734
+ f"[bold]Last error:[/bold]\n{last_error}\n"
735
+ f"{log_file_info}\n\n"
736
+ f"[bold]Options:[/bold]\n"
737
+ f"1. Fix the issue and resume:\n"
738
+ f" [cyan]spec-kitty orchestrate --resume[/cyan]\n\n"
739
+ f"2. Skip this WP and continue:\n"
740
+ f" [cyan]spec-kitty orchestrate --skip {wp_id}[/cyan]\n\n"
741
+ f"3. Abort the orchestration:\n"
742
+ f" [cyan]spec-kitty orchestrate --abort[/cyan]",
743
+ title="Human Intervention Required",
744
+ border_style="red",
745
+ )
746
+ )
747
+ console.print()
748
+
749
+ logger.info(f"Orchestration paused: {wp_id} failed during {role}")
750
+
751
+
752
+ def get_escalation_summary(state: OrchestrationRun) -> dict[str, Any]:
753
+ """Get a summary of escalation state for programmatic access.
754
+
755
+ Args:
756
+ state: Orchestration run state.
757
+
758
+ Returns:
759
+ Dict with escalation details.
760
+ """
761
+ failed_wps = [
762
+ wp_id
763
+ for wp_id, wp in state.work_packages.items()
764
+ if wp.status == WPStatus.FAILED
765
+ ]
766
+
767
+ return {
768
+ "is_paused": state.status == OrchestrationStatus.PAUSED,
769
+ "failed_wps": failed_wps,
770
+ "total_wps": state.wps_total,
771
+ "completed_wps": state.wps_completed,
772
+ "failed_count": state.wps_failed,
773
+ "details": {
774
+ wp_id: {
775
+ "last_error": state.work_packages[wp_id].last_error,
776
+ "agents_tried": state.work_packages[wp_id].fallback_agents_tried,
777
+ "log_file": str(state.work_packages[wp_id].log_file)
778
+ if state.work_packages[wp_id].log_file
779
+ else None,
780
+ }
781
+ for wp_id in failed_wps
782
+ },
783
+ }
784
+
785
+
786
+ # =============================================================================
787
+ # Combined Monitor Functions
788
+ # =============================================================================
789
+
790
+
791
+ async def handle_wp_failure(
792
+ wp_id: str,
793
+ role: str,
794
+ failed_agent: str,
795
+ result: InvocationResult,
796
+ config: OrchestratorConfig,
797
+ state: OrchestrationRun,
798
+ repo_root: Path,
799
+ execute_phase_fn: Callable[
800
+ [str, str, str], Awaitable[InvocationResult]
801
+ ] | None = None,
802
+ console: Console | None = None,
803
+ ) -> InvocationResult | None:
804
+ """Handle a WP phase failure with fallback and escalation.
805
+
806
+ Coordinates the fallback strategy and escalation flow after
807
+ a WP phase fails.
808
+
809
+ Args:
810
+ wp_id: Work package ID.
811
+ role: "implementation" or "review".
812
+ failed_agent: The agent that failed.
813
+ result: The failed invocation result.
814
+ config: Orchestrator config.
815
+ state: Orchestration run state.
816
+ repo_root: Repository root.
817
+ execute_phase_fn: Optional function to execute with a new agent.
818
+ Signature: (wp_id, role, agent_id) -> InvocationResult
819
+ console: Rich console for output.
820
+
821
+ Returns:
822
+ InvocationResult from successful retry/fallback, or None if
823
+ escalated to human.
824
+ """
825
+ failure_type = classify_failure(result, failed_agent)
826
+ logger.info(f"Handling failure for {wp_id} {role}: {failure_type.value}")
827
+
828
+ # Try fallback if available
829
+ next_agent = apply_fallback(wp_id, role, failed_agent, config, state)
830
+
831
+ if next_agent and execute_phase_fn:
832
+ logger.info(f"Attempting fallback with {next_agent} for {wp_id} {role}")
833
+ return await execute_phase_fn(wp_id, role, next_agent)
834
+
835
+ # No fallback available - escalate to human
836
+ await escalate_to_human(wp_id, role, state, repo_root, console)
837
+ return None
838
+
839
+
840
+ def update_wp_progress(
841
+ wp_execution: WPExecution,
842
+ result: InvocationResult,
843
+ role: str,
844
+ ) -> None:
845
+ """Update WP execution state based on result.
846
+
847
+ Args:
848
+ wp_execution: WP execution state to update.
849
+ result: The invocation result.
850
+ role: "implementation" or "review".
851
+ """
852
+ if role == "implementation":
853
+ wp_execution.implementation_exit_code = result.exit_code
854
+ else:
855
+ wp_execution.review_exit_code = result.exit_code
856
+
857
+ # Analyze output and update any extracted data
858
+ analysis = analyze_output(result)
859
+
860
+ if not is_success(result):
861
+ error_msg = result.stderr[:MAX_ERROR_LENGTH] if result.stderr else ""
862
+ if not error_msg and analysis.get("errors"):
863
+ error_msg = "; ".join(analysis["errors"])[:MAX_ERROR_LENGTH]
864
+ wp_execution.last_error = error_msg
865
+
866
+
867
+ __all__ = [
868
+ # Constants
869
+ "TIMEOUT_EXIT_CODE",
870
+ "RETRY_DELAY_SECONDS",
871
+ "MAX_ERROR_LENGTH",
872
+ # Enums
873
+ "FailureType",
874
+ # Exit code detection (T032)
875
+ "is_success",
876
+ "classify_failure",
877
+ "should_retry",
878
+ # JSON parsing (T033)
879
+ "parse_json_output",
880
+ "extract_result_data",
881
+ "analyze_output",
882
+ # Retry logic (T034)
883
+ "execute_with_retry",
884
+ # Fallback strategy (T035)
885
+ "apply_fallback",
886
+ "get_available_fallback_agents",
887
+ # Lane updates (T036)
888
+ "update_wp_lane",
889
+ "mark_subtask_done",
890
+ "transition_wp_lane",
891
+ "LANE_TRANSITIONS",
892
+ # Human escalation (T037)
893
+ "escalate_to_human",
894
+ "get_escalation_summary",
895
+ # Combined functions
896
+ "handle_wp_failure",
897
+ "update_wp_progress",
898
+ ]