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,832 @@
1
+ """Scheduler for orchestrating work package execution.
2
+
3
+ This module handles:
4
+ - Dependency graph reading from WP frontmatter
5
+ - Ready WP detection (dependencies satisfied)
6
+ - Agent selection by role and priority
7
+ - Concurrency management via semaphores
8
+ - Single-agent mode handling
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ from contextlib import asynccontextmanager
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, AsyncIterator
18
+
19
+ from specify_cli.core.dependency_graph import (
20
+ build_dependency_graph,
21
+ detect_cycles,
22
+ topological_sort,
23
+ )
24
+ from specify_cli.orchestrator.config import OrchestratorConfig, WPStatus
25
+ from specify_cli.orchestrator.state import OrchestrationRun, WPExecution
26
+
27
+ if TYPE_CHECKING:
28
+ pass
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # =============================================================================
34
+ # Exceptions
35
+ # =============================================================================
36
+
37
+
38
+ class SchedulerError(Exception):
39
+ """Base exception for scheduler errors."""
40
+
41
+ pass
42
+
43
+
44
+ class DependencyGraphError(SchedulerError):
45
+ """Raised when dependency graph is invalid."""
46
+
47
+ pass
48
+
49
+
50
+ class NoAgentAvailableError(SchedulerError):
51
+ """Raised when no agent is available for a role."""
52
+
53
+ pass
54
+
55
+
56
+ # =============================================================================
57
+ # Dependency Graph (T022)
58
+ # =============================================================================
59
+
60
+
61
+ def build_wp_graph(feature_dir: Path) -> dict[str, list[str]]:
62
+ """Build WP dependency graph from task frontmatter.
63
+
64
+ Wraps the existing build_dependency_graph function from core module.
65
+
66
+ Args:
67
+ feature_dir: Path to feature directory (contains tasks/ subdirectory)
68
+
69
+ Returns:
70
+ Dict mapping WP ID to list of dependency WP IDs.
71
+ e.g., {"WP02": ["WP01"], "WP03": ["WP01", "WP02"]}
72
+
73
+ Raises:
74
+ DependencyGraphError: If graph has cycles or invalid references.
75
+ """
76
+ graph = build_dependency_graph(feature_dir)
77
+
78
+ if not graph:
79
+ logger.warning(f"No work packages found in {feature_dir}")
80
+ return {}
81
+
82
+ logger.info(f"Built dependency graph with {len(graph)} work packages")
83
+ return graph
84
+
85
+
86
+ def validate_wp_graph(graph: dict[str, list[str]]) -> None:
87
+ """Validate WP dependency graph.
88
+
89
+ Checks for:
90
+ - Circular dependencies
91
+ - Invalid dependency references
92
+
93
+ Args:
94
+ graph: Dependency graph from build_wp_graph()
95
+
96
+ Raises:
97
+ DependencyGraphError: If validation fails.
98
+ """
99
+ if not graph:
100
+ return
101
+
102
+ # Check for cycles
103
+ cycles = detect_cycles(graph)
104
+ if cycles:
105
+ cycle_strs = [" -> ".join(cycle) for cycle in cycles]
106
+ raise DependencyGraphError(
107
+ f"Circular dependencies detected:\n " + "\n ".join(cycle_strs)
108
+ )
109
+
110
+ # Check all dependencies exist in graph
111
+ all_wp_ids = set(graph.keys())
112
+ for wp_id, deps in graph.items():
113
+ for dep in deps:
114
+ if dep not in all_wp_ids:
115
+ raise DependencyGraphError(
116
+ f"WP {wp_id} depends on {dep}, but {dep} does not exist"
117
+ )
118
+
119
+ logger.debug("Dependency graph validation passed")
120
+
121
+
122
+ def get_topological_order(graph: dict[str, list[str]]) -> list[str]:
123
+ """Get work packages in topological order.
124
+
125
+ Returns WPs ordered so that dependencies come before dependents.
126
+
127
+ Args:
128
+ graph: Validated dependency graph
129
+
130
+ Returns:
131
+ List of WP IDs in topological order
132
+ """
133
+ if not graph:
134
+ return []
135
+
136
+ return topological_sort(graph)
137
+
138
+
139
+ # =============================================================================
140
+ # Ready WP Detection (T023)
141
+ # =============================================================================
142
+
143
+
144
+ def get_ready_wps(
145
+ graph: dict[str, list[str]],
146
+ state: OrchestrationRun,
147
+ ) -> list[str]:
148
+ """Return WP IDs that are ready to execute.
149
+
150
+ A WP is ready if:
151
+ 1. All dependencies have completed successfully
152
+ 2. WP itself is in "pending" or "rework" status
153
+
154
+ REWORK status means the WP was reviewed and rejected, needing
155
+ re-implementation with the review feedback.
156
+
157
+ Args:
158
+ graph: Dependency graph from build_wp_graph()
159
+ state: Current orchestration state
160
+
161
+ Returns:
162
+ List of WP IDs ready for execution, sorted by topological order
163
+ """
164
+ ready = []
165
+
166
+ # Statuses that indicate a WP can be started/restarted
167
+ startable_statuses = {WPStatus.PENDING, WPStatus.REWORK}
168
+
169
+ for wp_id, deps in graph.items():
170
+ # Get WP state, defaulting to pending if not tracked yet
171
+ wp_state = state.work_packages.get(wp_id)
172
+
173
+ # Skip if not in a startable status
174
+ if wp_state and wp_state.status not in startable_statuses:
175
+ continue
176
+
177
+ # Check all dependencies completed successfully
178
+ all_deps_done = True
179
+ for dep_id in deps:
180
+ dep_state = state.work_packages.get(dep_id)
181
+ if not dep_state or dep_state.status != WPStatus.COMPLETED:
182
+ all_deps_done = False
183
+ break
184
+
185
+ if all_deps_done:
186
+ ready.append(wp_id)
187
+
188
+ # Sort by topological order for determinism
189
+ if ready:
190
+ try:
191
+ topo_order = get_topological_order(graph)
192
+ order_map = {wp: i for i, wp in enumerate(topo_order)}
193
+ ready.sort(key=lambda wp: order_map.get(wp, 999))
194
+ except ValueError:
195
+ # If topo sort fails (shouldn't after validation), just sort by ID
196
+ ready.sort()
197
+
198
+ logger.debug(f"Ready WPs: {ready}")
199
+ return ready
200
+
201
+
202
+ def get_blocked_wps(
203
+ graph: dict[str, list[str]],
204
+ state: OrchestrationRun,
205
+ ) -> dict[str, list[str]]:
206
+ """Get WPs that are blocked waiting on dependencies.
207
+
208
+ Args:
209
+ graph: Dependency graph
210
+ state: Current orchestration state
211
+
212
+ Returns:
213
+ Dict mapping blocked WP ID to list of blocking dependency IDs
214
+ """
215
+ blocked = {}
216
+
217
+ for wp_id, deps in graph.items():
218
+ wp_state = state.work_packages.get(wp_id)
219
+
220
+ # Only check pending WPs
221
+ if wp_state and wp_state.status != WPStatus.PENDING:
222
+ continue
223
+
224
+ # Find incomplete dependencies
225
+ blocking_deps = []
226
+ for dep_id in deps:
227
+ dep_state = state.work_packages.get(dep_id)
228
+ if not dep_state or dep_state.status != WPStatus.COMPLETED:
229
+ blocking_deps.append(dep_id)
230
+
231
+ if blocking_deps:
232
+ blocked[wp_id] = blocking_deps
233
+
234
+ return blocked
235
+
236
+
237
+ # =============================================================================
238
+ # Agent Selection (T024)
239
+ # =============================================================================
240
+
241
+
242
+ def _count_active_agent_tasks(
243
+ agent_id: str,
244
+ state: OrchestrationRun | None,
245
+ ) -> int:
246
+ """Count how many tasks an agent is currently running.
247
+
248
+ Args:
249
+ agent_id: Agent identifier
250
+ state: Current orchestration state
251
+
252
+ Returns:
253
+ Number of active tasks for this agent
254
+ """
255
+ if not state:
256
+ return 0
257
+
258
+ count = 0
259
+ for wp in state.work_packages.values():
260
+ # Count implementation tasks
261
+ if (
262
+ wp.status == WPStatus.IMPLEMENTATION
263
+ and wp.implementation_agent == agent_id
264
+ ):
265
+ count += 1
266
+
267
+ # Count review tasks
268
+ if wp.status == WPStatus.REVIEW and wp.review_agent == agent_id:
269
+ count += 1
270
+
271
+ return count
272
+
273
+
274
+ def _agent_at_limit(
275
+ agent_id: str,
276
+ config: OrchestratorConfig,
277
+ state: OrchestrationRun | None,
278
+ ) -> bool:
279
+ """Check if agent has reached its concurrency limit.
280
+
281
+ Args:
282
+ agent_id: Agent identifier
283
+ config: Orchestrator configuration
284
+ state: Current orchestration state
285
+
286
+ Returns:
287
+ True if agent is at its max_concurrent limit
288
+ """
289
+ agent_config = config.agents.get(agent_id)
290
+ if not agent_config:
291
+ return True # Unknown agent treated as at limit
292
+
293
+ active_count = _count_active_agent_tasks(agent_id, state)
294
+ return active_count >= agent_config.max_concurrent
295
+
296
+
297
+ def select_agent_from_user_config(
298
+ repo_root: Path,
299
+ role: str,
300
+ exclude_agent: str | None = None,
301
+ override_agent: str | None = None,
302
+ ) -> str | None:
303
+ """Select agent using user configuration from spec-kitty init.
304
+
305
+ This is the preferred way to select agents - uses the configuration
306
+ set by the user during `spec-kitty init`.
307
+
308
+ Args:
309
+ repo_root: Repository root for loading config
310
+ role: "implementation" or "review"
311
+ exclude_agent: Agent to exclude (for cross-review)
312
+ override_agent: CLI override to use specific agent
313
+
314
+ Returns:
315
+ Canonical agent ID (normalized from aliases) or None if no agents configured
316
+ """
317
+ from specify_cli.orchestrator.agent_config import load_agent_config
318
+ from specify_cli.orchestrator.agents import normalize_agent_id
319
+
320
+ # CLI override takes precedence
321
+ if override_agent:
322
+ logger.info(f"Using CLI override agent: {override_agent}")
323
+ return normalize_agent_id(override_agent)
324
+
325
+ config = load_agent_config(repo_root)
326
+
327
+ if not config.available:
328
+ logger.warning("No agents configured in .kittify/config.yaml")
329
+ return None
330
+
331
+ # Select agent and normalize to canonical ID
332
+ if role == "implementation":
333
+ selected = config.select_implementer(exclude=exclude_agent)
334
+ elif role == "review":
335
+ selected = config.select_reviewer(implementer=exclude_agent)
336
+ else:
337
+ logger.warning(f"Unknown role: {role}")
338
+ selected = config.available[0] if config.available else None
339
+
340
+ return normalize_agent_id(selected) if selected else None
341
+
342
+
343
+ def select_agent(
344
+ config: OrchestratorConfig,
345
+ role: str,
346
+ exclude_agent: str | None = None,
347
+ state: OrchestrationRun | None = None,
348
+ ) -> str | None:
349
+ """Select highest-priority available agent for role.
350
+
351
+ NOTE: This is the legacy selection method using OrchestratorConfig.
352
+ Prefer select_agent_from_user_config() which uses the configuration
353
+ set during `spec-kitty init`.
354
+
355
+ Args:
356
+ config: Orchestrator configuration
357
+ role: "implementation" or "review"
358
+ exclude_agent: Agent to exclude (for cross-agent review)
359
+ state: Current state (for concurrency tracking)
360
+
361
+ Returns:
362
+ Agent ID or None if no agent available
363
+ """
364
+ # Get candidates from defaults, maintaining priority order
365
+ candidates = config.defaults.get(role, [])
366
+
367
+ if not candidates:
368
+ # Fall back to all enabled agents with this role
369
+ candidates = [
370
+ agent_id
371
+ for agent_id, agent_config in config.agents.items()
372
+ if agent_config.enabled and role in agent_config.roles
373
+ ]
374
+ # Sort by priority
375
+ candidates.sort(
376
+ key=lambda aid: config.agents[aid].priority
377
+ )
378
+
379
+ for agent_id in candidates:
380
+ agent_config = config.agents.get(agent_id)
381
+ if not agent_config:
382
+ logger.debug(f"Agent {agent_id} not found in config")
383
+ continue
384
+
385
+ if not agent_config.enabled:
386
+ logger.debug(f"Agent {agent_id} is disabled")
387
+ continue
388
+
389
+ if role not in agent_config.roles:
390
+ logger.debug(f"Agent {agent_id} does not support role {role}")
391
+ continue
392
+
393
+ if agent_id == exclude_agent:
394
+ logger.debug(f"Agent {agent_id} excluded for cross-agent review")
395
+ continue
396
+
397
+ # Check concurrency limit
398
+ if _agent_at_limit(agent_id, config, state):
399
+ logger.debug(f"Agent {agent_id} at concurrency limit")
400
+ continue
401
+
402
+ logger.info(f"Selected agent {agent_id} for {role}")
403
+ return agent_id
404
+
405
+ logger.warning(f"No agent available for role {role}")
406
+ return None
407
+
408
+
409
+ def select_review_agent_from_user_config(
410
+ repo_root: Path,
411
+ implementation_agent: str,
412
+ override_agent: str | None = None,
413
+ ) -> str | None:
414
+ """Select review agent using user configuration from spec-kitty init.
415
+
416
+ Prefers a different agent than implementation for cross-review.
417
+
418
+ Args:
419
+ repo_root: Repository root for loading config
420
+ implementation_agent: Agent that did implementation (may be alias or canonical)
421
+ override_agent: CLI override to use specific agent
422
+
423
+ Returns:
424
+ Canonical agent ID (normalized from aliases) for review
425
+ """
426
+ from specify_cli.orchestrator.agent_config import load_agent_config
427
+ from specify_cli.orchestrator.agents import normalize_agent_id
428
+
429
+ # CLI override takes precedence
430
+ if override_agent:
431
+ logger.info(f"Using CLI override review agent: {override_agent}")
432
+ return normalize_agent_id(override_agent)
433
+
434
+ config = load_agent_config(repo_root)
435
+
436
+ if not config.available:
437
+ logger.warning("No agents configured, using implementer for review")
438
+ return normalize_agent_id(implementation_agent)
439
+
440
+ selected = config.select_reviewer(implementer=implementation_agent)
441
+ return normalize_agent_id(selected) if selected else None
442
+
443
+
444
+ def select_review_agent(
445
+ config: OrchestratorConfig,
446
+ implementation_agent: str,
447
+ state: OrchestrationRun | None = None,
448
+ ) -> str | None:
449
+ """Select review agent, excluding the implementation agent for cross-review.
450
+
451
+ NOTE: This is the legacy selection method using OrchestratorConfig.
452
+ Prefer select_review_agent_from_user_config() which uses the configuration
453
+ set during `spec-kitty init`.
454
+
455
+ Args:
456
+ config: Orchestrator configuration
457
+ implementation_agent: Agent that did implementation
458
+ state: Current state for concurrency tracking
459
+
460
+ Returns:
461
+ Agent ID for review, or None if unavailable
462
+ """
463
+ # In single-agent mode, use the same agent
464
+ if is_single_agent_mode(config):
465
+ logger.info(
466
+ f"Single-agent mode: using {implementation_agent} for review"
467
+ )
468
+ return implementation_agent
469
+
470
+ # Try to find a different agent for cross-review
471
+ review_agent = select_agent(
472
+ config,
473
+ role="review",
474
+ exclude_agent=implementation_agent,
475
+ state=state,
476
+ )
477
+
478
+ if review_agent:
479
+ return review_agent
480
+
481
+ # If no other agent available, fall back to same agent with warning
482
+ logger.warning(
483
+ f"No cross-review agent available, using {implementation_agent}"
484
+ )
485
+ return implementation_agent
486
+
487
+
488
+ # =============================================================================
489
+ # Concurrency Management (T025)
490
+ # =============================================================================
491
+
492
+
493
+ class ConcurrencyManager:
494
+ """Manages concurrency limits for orchestration.
495
+
496
+ Uses asyncio.Semaphore to limit:
497
+ - Global concurrent processes
498
+ - Per-agent concurrent processes
499
+ """
500
+
501
+ def __init__(self, config: OrchestratorConfig):
502
+ """Initialize concurrency manager.
503
+
504
+ Args:
505
+ config: Orchestrator configuration with concurrency limits
506
+ """
507
+ self.config = config
508
+ self.global_semaphore = asyncio.Semaphore(config.global_concurrency)
509
+ self.agent_semaphores: dict[str, asyncio.Semaphore] = {}
510
+
511
+ # Create per-agent semaphores
512
+ for agent_id, agent_config in config.agents.items():
513
+ self.agent_semaphores[agent_id] = asyncio.Semaphore(
514
+ agent_config.max_concurrent
515
+ )
516
+
517
+ logger.info(
518
+ f"ConcurrencyManager initialized: global={config.global_concurrency}, "
519
+ f"agents={len(self.agent_semaphores)}"
520
+ )
521
+
522
+ def _get_agent_semaphore(self, agent_id: str) -> asyncio.Semaphore:
523
+ """Get or create semaphore for agent.
524
+
525
+ Args:
526
+ agent_id: Agent identifier
527
+
528
+ Returns:
529
+ Semaphore for the agent
530
+ """
531
+ if agent_id not in self.agent_semaphores:
532
+ # Create with default limit if not configured
533
+ default_limit = 2
534
+ self.agent_semaphores[agent_id] = asyncio.Semaphore(default_limit)
535
+ logger.warning(
536
+ f"Created default semaphore for unconfigured agent {agent_id}"
537
+ )
538
+ return self.agent_semaphores[agent_id]
539
+
540
+ async def acquire(self, agent_id: str) -> None:
541
+ """Acquire both global and agent-specific semaphores.
542
+
543
+ Always acquires global first to prevent deadlocks.
544
+
545
+ Args:
546
+ agent_id: Agent identifier
547
+ """
548
+ # Always acquire global first to prevent deadlock
549
+ await self.global_semaphore.acquire()
550
+ try:
551
+ agent_sem = self._get_agent_semaphore(agent_id)
552
+ await agent_sem.acquire()
553
+ except Exception:
554
+ # Release global if agent acquisition fails
555
+ self.global_semaphore.release()
556
+ raise
557
+
558
+ logger.debug(f"Acquired semaphores for {agent_id}")
559
+
560
+ def release(self, agent_id: str) -> None:
561
+ """Release both semaphores.
562
+
563
+ Releases in reverse order of acquisition.
564
+
565
+ Args:
566
+ agent_id: Agent identifier
567
+ """
568
+ agent_sem = self._get_agent_semaphore(agent_id)
569
+ agent_sem.release()
570
+ self.global_semaphore.release()
571
+ logger.debug(f"Released semaphores for {agent_id}")
572
+
573
+ @asynccontextmanager
574
+ async def throttle(self, agent_id: str) -> AsyncIterator[None]:
575
+ """Context manager for throttled execution.
576
+
577
+ Acquires semaphores on entry, releases on exit.
578
+
579
+ Args:
580
+ agent_id: Agent identifier
581
+
582
+ Yields:
583
+ None after acquiring semaphores
584
+ """
585
+ await self.acquire(agent_id)
586
+ try:
587
+ yield
588
+ finally:
589
+ self.release(agent_id)
590
+
591
+ def get_available_slots(self) -> int:
592
+ """Get number of available global execution slots.
593
+
594
+ Returns:
595
+ Number of slots available (0 if at limit)
596
+ """
597
+ # Semaphore doesn't expose count directly, so we track it
598
+ # This is a heuristic based on the initial value
599
+ return self.config.global_concurrency - (
600
+ self.config.global_concurrency - self.global_semaphore._value
601
+ )
602
+
603
+ def get_agent_available_slots(self, agent_id: str) -> int:
604
+ """Get number of available slots for specific agent.
605
+
606
+ Args:
607
+ agent_id: Agent identifier
608
+
609
+ Returns:
610
+ Number of slots available for this agent
611
+ """
612
+ agent_sem = self._get_agent_semaphore(agent_id)
613
+ agent_config = self.config.agents.get(agent_id)
614
+ max_concurrent = agent_config.max_concurrent if agent_config else 2
615
+ return max_concurrent - (max_concurrent - agent_sem._value)
616
+
617
+
618
+ # =============================================================================
619
+ # Single-Agent Mode (T026)
620
+ # =============================================================================
621
+
622
+
623
+ # Default delay between implementation and review in single-agent mode
624
+ DEFAULT_SINGLE_AGENT_DELAY = 60 # seconds
625
+
626
+
627
+ def is_single_agent_mode(config: OrchestratorConfig) -> bool:
628
+ """Check if operating in single-agent mode.
629
+
630
+ Single-agent mode is active when:
631
+ - Explicitly enabled via config.single_agent_mode
632
+ - Only one agent is enabled (auto-detected)
633
+
634
+ Args:
635
+ config: Orchestrator configuration
636
+
637
+ Returns:
638
+ True if single-agent mode is active
639
+ """
640
+ # Explicit configuration
641
+ if config.single_agent_mode:
642
+ return True
643
+
644
+ # Auto-detect: only one agent enabled
645
+ enabled_agents = [
646
+ aid for aid, ac in config.agents.items()
647
+ if ac.enabled
648
+ ]
649
+ return len(enabled_agents) == 1
650
+
651
+
652
+ def get_single_agent(config: OrchestratorConfig) -> str | None:
653
+ """Get the single agent ID when in single-agent mode.
654
+
655
+ Args:
656
+ config: Orchestrator configuration
657
+
658
+ Returns:
659
+ Agent ID if in single-agent mode, None otherwise
660
+ """
661
+ if not is_single_agent_mode(config):
662
+ return None
663
+
664
+ # Use explicitly configured agent
665
+ if config.single_agent:
666
+ return config.single_agent
667
+
668
+ # Auto-detect: return the only enabled agent
669
+ enabled_agents = [
670
+ aid for aid, ac in config.agents.items()
671
+ if ac.enabled
672
+ ]
673
+ return enabled_agents[0] if enabled_agents else None
674
+
675
+
676
+ async def single_agent_review_delay(
677
+ delay_seconds: int | None = None,
678
+ ) -> None:
679
+ """Apply delay before single-agent review.
680
+
681
+ The delay helps the agent "forget" its implementation context
682
+ and review with a fresher perspective.
683
+
684
+ Args:
685
+ delay_seconds: Delay in seconds (defaults to 60)
686
+ """
687
+ delay = delay_seconds or DEFAULT_SINGLE_AGENT_DELAY
688
+ logger.info(
689
+ f"Single-agent mode: waiting {delay}s before review "
690
+ "to provide fresh perspective"
691
+ )
692
+ await asyncio.sleep(delay)
693
+
694
+
695
+ # =============================================================================
696
+ # Scheduler State
697
+ # =============================================================================
698
+
699
+
700
+ class SchedulerState:
701
+ """Tracks scheduler state during orchestration.
702
+
703
+ Combines configuration, dependency graph, and concurrency management.
704
+ """
705
+
706
+ def __init__(
707
+ self,
708
+ config: OrchestratorConfig,
709
+ feature_dir: Path,
710
+ ):
711
+ """Initialize scheduler state.
712
+
713
+ Args:
714
+ config: Orchestrator configuration
715
+ feature_dir: Path to feature directory
716
+
717
+ Raises:
718
+ DependencyGraphError: If dependency graph is invalid
719
+ """
720
+ self.config = config
721
+ self.feature_dir = feature_dir
722
+
723
+ # Build and validate dependency graph
724
+ self.graph = build_wp_graph(feature_dir)
725
+ validate_wp_graph(self.graph)
726
+
727
+ # Initialize concurrency manager
728
+ self.concurrency = ConcurrencyManager(config)
729
+
730
+ # Track single-agent mode
731
+ self.single_agent_mode = is_single_agent_mode(config)
732
+ self.single_agent = get_single_agent(config)
733
+
734
+ if self.single_agent_mode:
735
+ logger.warning(
736
+ f"Single-agent mode active: {self.single_agent}. "
737
+ "Cross-agent review will not be available."
738
+ )
739
+
740
+ def get_ready_wps(self, state: OrchestrationRun) -> list[str]:
741
+ """Get WPs ready for execution.
742
+
743
+ Args:
744
+ state: Current orchestration state
745
+
746
+ Returns:
747
+ List of ready WP IDs
748
+ """
749
+ return get_ready_wps(self.graph, state)
750
+
751
+ def select_implementation_agent(
752
+ self,
753
+ state: OrchestrationRun,
754
+ ) -> str | None:
755
+ """Select agent for implementation.
756
+
757
+ Args:
758
+ state: Current orchestration state
759
+
760
+ Returns:
761
+ Agent ID or None
762
+ """
763
+ if self.single_agent_mode:
764
+ return self.single_agent
765
+ return select_agent(self.config, "implementation", state=state)
766
+
767
+ def select_review_agent(
768
+ self,
769
+ implementation_agent: str,
770
+ state: OrchestrationRun,
771
+ ) -> str | None:
772
+ """Select agent for review.
773
+
774
+ Args:
775
+ implementation_agent: Agent that did implementation
776
+ state: Current orchestration state
777
+
778
+ Returns:
779
+ Agent ID or None
780
+ """
781
+ return select_review_agent(
782
+ self.config,
783
+ implementation_agent,
784
+ state=state,
785
+ )
786
+
787
+ def initialize_wp_state(
788
+ self,
789
+ state: OrchestrationRun,
790
+ ) -> None:
791
+ """Initialize WP execution state for all WPs in graph.
792
+
793
+ Creates WPExecution entries for WPs not already in state.
794
+
795
+ Args:
796
+ state: Orchestration state to update
797
+ """
798
+ for wp_id in self.graph:
799
+ if wp_id not in state.work_packages:
800
+ state.work_packages[wp_id] = WPExecution(wp_id=wp_id)
801
+
802
+ state.wps_total = len(self.graph)
803
+
804
+
805
+ __all__ = [
806
+ # Exceptions
807
+ "SchedulerError",
808
+ "DependencyGraphError",
809
+ "NoAgentAvailableError",
810
+ # Graph functions (T022)
811
+ "build_wp_graph",
812
+ "validate_wp_graph",
813
+ "get_topological_order",
814
+ # Ready detection (T023)
815
+ "get_ready_wps",
816
+ "get_blocked_wps",
817
+ # Agent selection (T024) - user config based (preferred)
818
+ "select_agent_from_user_config",
819
+ "select_review_agent_from_user_config",
820
+ # Agent selection (T024) - legacy (for backwards compatibility)
821
+ "select_agent",
822
+ "select_review_agent",
823
+ # Concurrency (T025)
824
+ "ConcurrencyManager",
825
+ # Single-agent mode (T026)
826
+ "is_single_agent_mode",
827
+ "get_single_agent",
828
+ "single_agent_review_delay",
829
+ "DEFAULT_SINGLE_AGENT_DELAY",
830
+ # State
831
+ "SchedulerState",
832
+ ]