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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""State management for orchestration runs.
|
|
2
|
+
|
|
3
|
+
This module handles:
|
|
4
|
+
- OrchestrationRun and WPExecution dataclasses
|
|
5
|
+
- State persistence to .kittify/orchestration-state.json
|
|
6
|
+
- State loading for resume capability
|
|
7
|
+
- State updates during execution
|
|
8
|
+
- Active orchestration detection
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from specify_cli.orchestrator.config import OrchestrationStatus, WPStatus
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# State file location
|
|
28
|
+
STATE_FILENAME = "orchestration-state.json"
|
|
29
|
+
STATE_BACKUP_SUFFIX = ".bak"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# Exceptions
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StateValidationError(Exception):
|
|
38
|
+
"""Raised when state validation fails."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StateLoadError(Exception):
|
|
44
|
+
"""Raised when state cannot be loaded."""
|
|
45
|
+
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# =============================================================================
|
|
50
|
+
# WPExecution Dataclass (T018)
|
|
51
|
+
# =============================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class WPExecution:
|
|
56
|
+
"""Tracks individual work package execution state.
|
|
57
|
+
|
|
58
|
+
Captures all relevant information for a single WP's progression
|
|
59
|
+
through implementation and review phases.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
wp_id: str
|
|
63
|
+
status: WPStatus = WPStatus.PENDING
|
|
64
|
+
|
|
65
|
+
# Implementation phase
|
|
66
|
+
implementation_agent: str | None = None
|
|
67
|
+
implementation_started: datetime | None = None
|
|
68
|
+
implementation_completed: datetime | None = None
|
|
69
|
+
implementation_exit_code: int | None = None
|
|
70
|
+
implementation_retries: int = 0
|
|
71
|
+
|
|
72
|
+
# Review phase
|
|
73
|
+
review_agent: str | None = None
|
|
74
|
+
review_started: datetime | None = None
|
|
75
|
+
review_completed: datetime | None = None
|
|
76
|
+
review_exit_code: int | None = None
|
|
77
|
+
review_retries: int = 0
|
|
78
|
+
review_feedback: str | None = None # Feedback from rejected review for re-implementation
|
|
79
|
+
|
|
80
|
+
# Output tracking
|
|
81
|
+
log_file: Path | None = None
|
|
82
|
+
worktree_path: Path | None = None
|
|
83
|
+
|
|
84
|
+
# Error tracking
|
|
85
|
+
last_error: str | None = None
|
|
86
|
+
fallback_agents_tried: list[str] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
def validate(self) -> None:
|
|
89
|
+
"""Validate state transitions per data-model.md rules.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
StateValidationError: If state is invalid.
|
|
93
|
+
"""
|
|
94
|
+
# Implementation completion requires start
|
|
95
|
+
if self.implementation_completed and not self.implementation_started:
|
|
96
|
+
raise StateValidationError(
|
|
97
|
+
f"WP {self.wp_id}: implementation_completed requires implementation_started"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Review start requires implementation completion
|
|
101
|
+
if self.review_started and not self.implementation_completed:
|
|
102
|
+
raise StateValidationError(
|
|
103
|
+
f"WP {self.wp_id}: review_started requires implementation_completed"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Review completion requires review start
|
|
107
|
+
if self.review_completed and not self.review_started:
|
|
108
|
+
raise StateValidationError(
|
|
109
|
+
f"WP {self.wp_id}: review_completed requires review_started"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# COMPLETED status requires review_completed (or single-agent mode)
|
|
113
|
+
if self.status == WPStatus.COMPLETED:
|
|
114
|
+
if not self.implementation_completed:
|
|
115
|
+
raise StateValidationError(
|
|
116
|
+
f"WP {self.wp_id}: COMPLETED status requires implementation_completed"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# IMPLEMENTATION status requires implementation_started
|
|
120
|
+
if self.status == WPStatus.IMPLEMENTATION and not self.implementation_started:
|
|
121
|
+
raise StateValidationError(
|
|
122
|
+
f"WP {self.wp_id}: IMPLEMENTATION status requires implementation_started"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# REVIEW status requires review_started
|
|
126
|
+
if self.status == WPStatus.REVIEW and not self.review_started:
|
|
127
|
+
raise StateValidationError(
|
|
128
|
+
f"WP {self.wp_id}: REVIEW status requires review_started"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# REWORK status requires review_feedback (rejection reason)
|
|
132
|
+
if self.status == WPStatus.REWORK and not self.review_feedback:
|
|
133
|
+
raise StateValidationError(
|
|
134
|
+
f"WP {self.wp_id}: REWORK status requires review_feedback"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> dict[str, Any]:
|
|
138
|
+
"""Serialize to JSON-compatible dict."""
|
|
139
|
+
return {
|
|
140
|
+
"wp_id": self.wp_id,
|
|
141
|
+
"status": self.status.value,
|
|
142
|
+
"implementation_agent": self.implementation_agent,
|
|
143
|
+
"implementation_started": (
|
|
144
|
+
self.implementation_started.isoformat()
|
|
145
|
+
if self.implementation_started
|
|
146
|
+
else None
|
|
147
|
+
),
|
|
148
|
+
"implementation_completed": (
|
|
149
|
+
self.implementation_completed.isoformat()
|
|
150
|
+
if self.implementation_completed
|
|
151
|
+
else None
|
|
152
|
+
),
|
|
153
|
+
"implementation_exit_code": self.implementation_exit_code,
|
|
154
|
+
"implementation_retries": self.implementation_retries,
|
|
155
|
+
"review_agent": self.review_agent,
|
|
156
|
+
"review_started": (
|
|
157
|
+
self.review_started.isoformat() if self.review_started else None
|
|
158
|
+
),
|
|
159
|
+
"review_completed": (
|
|
160
|
+
self.review_completed.isoformat() if self.review_completed else None
|
|
161
|
+
),
|
|
162
|
+
"review_exit_code": self.review_exit_code,
|
|
163
|
+
"review_retries": self.review_retries,
|
|
164
|
+
"review_feedback": self.review_feedback,
|
|
165
|
+
"log_file": str(self.log_file) if self.log_file else None,
|
|
166
|
+
"worktree_path": str(self.worktree_path) if self.worktree_path else None,
|
|
167
|
+
"last_error": self.last_error,
|
|
168
|
+
"fallback_agents_tried": self.fallback_agents_tried,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(cls, data: dict[str, Any]) -> "WPExecution":
|
|
173
|
+
"""Deserialize from dict."""
|
|
174
|
+
return cls(
|
|
175
|
+
wp_id=data["wp_id"],
|
|
176
|
+
status=WPStatus(data.get("status", "pending")),
|
|
177
|
+
implementation_agent=data.get("implementation_agent"),
|
|
178
|
+
implementation_started=(
|
|
179
|
+
datetime.fromisoformat(data["implementation_started"])
|
|
180
|
+
if data.get("implementation_started")
|
|
181
|
+
else None
|
|
182
|
+
),
|
|
183
|
+
implementation_completed=(
|
|
184
|
+
datetime.fromisoformat(data["implementation_completed"])
|
|
185
|
+
if data.get("implementation_completed")
|
|
186
|
+
else None
|
|
187
|
+
),
|
|
188
|
+
implementation_exit_code=data.get("implementation_exit_code"),
|
|
189
|
+
implementation_retries=data.get("implementation_retries", 0),
|
|
190
|
+
review_agent=data.get("review_agent"),
|
|
191
|
+
review_started=(
|
|
192
|
+
datetime.fromisoformat(data["review_started"])
|
|
193
|
+
if data.get("review_started")
|
|
194
|
+
else None
|
|
195
|
+
),
|
|
196
|
+
review_completed=(
|
|
197
|
+
datetime.fromisoformat(data["review_completed"])
|
|
198
|
+
if data.get("review_completed")
|
|
199
|
+
else None
|
|
200
|
+
),
|
|
201
|
+
review_exit_code=data.get("review_exit_code"),
|
|
202
|
+
review_retries=data.get("review_retries", 0),
|
|
203
|
+
review_feedback=data.get("review_feedback"),
|
|
204
|
+
log_file=Path(data["log_file"]) if data.get("log_file") else None,
|
|
205
|
+
worktree_path=(
|
|
206
|
+
Path(data["worktree_path"]) if data.get("worktree_path") else None
|
|
207
|
+
),
|
|
208
|
+
last_error=data.get("last_error"),
|
|
209
|
+
fallback_agents_tried=data.get("fallback_agents_tried", []),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# =============================================================================
|
|
214
|
+
# OrchestrationRun Dataclass (T017)
|
|
215
|
+
# =============================================================================
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class OrchestrationRun:
|
|
220
|
+
"""Tracks complete orchestration execution state.
|
|
221
|
+
|
|
222
|
+
This is the top-level state object persisted to disk, containing
|
|
223
|
+
all information needed to resume an interrupted orchestration.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
run_id: str
|
|
227
|
+
feature_slug: str
|
|
228
|
+
started_at: datetime
|
|
229
|
+
status: OrchestrationStatus = OrchestrationStatus.PENDING
|
|
230
|
+
completed_at: datetime | None = None
|
|
231
|
+
|
|
232
|
+
# Configuration snapshot
|
|
233
|
+
config_hash: str = ""
|
|
234
|
+
concurrency_limit: int = 5
|
|
235
|
+
|
|
236
|
+
# Progress tracking
|
|
237
|
+
wps_total: int = 0
|
|
238
|
+
wps_completed: int = 0
|
|
239
|
+
wps_failed: int = 0
|
|
240
|
+
|
|
241
|
+
# Metrics
|
|
242
|
+
parallel_peak: int = 0
|
|
243
|
+
total_agent_invocations: int = 0
|
|
244
|
+
|
|
245
|
+
# Work package states
|
|
246
|
+
work_packages: dict[str, WPExecution] = field(default_factory=dict)
|
|
247
|
+
|
|
248
|
+
def validate(self) -> None:
|
|
249
|
+
"""Validate overall orchestration state.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
StateValidationError: If state is invalid.
|
|
253
|
+
"""
|
|
254
|
+
# Validate each WP
|
|
255
|
+
for wp in self.work_packages.values():
|
|
256
|
+
wp.validate()
|
|
257
|
+
|
|
258
|
+
# Completed count should match
|
|
259
|
+
completed_count = sum(
|
|
260
|
+
1
|
|
261
|
+
for wp in self.work_packages.values()
|
|
262
|
+
if wp.status == WPStatus.COMPLETED
|
|
263
|
+
)
|
|
264
|
+
if completed_count != self.wps_completed:
|
|
265
|
+
logger.warning(
|
|
266
|
+
f"wps_completed mismatch: stored={self.wps_completed}, "
|
|
267
|
+
f"actual={completed_count}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Failed count should match
|
|
271
|
+
failed_count = sum(
|
|
272
|
+
1
|
|
273
|
+
for wp in self.work_packages.values()
|
|
274
|
+
if wp.status == WPStatus.FAILED
|
|
275
|
+
)
|
|
276
|
+
if failed_count != self.wps_failed:
|
|
277
|
+
logger.warning(
|
|
278
|
+
f"wps_failed mismatch: stored={self.wps_failed}, "
|
|
279
|
+
f"actual={failed_count}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def to_dict(self) -> dict[str, Any]:
|
|
283
|
+
"""Serialize to JSON-compatible dict."""
|
|
284
|
+
return {
|
|
285
|
+
"run_id": self.run_id,
|
|
286
|
+
"feature_slug": self.feature_slug,
|
|
287
|
+
"started_at": self.started_at.isoformat(),
|
|
288
|
+
"completed_at": (
|
|
289
|
+
self.completed_at.isoformat() if self.completed_at else None
|
|
290
|
+
),
|
|
291
|
+
"status": self.status.value,
|
|
292
|
+
"config_hash": self.config_hash,
|
|
293
|
+
"concurrency_limit": self.concurrency_limit,
|
|
294
|
+
"wps_total": self.wps_total,
|
|
295
|
+
"wps_completed": self.wps_completed,
|
|
296
|
+
"wps_failed": self.wps_failed,
|
|
297
|
+
"parallel_peak": self.parallel_peak,
|
|
298
|
+
"total_agent_invocations": self.total_agent_invocations,
|
|
299
|
+
"work_packages": {
|
|
300
|
+
wp_id: wp.to_dict() for wp_id, wp in self.work_packages.items()
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def from_dict(cls, data: dict[str, Any]) -> "OrchestrationRun":
|
|
306
|
+
"""Deserialize from dict."""
|
|
307
|
+
work_packages = {
|
|
308
|
+
wp_id: WPExecution.from_dict(wp_data)
|
|
309
|
+
for wp_id, wp_data in data.get("work_packages", {}).items()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return cls(
|
|
313
|
+
run_id=data["run_id"],
|
|
314
|
+
feature_slug=data["feature_slug"],
|
|
315
|
+
started_at=datetime.fromisoformat(data["started_at"]),
|
|
316
|
+
completed_at=(
|
|
317
|
+
datetime.fromisoformat(data["completed_at"])
|
|
318
|
+
if data.get("completed_at")
|
|
319
|
+
else None
|
|
320
|
+
),
|
|
321
|
+
status=OrchestrationStatus(data.get("status", "pending")),
|
|
322
|
+
config_hash=data.get("config_hash", ""),
|
|
323
|
+
concurrency_limit=data.get("concurrency_limit", 5),
|
|
324
|
+
wps_total=data.get("wps_total", 0),
|
|
325
|
+
wps_completed=data.get("wps_completed", 0),
|
|
326
|
+
wps_failed=data.get("wps_failed", 0),
|
|
327
|
+
parallel_peak=data.get("parallel_peak", 0),
|
|
328
|
+
total_agent_invocations=data.get("total_agent_invocations", 0),
|
|
329
|
+
work_packages=work_packages,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# =============================================================================
|
|
334
|
+
# JSON Serialization Helpers (T021)
|
|
335
|
+
# =============================================================================
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _json_serializer(obj: Any) -> Any:
|
|
339
|
+
"""JSON serializer for datetime and Path objects."""
|
|
340
|
+
if isinstance(obj, datetime):
|
|
341
|
+
return obj.isoformat()
|
|
342
|
+
if isinstance(obj, Path):
|
|
343
|
+
return str(obj)
|
|
344
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _atomic_write(path: Path, data: dict[str, Any]) -> None:
|
|
348
|
+
"""Write JSON atomically via temp file rename.
|
|
349
|
+
|
|
350
|
+
Creates a backup of existing state before writing.
|
|
351
|
+
Uses atomic rename to ensure either old or new state exists,
|
|
352
|
+
never a partial write.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
path: Target file path.
|
|
356
|
+
data: Data to serialize as JSON.
|
|
357
|
+
"""
|
|
358
|
+
# Create backup of existing state
|
|
359
|
+
if path.exists():
|
|
360
|
+
backup_path = path.with_suffix(path.suffix + STATE_BACKUP_SUFFIX)
|
|
361
|
+
try:
|
|
362
|
+
shutil.copy2(path, backup_path)
|
|
363
|
+
except OSError as e:
|
|
364
|
+
logger.warning(f"Failed to create backup: {e}")
|
|
365
|
+
|
|
366
|
+
# Ensure parent directory exists
|
|
367
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
|
|
369
|
+
# Write to temp file in same directory (ensures same filesystem for atomic rename)
|
|
370
|
+
fd, temp_path = tempfile.mkstemp(
|
|
371
|
+
dir=path.parent,
|
|
372
|
+
prefix=".orchestration-state-",
|
|
373
|
+
suffix=".tmp",
|
|
374
|
+
)
|
|
375
|
+
try:
|
|
376
|
+
with os.fdopen(fd, "w") as f:
|
|
377
|
+
json.dump(data, f, indent=2, default=_json_serializer)
|
|
378
|
+
# Atomic rename
|
|
379
|
+
os.rename(temp_path, path)
|
|
380
|
+
logger.debug(f"State saved to {path}")
|
|
381
|
+
except Exception:
|
|
382
|
+
# Clean up temp file on failure
|
|
383
|
+
if os.path.exists(temp_path):
|
|
384
|
+
os.unlink(temp_path)
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# =============================================================================
|
|
389
|
+
# State Persistence Functions (T020)
|
|
390
|
+
# =============================================================================
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def get_state_path(repo_root: Path) -> Path:
|
|
394
|
+
"""Get the path to the state file.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
repo_root: Repository root directory.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Path to orchestration-state.json.
|
|
401
|
+
"""
|
|
402
|
+
return repo_root / ".kittify" / STATE_FILENAME
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def save_state(state: OrchestrationRun, repo_root: Path) -> None:
|
|
406
|
+
"""Save orchestration state to JSON file.
|
|
407
|
+
|
|
408
|
+
Uses atomic writes to prevent corruption on crash.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
state: Orchestration state to save.
|
|
412
|
+
repo_root: Repository root directory.
|
|
413
|
+
"""
|
|
414
|
+
state_file = get_state_path(repo_root)
|
|
415
|
+
data = state.to_dict()
|
|
416
|
+
_atomic_write(state_file, data)
|
|
417
|
+
logger.info(f"Saved orchestration state for {state.feature_slug}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def load_state(repo_root: Path) -> OrchestrationRun | None:
|
|
421
|
+
"""Load orchestration state from JSON file.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
repo_root: Repository root directory.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Loaded OrchestrationRun or None if no state file exists.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
StateLoadError: If state file exists but cannot be parsed.
|
|
431
|
+
"""
|
|
432
|
+
state_file = get_state_path(repo_root)
|
|
433
|
+
if not state_file.exists():
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
with open(state_file) as f:
|
|
438
|
+
data = json.load(f)
|
|
439
|
+
state = OrchestrationRun.from_dict(data)
|
|
440
|
+
logger.info(f"Loaded orchestration state for {state.feature_slug}")
|
|
441
|
+
return state
|
|
442
|
+
except json.JSONDecodeError as e:
|
|
443
|
+
raise StateLoadError(f"Failed to parse state file: {e}")
|
|
444
|
+
except KeyError as e:
|
|
445
|
+
raise StateLoadError(f"Missing required field in state file: {e}")
|
|
446
|
+
except Exception as e:
|
|
447
|
+
raise StateLoadError(f"Failed to load state: {e}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def has_active_orchestration(repo_root: Path) -> bool:
|
|
451
|
+
"""Check if there's an active (running/paused) orchestration.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
repo_root: Repository root directory.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
True if an orchestration is running or paused.
|
|
458
|
+
"""
|
|
459
|
+
state = load_state(repo_root)
|
|
460
|
+
if state is None:
|
|
461
|
+
return False
|
|
462
|
+
return state.status in [OrchestrationStatus.RUNNING, OrchestrationStatus.PAUSED]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def clear_state(repo_root: Path) -> None:
|
|
466
|
+
"""Remove state file and its backup.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
repo_root: Repository root directory.
|
|
470
|
+
"""
|
|
471
|
+
state_file = get_state_path(repo_root)
|
|
472
|
+
backup_file = state_file.with_suffix(state_file.suffix + STATE_BACKUP_SUFFIX)
|
|
473
|
+
|
|
474
|
+
if state_file.exists():
|
|
475
|
+
state_file.unlink()
|
|
476
|
+
logger.info(f"Removed state file: {state_file}")
|
|
477
|
+
|
|
478
|
+
if backup_file.exists():
|
|
479
|
+
backup_file.unlink()
|
|
480
|
+
logger.debug(f"Removed backup file: {backup_file}")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def restore_from_backup(repo_root: Path) -> OrchestrationRun | None:
|
|
484
|
+
"""Attempt to restore state from backup file.
|
|
485
|
+
|
|
486
|
+
Useful if the main state file was corrupted.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
repo_root: Repository root directory.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Restored OrchestrationRun or None if backup doesn't exist.
|
|
493
|
+
"""
|
|
494
|
+
state_file = get_state_path(repo_root)
|
|
495
|
+
backup_file = state_file.with_suffix(state_file.suffix + STATE_BACKUP_SUFFIX)
|
|
496
|
+
|
|
497
|
+
if not backup_file.exists():
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
with open(backup_file) as f:
|
|
502
|
+
data = json.load(f)
|
|
503
|
+
state = OrchestrationRun.from_dict(data)
|
|
504
|
+
logger.info(f"Restored orchestration state from backup for {state.feature_slug}")
|
|
505
|
+
return state
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.error(f"Failed to restore from backup: {e}")
|
|
508
|
+
return None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Testing utilities for the orchestrator.
|
|
2
|
+
|
|
3
|
+
This subpackage provides infrastructure for end-to-end testing of the
|
|
4
|
+
multi-agent orchestrator. It includes:
|
|
5
|
+
|
|
6
|
+
- Agent availability detection (which agents are installed and authenticated)
|
|
7
|
+
- Test path selection (1-agent, 2-agent, or 3+-agent test paths)
|
|
8
|
+
- Fixture management (checkpoint snapshots for deterministic testing)
|
|
9
|
+
|
|
10
|
+
Example usage:
|
|
11
|
+
from specify_cli.orchestrator.testing import (
|
|
12
|
+
AgentAvailability,
|
|
13
|
+
detect_all_agents,
|
|
14
|
+
CORE_AGENTS,
|
|
15
|
+
EXTENDED_AGENTS,
|
|
16
|
+
TestPath,
|
|
17
|
+
select_test_path,
|
|
18
|
+
FixtureCheckpoint,
|
|
19
|
+
TestContext,
|
|
20
|
+
load_checkpoint,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Detect available agents
|
|
24
|
+
agents = await detect_all_agents()
|
|
25
|
+
available = [a for a in agents.values() if a.is_available]
|
|
26
|
+
|
|
27
|
+
# Select test path based on available agents
|
|
28
|
+
test_path = await select_test_path()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
# Availability detection (WP01)
|
|
34
|
+
from specify_cli.orchestrator.testing.availability import (
|
|
35
|
+
CORE_AGENTS,
|
|
36
|
+
EXTENDED_AGENTS,
|
|
37
|
+
ALL_AGENTS,
|
|
38
|
+
AgentAvailability,
|
|
39
|
+
detect_all_agents,
|
|
40
|
+
detect_agent,
|
|
41
|
+
get_available_agents,
|
|
42
|
+
clear_agent_cache,
|
|
43
|
+
check_installed,
|
|
44
|
+
probe_agent_auth,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Test path selection (WP02)
|
|
48
|
+
from specify_cli.orchestrator.testing.paths import (
|
|
49
|
+
TestPath,
|
|
50
|
+
assign_agents,
|
|
51
|
+
clear_test_path_cache,
|
|
52
|
+
determine_path_type,
|
|
53
|
+
select_test_path,
|
|
54
|
+
select_test_path_sync,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Fixture management (WP03 + WP04)
|
|
58
|
+
from specify_cli.orchestrator.testing.fixtures import (
|
|
59
|
+
FixtureCheckpoint,
|
|
60
|
+
GitError,
|
|
61
|
+
StateFileError,
|
|
62
|
+
TestContext,
|
|
63
|
+
WorktreeMetadata,
|
|
64
|
+
WorktreesFileError,
|
|
65
|
+
cleanup_temp_dir,
|
|
66
|
+
cleanup_test_context,
|
|
67
|
+
copy_fixture_to_temp,
|
|
68
|
+
create_worktrees_from_metadata,
|
|
69
|
+
init_git_repo,
|
|
70
|
+
load_checkpoint,
|
|
71
|
+
load_orchestration_state,
|
|
72
|
+
load_state_file,
|
|
73
|
+
load_worktrees_file,
|
|
74
|
+
register_for_cleanup,
|
|
75
|
+
save_state_file,
|
|
76
|
+
save_worktrees_file,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
# Tier constants
|
|
81
|
+
"CORE_AGENTS",
|
|
82
|
+
"EXTENDED_AGENTS",
|
|
83
|
+
"ALL_AGENTS",
|
|
84
|
+
# Availability detection (WP01)
|
|
85
|
+
"AgentAvailability",
|
|
86
|
+
"detect_all_agents",
|
|
87
|
+
"detect_agent",
|
|
88
|
+
"get_available_agents",
|
|
89
|
+
"clear_agent_cache",
|
|
90
|
+
"check_installed",
|
|
91
|
+
"probe_agent_auth",
|
|
92
|
+
# Test path selection (WP02)
|
|
93
|
+
"TestPath",
|
|
94
|
+
"assign_agents",
|
|
95
|
+
"clear_test_path_cache",
|
|
96
|
+
"determine_path_type",
|
|
97
|
+
"select_test_path",
|
|
98
|
+
"select_test_path_sync",
|
|
99
|
+
# Data structures (WP03)
|
|
100
|
+
"FixtureCheckpoint",
|
|
101
|
+
"WorktreeMetadata",
|
|
102
|
+
"TestContext",
|
|
103
|
+
# Exceptions
|
|
104
|
+
"WorktreesFileError",
|
|
105
|
+
"StateFileError",
|
|
106
|
+
"GitError",
|
|
107
|
+
# File I/O
|
|
108
|
+
"load_worktrees_file",
|
|
109
|
+
"save_worktrees_file",
|
|
110
|
+
"load_state_file",
|
|
111
|
+
"save_state_file",
|
|
112
|
+
# Loader functions (WP04)
|
|
113
|
+
"copy_fixture_to_temp",
|
|
114
|
+
"init_git_repo",
|
|
115
|
+
"create_worktrees_from_metadata",
|
|
116
|
+
"load_orchestration_state",
|
|
117
|
+
"load_checkpoint",
|
|
118
|
+
# Cleanup functions
|
|
119
|
+
"cleanup_temp_dir",
|
|
120
|
+
"cleanup_test_context",
|
|
121
|
+
"register_for_cleanup",
|
|
122
|
+
]
|