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
specify_cli/mission.py
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Mission system for Spec Kitty.
|
|
2
|
+
|
|
3
|
+
This module provides the infrastructure for loading and managing missions,
|
|
4
|
+
which allow Spec Kitty to support multiple domains (software dev, research,
|
|
5
|
+
writing, etc.) with domain-specific templates, workflows, and validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import warnings
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissionError(Exception):
|
|
19
|
+
"""Base exception for mission-related errors."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissionNotFoundError(MissionError):
|
|
24
|
+
"""Raised when a mission cannot be found."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MISSION_ROOT_FIELDS: tuple[str, ...] = (
|
|
29
|
+
"name",
|
|
30
|
+
"description",
|
|
31
|
+
"version",
|
|
32
|
+
"domain",
|
|
33
|
+
"workflow",
|
|
34
|
+
"artifacts",
|
|
35
|
+
"paths",
|
|
36
|
+
"validation",
|
|
37
|
+
"mcp_tools",
|
|
38
|
+
"agent_context",
|
|
39
|
+
"task_metadata",
|
|
40
|
+
"commands",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PhaseConfig(BaseModel):
|
|
45
|
+
"""Workflow phase definition."""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="forbid")
|
|
48
|
+
|
|
49
|
+
name: str = Field(..., description="Phase identifier")
|
|
50
|
+
description: str = Field(..., description="Phase description")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ArtifactsConfig(BaseModel):
|
|
54
|
+
"""Required and optional artifacts."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(extra="forbid")
|
|
57
|
+
|
|
58
|
+
required: List[str] = Field(default_factory=list, description="Artifacts required for acceptance")
|
|
59
|
+
optional: List[str] = Field(default_factory=list, description="Optional artifacts and directories")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ValidationConfig(BaseModel):
|
|
63
|
+
"""Validation rules for the mission."""
|
|
64
|
+
|
|
65
|
+
model_config = ConfigDict(extra="forbid")
|
|
66
|
+
|
|
67
|
+
checks: List[str] = Field(default_factory=list, description="Validation checks executed for this mission")
|
|
68
|
+
custom_validators: bool = Field(default=False, description="Whether validators.py should be invoked")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WorkflowConfig(BaseModel):
|
|
72
|
+
"""Mission workflow configuration."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
phases: List[PhaseConfig] = Field(..., min_length=1, description="Ordered workflow phases")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MCPToolsConfig(BaseModel):
|
|
80
|
+
"""Mission MCP tool recommendations."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(extra="forbid")
|
|
83
|
+
|
|
84
|
+
required: List[str] = Field(default_factory=list)
|
|
85
|
+
recommended: List[str] = Field(default_factory=list)
|
|
86
|
+
optional: List[str] = Field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CommandConfig(BaseModel):
|
|
90
|
+
"""Command customization for a mission."""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="forbid")
|
|
93
|
+
|
|
94
|
+
prompt: str = Field(..., description="Command-specific prompt/description")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TaskMetadataConfig(BaseModel):
|
|
98
|
+
"""Task metadata definitions."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid")
|
|
101
|
+
|
|
102
|
+
required: List[str] = Field(default_factory=list)
|
|
103
|
+
optional: List[str] = Field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MissionConfig(BaseModel):
|
|
107
|
+
"""Complete mission configuration schema."""
|
|
108
|
+
|
|
109
|
+
model_config = ConfigDict(extra="forbid")
|
|
110
|
+
|
|
111
|
+
name: str = Field(..., description="Mission display name")
|
|
112
|
+
description: str = Field(..., description="Mission description")
|
|
113
|
+
version: str = Field(..., pattern=r"^\d+\.\d+\.\d+$", description="Semver version (major.minor.patch)")
|
|
114
|
+
domain: Literal["software", "research", "writing", "seo", "other"] = Field(
|
|
115
|
+
..., description="Mission domain classification"
|
|
116
|
+
)
|
|
117
|
+
workflow: WorkflowConfig = Field(..., description="Workflow definition")
|
|
118
|
+
artifacts: ArtifactsConfig = Field(..., description="Artifacts required/optional")
|
|
119
|
+
paths: Dict[str, str] = Field(
|
|
120
|
+
default_factory=dict,
|
|
121
|
+
description="Path conventions (workspace/tests/deliverables/documentation/data/etc.)",
|
|
122
|
+
)
|
|
123
|
+
validation: ValidationConfig = Field(default_factory=ValidationConfig, description="Validation settings")
|
|
124
|
+
mcp_tools: Optional[MCPToolsConfig] = Field(default=None, description="MCP tool recommendations")
|
|
125
|
+
agent_context: Optional[str] = Field(default=None, description="Agent instructions/personality")
|
|
126
|
+
task_metadata: Optional[TaskMetadataConfig] = Field(default=None, description="Task metadata definitions")
|
|
127
|
+
commands: Optional[Dict[str, CommandConfig]] = Field(default=None, description="Command-specific prompts")
|
|
128
|
+
|
|
129
|
+
def model_post_init(self, __context: Any) -> None: # pragma: no cover - simple warning logic
|
|
130
|
+
"""Warn on unknown path convention keys while permitting customization."""
|
|
131
|
+
valid_path_keys = {"workspace", "tests", "deliverables", "documentation", "data"}
|
|
132
|
+
unknown_paths = set(self.paths.keys()) - valid_path_keys
|
|
133
|
+
if unknown_paths:
|
|
134
|
+
warnings.warn(
|
|
135
|
+
f"Unknown path conventions: {sorted(unknown_paths)}. "
|
|
136
|
+
f"Known conventions: {sorted(valid_path_keys)}",
|
|
137
|
+
stacklevel=2,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _format_validation_error(config_path: Path, error: ValidationError) -> str:
|
|
142
|
+
"""Return a human-friendly validation error message."""
|
|
143
|
+
header = [
|
|
144
|
+
f"Invalid mission configuration in {config_path}:",
|
|
145
|
+
"",
|
|
146
|
+
"Detected issues:",
|
|
147
|
+
]
|
|
148
|
+
for err in error.errors():
|
|
149
|
+
path = " -> ".join(str(part) for part in err.get("loc", ())) or "<root>"
|
|
150
|
+
message = err.get("msg", "Invalid value")
|
|
151
|
+
detail = f"- {path}: {message}"
|
|
152
|
+
if err.get("type") == "extra_forbidden" and len(err.get("loc", ())) == 1:
|
|
153
|
+
valid_fields = ", ".join(MISSION_ROOT_FIELDS)
|
|
154
|
+
detail += f" (check for typos; valid root fields: {valid_fields})"
|
|
155
|
+
header.append(detail)
|
|
156
|
+
header.append("")
|
|
157
|
+
header.append("Refer to kitty-specs/005-refactor-mission-system/data-model.md for the schema definition.")
|
|
158
|
+
return "\n".join(header)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Mission:
|
|
162
|
+
"""Represents a Spec Kitty mission with its configuration and resources."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, mission_path: Path):
|
|
165
|
+
"""Initialize a mission from a directory path.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
mission_path: Path to the mission directory containing mission.yaml
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
MissionNotFoundError: If mission directory or config doesn't exist
|
|
172
|
+
"""
|
|
173
|
+
self.path = mission_path.resolve()
|
|
174
|
+
|
|
175
|
+
if not self.path.exists():
|
|
176
|
+
raise MissionNotFoundError(f"Mission directory not found: {self.path}")
|
|
177
|
+
|
|
178
|
+
self.config: MissionConfig = self._load_and_validate_config()
|
|
179
|
+
|
|
180
|
+
def _load_and_validate_config(self) -> MissionConfig:
|
|
181
|
+
"""Load and validate mission configuration from mission.yaml.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
MissionConfig instance containing validated configuration
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
MissionNotFoundError: If mission.yaml doesn't exist
|
|
188
|
+
MissionError: If YAML is malformed or validation fails
|
|
189
|
+
yaml.YAMLError: If mission.yaml is malformed
|
|
190
|
+
"""
|
|
191
|
+
config_file = self.path / "mission.yaml"
|
|
192
|
+
|
|
193
|
+
if not config_file.exists():
|
|
194
|
+
raise MissionNotFoundError(
|
|
195
|
+
f"Mission config not found: {config_file}\n"
|
|
196
|
+
f"Expected mission.yaml in mission directory"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
with open(config_file, 'r') as f:
|
|
200
|
+
try:
|
|
201
|
+
raw_config = yaml.safe_load(f) or {}
|
|
202
|
+
except yaml.YAMLError as e:
|
|
203
|
+
raise MissionError(f"Invalid mission.yaml: {e}")
|
|
204
|
+
|
|
205
|
+
if not isinstance(raw_config, dict):
|
|
206
|
+
raise MissionError(
|
|
207
|
+
f"Mission config must be a mapping/dictionary in {config_file}, "
|
|
208
|
+
f"got {type(raw_config).__name__} instead."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
return MissionConfig.model_validate(raw_config)
|
|
213
|
+
except ValidationError as error:
|
|
214
|
+
raise MissionError(_format_validation_error(config_file, error)) from error
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def name(self) -> str:
|
|
218
|
+
"""Get the mission name (e.g., 'Software Dev Kitty')."""
|
|
219
|
+
return self.config.name
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def description(self) -> str:
|
|
223
|
+
"""Get the mission description."""
|
|
224
|
+
return self.config.description
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def version(self) -> str:
|
|
228
|
+
"""Get the mission version."""
|
|
229
|
+
return self.config.version
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def domain(self) -> str:
|
|
233
|
+
"""Get the mission domain (e.g., 'software', 'research')."""
|
|
234
|
+
return self.config.domain
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def templates_dir(self) -> Path:
|
|
238
|
+
"""Get the templates directory for this mission."""
|
|
239
|
+
return self.path / "templates"
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def command_templates_dir(self) -> Path:
|
|
243
|
+
"""Get the command templates directory for this mission."""
|
|
244
|
+
return self.path / "command-templates"
|
|
245
|
+
|
|
246
|
+
def get_template(self, template_name: str) -> Path:
|
|
247
|
+
"""Get path to a template file.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
template_name: Name of template (e.g., 'spec-template.md')
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Path to the template file
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
FileNotFoundError: If template doesn't exist
|
|
257
|
+
"""
|
|
258
|
+
template_path = self.templates_dir / template_name
|
|
259
|
+
|
|
260
|
+
if not template_path.exists():
|
|
261
|
+
raise FileNotFoundError(
|
|
262
|
+
f"Template not found: {template_path}\n"
|
|
263
|
+
f"Mission: {self.name}\n"
|
|
264
|
+
f"Available templates: {self.list_templates()}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return template_path
|
|
268
|
+
|
|
269
|
+
def get_command_template(self, command_name: str) -> Path:
|
|
270
|
+
"""Get path to a command template file.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
command_name: Name of command (e.g., 'plan', 'implement')
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Path to the command template file
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
FileNotFoundError: If command template doesn't exist
|
|
280
|
+
"""
|
|
281
|
+
# Support both with and without .md extension
|
|
282
|
+
if not command_name.endswith('.md'):
|
|
283
|
+
command_name = f"{command_name}.md"
|
|
284
|
+
|
|
285
|
+
command_path = self.command_templates_dir / command_name
|
|
286
|
+
|
|
287
|
+
if not command_path.exists():
|
|
288
|
+
raise FileNotFoundError(
|
|
289
|
+
f"Command template not found: {command_path}\n"
|
|
290
|
+
f"Mission: {self.name}\n"
|
|
291
|
+
f"Available commands: {self.list_commands()}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return command_path
|
|
295
|
+
|
|
296
|
+
def list_templates(self) -> List[str]:
|
|
297
|
+
"""List all available templates in this mission."""
|
|
298
|
+
if not self.templates_dir.exists():
|
|
299
|
+
return []
|
|
300
|
+
return [f.name for f in self.templates_dir.glob("*.md")]
|
|
301
|
+
|
|
302
|
+
def list_commands(self) -> List[str]:
|
|
303
|
+
"""List all available command templates in this mission."""
|
|
304
|
+
if not self.command_templates_dir.exists():
|
|
305
|
+
return []
|
|
306
|
+
return [f.stem for f in self.command_templates_dir.glob("*.md")]
|
|
307
|
+
|
|
308
|
+
def get_validation_checks(self) -> List[str]:
|
|
309
|
+
"""Get list of validation checks for this mission."""
|
|
310
|
+
return list(self.config.validation.checks)
|
|
311
|
+
|
|
312
|
+
def has_custom_validators(self) -> bool:
|
|
313
|
+
"""Check if mission has custom validators.py."""
|
|
314
|
+
return self.config.validation.custom_validators
|
|
315
|
+
|
|
316
|
+
def get_workflow_phases(self) -> List[Dict[str, str]]:
|
|
317
|
+
"""Get workflow phases for this mission.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
List of dicts with 'name' and 'description' keys
|
|
321
|
+
"""
|
|
322
|
+
return [phase.model_dump() for phase in self.config.workflow.phases]
|
|
323
|
+
|
|
324
|
+
def get_required_artifacts(self) -> List[str]:
|
|
325
|
+
"""Get list of required artifacts for this mission."""
|
|
326
|
+
return list(self.config.artifacts.required)
|
|
327
|
+
|
|
328
|
+
def get_optional_artifacts(self) -> List[str]:
|
|
329
|
+
"""Get list of optional artifacts for this mission."""
|
|
330
|
+
return list(self.config.artifacts.optional)
|
|
331
|
+
|
|
332
|
+
def get_path_conventions(self) -> Dict[str, str]:
|
|
333
|
+
"""Get path conventions for this mission (e.g., workspace, tests)."""
|
|
334
|
+
return dict(self.config.paths)
|
|
335
|
+
|
|
336
|
+
def get_mcp_tools(self) -> Dict[str, List[str]]:
|
|
337
|
+
"""Get MCP tools configuration for this mission.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dict with 'required', 'recommended', 'optional' lists
|
|
341
|
+
"""
|
|
342
|
+
mcp_tools = self.config.mcp_tools
|
|
343
|
+
if mcp_tools is None:
|
|
344
|
+
return {"required": [], "recommended": [], "optional": []}
|
|
345
|
+
return {
|
|
346
|
+
"required": list(mcp_tools.required),
|
|
347
|
+
"recommended": list(mcp_tools.recommended),
|
|
348
|
+
"optional": list(mcp_tools.optional),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
def get_agent_context(self) -> str:
|
|
352
|
+
"""Get agent personality/instructions for this mission."""
|
|
353
|
+
return self.config.agent_context or ""
|
|
354
|
+
|
|
355
|
+
def get_command_config(self, command_name: str) -> Dict[str, str]:
|
|
356
|
+
"""Get configuration for a specific command.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
command_name: Name of command (e.g., 'plan', 'implement')
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Dict with command configuration (e.g., 'prompt')
|
|
363
|
+
"""
|
|
364
|
+
if not self.config.commands:
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
command = self.config.commands.get(command_name)
|
|
368
|
+
return command.model_dump() if command else {}
|
|
369
|
+
|
|
370
|
+
def __repr__(self) -> str:
|
|
371
|
+
return f"Mission(name='{self.name}', domain='{self.domain}', version='{self.version}')"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_active_mission(project_root: Optional[Path] = None) -> Mission:
|
|
375
|
+
"""Get the currently active mission for a project.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
project_root: Path to project root (defaults to current directory)
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Mission object for the active mission
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
MissionNotFoundError: If no active mission is configured
|
|
385
|
+
"""
|
|
386
|
+
if project_root is None:
|
|
387
|
+
project_root = Path.cwd()
|
|
388
|
+
|
|
389
|
+
kittify_dir = project_root / ".kittify"
|
|
390
|
+
|
|
391
|
+
if not kittify_dir.exists():
|
|
392
|
+
raise MissionNotFoundError(
|
|
393
|
+
f"No .kittify directory found in {project_root}\n"
|
|
394
|
+
f"Is this a Spec Kitty project? Run 'spec-kitty init' to create one."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Check for active-mission symlink
|
|
398
|
+
active_mission_link = kittify_dir / "active-mission"
|
|
399
|
+
|
|
400
|
+
if active_mission_link.exists():
|
|
401
|
+
mission_path: Optional[Path] = None
|
|
402
|
+
if active_mission_link.is_symlink():
|
|
403
|
+
# Resolve symlink to actual mission directory (supports relative targets)
|
|
404
|
+
mission_path = active_mission_link.resolve()
|
|
405
|
+
elif active_mission_link.is_file():
|
|
406
|
+
try:
|
|
407
|
+
mission_name = active_mission_link.read_text(encoding="utf-8-sig").strip()
|
|
408
|
+
except OSError:
|
|
409
|
+
mission_name = ""
|
|
410
|
+
if mission_name:
|
|
411
|
+
mission_path = kittify_dir / "missions" / mission_name
|
|
412
|
+
if mission_path is None:
|
|
413
|
+
# Fallback to interpreting the target path directly
|
|
414
|
+
try:
|
|
415
|
+
target = Path(os.readlink(active_mission_link))
|
|
416
|
+
mission_path = (active_mission_link.parent / target).resolve()
|
|
417
|
+
except (OSError, RuntimeError):
|
|
418
|
+
mission_path = None
|
|
419
|
+
|
|
420
|
+
if mission_path is None:
|
|
421
|
+
mission_path = kittify_dir / "missions" / "software-dev"
|
|
422
|
+
else:
|
|
423
|
+
# Default to software-dev if no active mission set
|
|
424
|
+
mission_path = kittify_dir / "missions" / "software-dev"
|
|
425
|
+
|
|
426
|
+
if not mission_path.exists():
|
|
427
|
+
raise MissionNotFoundError(
|
|
428
|
+
f"Active mission directory not found: {mission_path}\n"
|
|
429
|
+
f"Available missions: {list_available_missions(kittify_dir)}"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return Mission(mission_path)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def list_available_missions(kittify_dir: Optional[Path] = None) -> List[str]:
|
|
436
|
+
"""List all available missions in a project.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
kittify_dir: Path to .kittify directory (defaults to current project)
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
List of mission names (directory names)
|
|
443
|
+
"""
|
|
444
|
+
if kittify_dir is None:
|
|
445
|
+
kittify_dir = Path.cwd() / ".kittify"
|
|
446
|
+
|
|
447
|
+
missions_dir = kittify_dir / "missions"
|
|
448
|
+
|
|
449
|
+
if not missions_dir.exists():
|
|
450
|
+
return []
|
|
451
|
+
|
|
452
|
+
missions = []
|
|
453
|
+
for mission_dir in missions_dir.iterdir():
|
|
454
|
+
if mission_dir.is_dir() and (mission_dir / "mission.yaml").exists():
|
|
455
|
+
missions.append(mission_dir.name)
|
|
456
|
+
|
|
457
|
+
return sorted(missions)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def get_mission_by_name(mission_name: str, kittify_dir: Optional[Path] = None) -> Mission:
|
|
461
|
+
"""Get a mission by name.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
mission_name: Name of the mission (e.g., 'software-dev', 'research')
|
|
465
|
+
kittify_dir: Path to .kittify directory (defaults to current project)
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Mission object
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
MissionNotFoundError: If mission doesn't exist
|
|
472
|
+
"""
|
|
473
|
+
if kittify_dir is None:
|
|
474
|
+
kittify_dir = Path.cwd() / ".kittify"
|
|
475
|
+
|
|
476
|
+
mission_path = kittify_dir / "missions" / mission_name
|
|
477
|
+
|
|
478
|
+
if not mission_path.exists():
|
|
479
|
+
available = list_available_missions(kittify_dir)
|
|
480
|
+
raise MissionNotFoundError(
|
|
481
|
+
f"Mission '{mission_name}' not found.\n"
|
|
482
|
+
f"Available missions: {', '.join(available) if available else 'none'}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return Mission(mission_path)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def set_active_mission(mission_name: str, kittify_dir: Optional[Path] = None) -> None:
|
|
489
|
+
"""DEPRECATED: Set the active mission for a project.
|
|
490
|
+
|
|
491
|
+
.. deprecated:: 0.8.0
|
|
492
|
+
Missions are now selected per-feature during /spec-kitty.specify.
|
|
493
|
+
This function is kept for backwards compatibility but will be removed
|
|
494
|
+
in a future version. Use get_mission_for_feature() instead.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
mission_name: Name of the mission to activate
|
|
498
|
+
kittify_dir: Path to .kittify directory (defaults to current project)
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
MissionNotFoundError: If mission doesn't exist
|
|
502
|
+
"""
|
|
503
|
+
import warnings
|
|
504
|
+
warnings.warn(
|
|
505
|
+
"set_active_mission() is deprecated. Missions are now per-feature "
|
|
506
|
+
"and selected during /spec-kitty.specify. This function will be "
|
|
507
|
+
"removed in a future version.",
|
|
508
|
+
DeprecationWarning,
|
|
509
|
+
stacklevel=2
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if kittify_dir is None:
|
|
513
|
+
kittify_dir = Path.cwd() / ".kittify"
|
|
514
|
+
|
|
515
|
+
# Validate mission exists
|
|
516
|
+
mission = get_mission_by_name(mission_name, kittify_dir)
|
|
517
|
+
|
|
518
|
+
# Create or update symlink
|
|
519
|
+
active_mission_link = kittify_dir / "active-mission"
|
|
520
|
+
|
|
521
|
+
# Remove existing symlink if it exists
|
|
522
|
+
if active_mission_link.exists() or active_mission_link.is_symlink():
|
|
523
|
+
active_mission_link.unlink()
|
|
524
|
+
|
|
525
|
+
# Create new symlink (relative path keeps worktrees portable)
|
|
526
|
+
try:
|
|
527
|
+
active_mission_link.symlink_to(Path("missions") / mission_name)
|
|
528
|
+
except (OSError, NotImplementedError):
|
|
529
|
+
# Fall back to plain file marker when symlinks are unavailable
|
|
530
|
+
active_mission_link.write_text(f"{mission_name}\n", encoding="utf-8")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# =============================================================================
|
|
534
|
+
# Per-Feature Mission Functions (v0.8.0+)
|
|
535
|
+
# =============================================================================
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def get_feature_mission_key(feature_dir: Path) -> str:
|
|
539
|
+
"""Extract mission key from feature's meta.json, defaulting to software-dev.
|
|
540
|
+
|
|
541
|
+
This is a helper function for reading the mission field from a feature's
|
|
542
|
+
metadata file. It handles missing files and invalid JSON gracefully.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
feature_dir: Path to the feature directory (kitty-specs/<feature>/)
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Mission key string (e.g., 'software-dev', 'research')
|
|
549
|
+
"""
|
|
550
|
+
meta_file = feature_dir / "meta.json"
|
|
551
|
+
if not meta_file.exists():
|
|
552
|
+
return "software-dev"
|
|
553
|
+
try:
|
|
554
|
+
with open(meta_file, 'r', encoding='utf-8') as f:
|
|
555
|
+
meta = json.load(f)
|
|
556
|
+
return meta.get("mission", "software-dev")
|
|
557
|
+
except (json.JSONDecodeError, OSError):
|
|
558
|
+
return "software-dev"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def get_mission_for_feature(feature_dir: Path, project_root: Optional[Path] = None) -> Mission:
|
|
562
|
+
"""Get the mission for a specific feature.
|
|
563
|
+
|
|
564
|
+
Reads the mission key from the feature's meta.json and loads the
|
|
565
|
+
corresponding mission. If the mission field is missing or the specified
|
|
566
|
+
mission doesn't exist, falls back to software-dev for backward compatibility.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
feature_dir: Path to the feature directory (kitty-specs/<feature>/)
|
|
570
|
+
project_root: Optional project root (defaults to finding .kittify)
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Mission object for the feature
|
|
574
|
+
|
|
575
|
+
Raises:
|
|
576
|
+
MissionNotFoundError: If feature meta.json not found and no default available
|
|
577
|
+
"""
|
|
578
|
+
# Get the mission key from meta.json
|
|
579
|
+
mission_key = get_feature_mission_key(feature_dir)
|
|
580
|
+
|
|
581
|
+
# Find project root if not provided
|
|
582
|
+
if project_root is None:
|
|
583
|
+
# Walk up from feature_dir to find .kittify
|
|
584
|
+
current = feature_dir.resolve()
|
|
585
|
+
while current != current.parent:
|
|
586
|
+
if (current / ".kittify").exists():
|
|
587
|
+
project_root = current
|
|
588
|
+
break
|
|
589
|
+
current = current.parent
|
|
590
|
+
|
|
591
|
+
if project_root is None:
|
|
592
|
+
raise MissionNotFoundError(
|
|
593
|
+
f"Could not find .kittify directory from {feature_dir}\n"
|
|
594
|
+
f"Is this a Spec Kitty project?"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
kittify_dir = project_root / ".kittify"
|
|
598
|
+
|
|
599
|
+
# Try to load the specified mission
|
|
600
|
+
try:
|
|
601
|
+
return get_mission_by_name(mission_key, kittify_dir)
|
|
602
|
+
except MissionNotFoundError:
|
|
603
|
+
# Fall back to software-dev with warning
|
|
604
|
+
warnings.warn(
|
|
605
|
+
f"Mission '{mission_key}' not found for feature {feature_dir.name}, "
|
|
606
|
+
f"using software-dev as default",
|
|
607
|
+
stacklevel=2
|
|
608
|
+
)
|
|
609
|
+
return get_mission_by_name("software-dev", kittify_dir)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def discover_missions(project_root: Optional[Path] = None) -> Dict[str, Tuple[Mission, str]]:
|
|
613
|
+
"""Discover all available missions with their sources.
|
|
614
|
+
|
|
615
|
+
Scans the project's .kittify/missions/ directory for valid mission
|
|
616
|
+
configurations and returns them with source indicators.
|
|
617
|
+
|
|
618
|
+
Args:
|
|
619
|
+
project_root: Path to project root (defaults to current directory)
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Dict mapping mission key to (Mission, source) tuple.
|
|
623
|
+
Source is one of: "project", "built-in"
|
|
624
|
+
(Currently both are in the same location, but conceptually distinct)
|
|
625
|
+
"""
|
|
626
|
+
if project_root is None:
|
|
627
|
+
project_root = Path.cwd()
|
|
628
|
+
|
|
629
|
+
kittify_dir = project_root / ".kittify"
|
|
630
|
+
|
|
631
|
+
if not kittify_dir.exists():
|
|
632
|
+
return {}
|
|
633
|
+
|
|
634
|
+
missions_dir = kittify_dir / "missions"
|
|
635
|
+
|
|
636
|
+
if not missions_dir.exists():
|
|
637
|
+
return {}
|
|
638
|
+
|
|
639
|
+
missions: Dict[str, Tuple[Mission, str]] = {}
|
|
640
|
+
|
|
641
|
+
for mission_dir in missions_dir.iterdir():
|
|
642
|
+
if mission_dir.is_dir() and (mission_dir / "mission.yaml").exists():
|
|
643
|
+
try:
|
|
644
|
+
mission = Mission(mission_dir)
|
|
645
|
+
# For now, all missions are "project" source
|
|
646
|
+
# (built-in and project share same location in .kittify/missions/)
|
|
647
|
+
missions[mission_dir.name] = (mission, "project")
|
|
648
|
+
except MissionError as e:
|
|
649
|
+
warnings.warn(
|
|
650
|
+
f"Skipping invalid mission '{mission_dir.name}': {e}",
|
|
651
|
+
stacklevel=2
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return missions
|