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,1057 @@
|
|
|
1
|
+
"""Feature lifecycle commands for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from typing_extensions import Annotated
|
|
18
|
+
|
|
19
|
+
from specify_cli.core.paths import locate_project_root, is_worktree_context
|
|
20
|
+
from specify_cli.core.worktree import (
|
|
21
|
+
get_next_feature_number,
|
|
22
|
+
validate_feature_structure,
|
|
23
|
+
setup_feature_directory,
|
|
24
|
+
)
|
|
25
|
+
from specify_cli.core.git_ops import run_command, is_git_repo, get_current_branch
|
|
26
|
+
from specify_cli.core.dependency_graph import (
|
|
27
|
+
parse_wp_dependencies,
|
|
28
|
+
detect_cycles,
|
|
29
|
+
validate_dependencies,
|
|
30
|
+
)
|
|
31
|
+
from specify_cli.frontmatter import read_frontmatter, write_frontmatter
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="feature",
|
|
35
|
+
help="Feature lifecycle commands for AI agents",
|
|
36
|
+
no_args_is_help=True
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _commit_to_main(
|
|
43
|
+
file_path: Path,
|
|
44
|
+
feature_slug: str,
|
|
45
|
+
artifact_type: str,
|
|
46
|
+
repo_root: Path,
|
|
47
|
+
json_output: bool = False
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Commit planning artifact to main branch.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
file_path: Path to file being committed
|
|
53
|
+
feature_slug: Feature slug (e.g., "001-my-feature")
|
|
54
|
+
artifact_type: Type of artifact ("spec", "plan", "tasks")
|
|
55
|
+
repo_root: Repository root path (ensures commits go to main repo, not worktree)
|
|
56
|
+
json_output: If True, suppress Rich console output
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
subprocess.CalledProcessError: If commit fails unexpectedly
|
|
60
|
+
typer.Exit: If not on main/master branch
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Verify we're on main branch (check from repo root)
|
|
64
|
+
current_branch = get_current_branch(repo_root)
|
|
65
|
+
if current_branch not in ["main", "master"]:
|
|
66
|
+
error_msg = f"Planning artifacts must be committed to main branch (currently on: {current_branch})"
|
|
67
|
+
if not json_output:
|
|
68
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
69
|
+
console.print("[yellow]Switch to main branch:[/yellow] cd {repo_root} && git checkout main")
|
|
70
|
+
raise RuntimeError(error_msg)
|
|
71
|
+
|
|
72
|
+
# Add file to staging (run from repo root to ensure main repo, not worktree)
|
|
73
|
+
run_command(["git", "add", str(file_path)], check_return=True, capture=True, cwd=repo_root)
|
|
74
|
+
|
|
75
|
+
# Commit with descriptive message
|
|
76
|
+
commit_msg = f"Add {artifact_type} for feature {feature_slug}"
|
|
77
|
+
run_command(
|
|
78
|
+
["git", "commit", "-m", commit_msg],
|
|
79
|
+
check_return=True,
|
|
80
|
+
capture=True,
|
|
81
|
+
cwd=repo_root
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not json_output:
|
|
85
|
+
console.print(f"[green]✓[/green] {artifact_type.capitalize()} committed to main")
|
|
86
|
+
|
|
87
|
+
except subprocess.CalledProcessError as e:
|
|
88
|
+
# Check if it's just "nothing to commit" (benign)
|
|
89
|
+
stderr = e.stderr if hasattr(e, 'stderr') and e.stderr else ""
|
|
90
|
+
if "nothing to commit" in stderr or "nothing added to commit" in stderr:
|
|
91
|
+
# Benign - file unchanged
|
|
92
|
+
if not json_output:
|
|
93
|
+
console.print(f"[dim]{artifact_type.capitalize()} unchanged, no commit needed[/dim]")
|
|
94
|
+
else:
|
|
95
|
+
# Actual error
|
|
96
|
+
if not json_output:
|
|
97
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to commit {artifact_type}: {e}")
|
|
98
|
+
console.print(f"[yellow]You may need to commit manually:[/yellow] git add {file_path} && git commit")
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_feature_directory(repo_root: Path, cwd: Path) -> Path:
|
|
103
|
+
"""Find the current feature directory.
|
|
104
|
+
|
|
105
|
+
Handles three contexts:
|
|
106
|
+
1. Worktree root (cwd contains kitty-specs/)
|
|
107
|
+
2. Inside feature directory (walk up to find kitty-specs/)
|
|
108
|
+
3. Main repo (find latest feature in kitty-specs/)
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
repo_root: Repository root path
|
|
112
|
+
cwd: Current working directory
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to feature directory
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If feature directory cannot be determined
|
|
119
|
+
"""
|
|
120
|
+
# Check if we're in a worktree
|
|
121
|
+
if is_worktree_context(cwd):
|
|
122
|
+
# Get the current git branch name to match feature directory
|
|
123
|
+
try:
|
|
124
|
+
result = subprocess.run(
|
|
125
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
126
|
+
cwd=cwd,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
check=True
|
|
130
|
+
)
|
|
131
|
+
branch_name = result.stdout.strip()
|
|
132
|
+
except subprocess.CalledProcessError:
|
|
133
|
+
branch_name = None
|
|
134
|
+
|
|
135
|
+
# Strategy 1: Check if cwd contains kitty-specs/ (we're at worktree root)
|
|
136
|
+
kitty_specs_candidate = cwd / "kitty-specs"
|
|
137
|
+
if kitty_specs_candidate.exists() and kitty_specs_candidate.is_dir():
|
|
138
|
+
kitty_specs = kitty_specs_candidate
|
|
139
|
+
else:
|
|
140
|
+
# Strategy 2: Walk up to find kitty-specs directory
|
|
141
|
+
kitty_specs = cwd
|
|
142
|
+
while kitty_specs != kitty_specs.parent:
|
|
143
|
+
if kitty_specs.name == "kitty-specs":
|
|
144
|
+
break
|
|
145
|
+
kitty_specs = kitty_specs.parent
|
|
146
|
+
|
|
147
|
+
if kitty_specs.name != "kitty-specs":
|
|
148
|
+
raise ValueError("Could not locate kitty-specs directory in worktree")
|
|
149
|
+
|
|
150
|
+
# Find the ###-* feature directory that matches the branch name
|
|
151
|
+
if branch_name:
|
|
152
|
+
# First try exact match with branch name
|
|
153
|
+
branch_feature_dir = kitty_specs / branch_name
|
|
154
|
+
if branch_feature_dir.exists() and branch_feature_dir.is_dir():
|
|
155
|
+
return branch_feature_dir
|
|
156
|
+
|
|
157
|
+
# Fallback: Find any ###-* feature directory (for older worktrees)
|
|
158
|
+
for item in kitty_specs.iterdir():
|
|
159
|
+
if item.is_dir() and len(item.name) >= 3 and item.name[:3].isdigit():
|
|
160
|
+
return item
|
|
161
|
+
|
|
162
|
+
raise ValueError("Could not find feature directory in worktree")
|
|
163
|
+
else:
|
|
164
|
+
# We're in main repo - find latest feature
|
|
165
|
+
specs_dir = repo_root / "kitty-specs"
|
|
166
|
+
if not specs_dir.exists():
|
|
167
|
+
raise ValueError("No kitty-specs directory found in repository")
|
|
168
|
+
|
|
169
|
+
# Find the highest numbered feature
|
|
170
|
+
max_num = 0
|
|
171
|
+
feature_dir = None
|
|
172
|
+
for item in specs_dir.iterdir():
|
|
173
|
+
if item.is_dir() and len(item.name) >= 3 and item.name[:3].isdigit():
|
|
174
|
+
try:
|
|
175
|
+
num = int(item.name[:3])
|
|
176
|
+
if num > max_num:
|
|
177
|
+
max_num = num
|
|
178
|
+
feature_dir = item
|
|
179
|
+
except ValueError:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
if feature_dir is None:
|
|
183
|
+
raise ValueError("No feature directories found in kitty-specs/")
|
|
184
|
+
|
|
185
|
+
return feature_dir
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command(name="create-feature")
|
|
189
|
+
def create_feature(
|
|
190
|
+
feature_slug: Annotated[str, typer.Argument(help="Feature slug (e.g., 'user-auth')")],
|
|
191
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Create new feature directory structure in main repository.
|
|
194
|
+
|
|
195
|
+
This command is designed for AI agents to call programmatically.
|
|
196
|
+
Creates feature directory in kitty-specs/ and commits to main branch.
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
spec-kitty agent create-feature "new-dashboard" --json
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
# GUARD: Refuse to run from inside a worktree (must be on main branch in main repo)
|
|
203
|
+
cwd = Path.cwd().resolve()
|
|
204
|
+
if is_worktree_context(cwd):
|
|
205
|
+
error_msg = "Cannot create features from inside a worktree. Must be on main branch in main repository."
|
|
206
|
+
if json_output:
|
|
207
|
+
print(json.dumps({"error": error_msg}))
|
|
208
|
+
else:
|
|
209
|
+
console.print(f"[bold red]Error:[/bold red] {error_msg}")
|
|
210
|
+
# Find and suggest the main repo path
|
|
211
|
+
for i, part in enumerate(cwd.parts):
|
|
212
|
+
if part == ".worktrees":
|
|
213
|
+
main_repo = Path(*cwd.parts[:i])
|
|
214
|
+
console.print(f"\n[cyan]Run from the main repository instead:[/cyan]")
|
|
215
|
+
console.print(f" cd {main_repo}")
|
|
216
|
+
console.print(f" spec-kitty agent create-feature {feature_slug}")
|
|
217
|
+
break
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
|
|
220
|
+
repo_root = locate_project_root()
|
|
221
|
+
if repo_root is None:
|
|
222
|
+
error_msg = "Could not locate project root. Run from within spec-kitty repository."
|
|
223
|
+
if json_output:
|
|
224
|
+
print(json.dumps({"error": error_msg}))
|
|
225
|
+
else:
|
|
226
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
|
|
229
|
+
# Verify we're in a git repository
|
|
230
|
+
if not is_git_repo(repo_root):
|
|
231
|
+
error_msg = "Not in a git repository. Feature creation requires git."
|
|
232
|
+
if json_output:
|
|
233
|
+
print(json.dumps({"error": error_msg}))
|
|
234
|
+
else:
|
|
235
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
# Verify we're on main branch (or acceptable branch)
|
|
239
|
+
current_branch = get_current_branch(repo_root)
|
|
240
|
+
if current_branch not in ["main", "master"]:
|
|
241
|
+
error_msg = f"Must be on main branch to create features (currently on: {current_branch})"
|
|
242
|
+
if json_output:
|
|
243
|
+
print(json.dumps({"error": error_msg}))
|
|
244
|
+
else:
|
|
245
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
|
|
248
|
+
# Get next feature number
|
|
249
|
+
feature_number = get_next_feature_number(repo_root)
|
|
250
|
+
feature_slug_formatted = f"{feature_number:03d}-{feature_slug}"
|
|
251
|
+
|
|
252
|
+
# Create feature directory in main repo
|
|
253
|
+
feature_dir = repo_root / "kitty-specs" / feature_slug_formatted
|
|
254
|
+
feature_dir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
# Create subdirectories
|
|
257
|
+
(feature_dir / "checklists").mkdir(exist_ok=True)
|
|
258
|
+
(feature_dir / "research").mkdir(exist_ok=True)
|
|
259
|
+
tasks_dir = feature_dir / "tasks"
|
|
260
|
+
tasks_dir.mkdir(exist_ok=True)
|
|
261
|
+
|
|
262
|
+
# Create tasks/.gitkeep and README.md
|
|
263
|
+
(tasks_dir / ".gitkeep").touch()
|
|
264
|
+
|
|
265
|
+
# Create tasks/README.md (using same content from setup_feature_directory)
|
|
266
|
+
tasks_readme_content = '''# Tasks Directory
|
|
267
|
+
|
|
268
|
+
This directory contains work package (WP) prompt files with lane status in frontmatter.
|
|
269
|
+
|
|
270
|
+
## Directory Structure (v0.9.0+)
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
tasks/
|
|
274
|
+
├── WP01-setup-infrastructure.md
|
|
275
|
+
├── WP02-user-authentication.md
|
|
276
|
+
├── WP03-api-endpoints.md
|
|
277
|
+
└── README.md
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
All WP files are stored flat in `tasks/`. The lane (planned, doing, for_review, done) is stored in the YAML frontmatter `lane:` field.
|
|
281
|
+
|
|
282
|
+
## Work Package File Format
|
|
283
|
+
|
|
284
|
+
Each WP file **MUST** use YAML frontmatter:
|
|
285
|
+
|
|
286
|
+
```yaml
|
|
287
|
+
---
|
|
288
|
+
work_package_id: "WP01"
|
|
289
|
+
title: "Work Package Title"
|
|
290
|
+
lane: "planned"
|
|
291
|
+
subtasks:
|
|
292
|
+
- "T001"
|
|
293
|
+
- "T002"
|
|
294
|
+
phase: "Phase 1 - Setup"
|
|
295
|
+
assignee: ""
|
|
296
|
+
agent: ""
|
|
297
|
+
shell_pid: ""
|
|
298
|
+
review_status: ""
|
|
299
|
+
reviewed_by: ""
|
|
300
|
+
history:
|
|
301
|
+
- timestamp: "2025-01-01T00:00:00Z"
|
|
302
|
+
lane: "planned"
|
|
303
|
+
agent: "system"
|
|
304
|
+
action: "Prompt generated via /spec-kitty.tasks"
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
# Work Package Prompt: WP01 – Work Package Title
|
|
308
|
+
|
|
309
|
+
[Content follows...]
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Valid Lane Values
|
|
313
|
+
|
|
314
|
+
- `planned` - Ready for implementation
|
|
315
|
+
- `doing` - Currently being worked on
|
|
316
|
+
- `for_review` - Awaiting review
|
|
317
|
+
- `done` - Completed
|
|
318
|
+
|
|
319
|
+
## Moving Between Lanes
|
|
320
|
+
|
|
321
|
+
Use the CLI (updates frontmatter only, no file movement):
|
|
322
|
+
```bash
|
|
323
|
+
spec-kitty agent tasks move-task <WPID> --to <lane>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Example:
|
|
327
|
+
```bash
|
|
328
|
+
spec-kitty agent tasks move-task WP01 --to doing
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## File Naming
|
|
332
|
+
|
|
333
|
+
- Format: `WP01-kebab-case-slug.md`
|
|
334
|
+
- Examples: `WP01-setup-infrastructure.md`, `WP02-user-auth.md`
|
|
335
|
+
'''
|
|
336
|
+
(tasks_dir / "README.md").write_text(tasks_readme_content)
|
|
337
|
+
|
|
338
|
+
# Copy spec template if it exists
|
|
339
|
+
spec_file = feature_dir / "spec.md"
|
|
340
|
+
if not spec_file.exists():
|
|
341
|
+
spec_template_candidates = [
|
|
342
|
+
repo_root / ".kittify" / "templates" / "spec-template.md",
|
|
343
|
+
repo_root / "templates" / "spec-template.md",
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
for template in spec_template_candidates:
|
|
347
|
+
if template.exists():
|
|
348
|
+
shutil.copy2(template, spec_file)
|
|
349
|
+
break
|
|
350
|
+
else:
|
|
351
|
+
# No template found, create empty spec.md
|
|
352
|
+
spec_file.touch()
|
|
353
|
+
|
|
354
|
+
# Commit spec.md to main
|
|
355
|
+
_commit_to_main(spec_file, feature_slug_formatted, "spec", repo_root, json_output)
|
|
356
|
+
|
|
357
|
+
if json_output:
|
|
358
|
+
print(json.dumps({
|
|
359
|
+
"result": "success",
|
|
360
|
+
"feature": feature_slug_formatted,
|
|
361
|
+
"feature_dir": str(feature_dir)
|
|
362
|
+
}))
|
|
363
|
+
else:
|
|
364
|
+
console.print(f"[green]✓[/green] Feature created: {feature_slug_formatted}")
|
|
365
|
+
console.print(f" Directory: {feature_dir}")
|
|
366
|
+
console.print(f" Spec committed to main")
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
if json_output:
|
|
370
|
+
print(json.dumps({"error": str(e)}))
|
|
371
|
+
else:
|
|
372
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
373
|
+
raise typer.Exit(1)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.command(name="check-prerequisites")
|
|
377
|
+
def check_prerequisites(
|
|
378
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
379
|
+
paths_only: Annotated[bool, typer.Option("--paths-only", help="Only output path variables")] = False,
|
|
380
|
+
include_tasks: Annotated[bool, typer.Option("--include-tasks", help="Include tasks.md in validation")] = False,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Validate feature structure and prerequisites.
|
|
383
|
+
|
|
384
|
+
This command is designed for AI agents to call programmatically.
|
|
385
|
+
|
|
386
|
+
Examples:
|
|
387
|
+
spec-kitty agent check-prerequisites --json
|
|
388
|
+
spec-kitty agent check-prerequisites --paths-only --json
|
|
389
|
+
"""
|
|
390
|
+
try:
|
|
391
|
+
repo_root = locate_project_root()
|
|
392
|
+
if repo_root is None:
|
|
393
|
+
error_msg = "Could not locate project root. Run from within spec-kitty repository."
|
|
394
|
+
if json_output:
|
|
395
|
+
print(json.dumps({"error": error_msg}))
|
|
396
|
+
else:
|
|
397
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
398
|
+
raise typer.Exit(1)
|
|
399
|
+
|
|
400
|
+
# Determine feature directory (main repo or worktree)
|
|
401
|
+
cwd = Path.cwd().resolve()
|
|
402
|
+
feature_dir = _find_feature_directory(repo_root, cwd)
|
|
403
|
+
|
|
404
|
+
validation_result = validate_feature_structure(feature_dir, check_tasks=include_tasks)
|
|
405
|
+
|
|
406
|
+
if json_output:
|
|
407
|
+
if paths_only:
|
|
408
|
+
print(json.dumps(validation_result["paths"]))
|
|
409
|
+
else:
|
|
410
|
+
print(json.dumps(validation_result))
|
|
411
|
+
else:
|
|
412
|
+
if validation_result["valid"]:
|
|
413
|
+
console.print("[green]✓[/green] Prerequisites check passed")
|
|
414
|
+
console.print(f" Feature: {feature_dir.name}")
|
|
415
|
+
else:
|
|
416
|
+
console.print("[red]✗[/red] Prerequisites check failed")
|
|
417
|
+
for error in validation_result["errors"]:
|
|
418
|
+
console.print(f" • {error}")
|
|
419
|
+
|
|
420
|
+
if validation_result["warnings"]:
|
|
421
|
+
console.print("\n[yellow]Warnings:[/yellow]")
|
|
422
|
+
for warning in validation_result["warnings"]:
|
|
423
|
+
console.print(f" • {warning}")
|
|
424
|
+
|
|
425
|
+
except Exception as e:
|
|
426
|
+
if json_output:
|
|
427
|
+
print(json.dumps({"error": str(e)}))
|
|
428
|
+
else:
|
|
429
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
430
|
+
raise typer.Exit(1)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@app.command(name="setup-plan")
|
|
434
|
+
def setup_plan(
|
|
435
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Scaffold implementation plan template in main repository.
|
|
438
|
+
|
|
439
|
+
This command is designed for AI agents to call programmatically.
|
|
440
|
+
Creates plan.md and commits to main branch.
|
|
441
|
+
|
|
442
|
+
Examples:
|
|
443
|
+
spec-kitty agent setup-plan --json
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
repo_root = locate_project_root()
|
|
447
|
+
if repo_root is None:
|
|
448
|
+
error_msg = "Could not locate project root. Run from within spec-kitty repository."
|
|
449
|
+
if json_output:
|
|
450
|
+
print(json.dumps({"error": error_msg}))
|
|
451
|
+
else:
|
|
452
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
453
|
+
raise typer.Exit(1)
|
|
454
|
+
|
|
455
|
+
# Determine feature directory (main repo or worktree)
|
|
456
|
+
cwd = Path.cwd().resolve()
|
|
457
|
+
feature_dir = _find_feature_directory(repo_root, cwd)
|
|
458
|
+
|
|
459
|
+
plan_file = feature_dir / "plan.md"
|
|
460
|
+
|
|
461
|
+
# Find plan template
|
|
462
|
+
plan_template_candidates = [
|
|
463
|
+
repo_root / ".kittify" / "templates" / "plan-template.md",
|
|
464
|
+
repo_root / "src" / "specify_cli" / "templates" / "plan-template.md",
|
|
465
|
+
repo_root / "templates" / "plan-template.md",
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
plan_template = None
|
|
469
|
+
for candidate in plan_template_candidates:
|
|
470
|
+
if candidate.exists():
|
|
471
|
+
plan_template = candidate
|
|
472
|
+
break
|
|
473
|
+
|
|
474
|
+
if plan_template is not None:
|
|
475
|
+
shutil.copy2(plan_template, plan_file)
|
|
476
|
+
else:
|
|
477
|
+
package_template = files("specify_cli").joinpath("templates", "plan-template.md")
|
|
478
|
+
if not package_template.exists():
|
|
479
|
+
raise FileNotFoundError("Plan template not found in repository or package")
|
|
480
|
+
with package_template.open("rb") as src, open(plan_file, "wb") as dst:
|
|
481
|
+
shutil.copyfileobj(src, dst)
|
|
482
|
+
|
|
483
|
+
# Commit plan.md to main
|
|
484
|
+
feature_slug = feature_dir.name
|
|
485
|
+
_commit_to_main(plan_file, feature_slug, "plan", repo_root, json_output)
|
|
486
|
+
|
|
487
|
+
if json_output:
|
|
488
|
+
print(json.dumps({
|
|
489
|
+
"result": "success",
|
|
490
|
+
"plan_file": str(plan_file),
|
|
491
|
+
"feature_dir": str(feature_dir)
|
|
492
|
+
}))
|
|
493
|
+
else:
|
|
494
|
+
console.print(f"[green]✓[/green] Plan scaffolded: {plan_file}")
|
|
495
|
+
|
|
496
|
+
except Exception as e:
|
|
497
|
+
if json_output:
|
|
498
|
+
print(json.dumps({"error": str(e)}))
|
|
499
|
+
else:
|
|
500
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
501
|
+
raise typer.Exit(1)
|
|
502
|
+
|
|
503
|
+
def _find_latest_feature_worktree(repo_root: Path) -> Optional[Path]:
|
|
504
|
+
"""Find the latest feature worktree by number.
|
|
505
|
+
|
|
506
|
+
Migrated from find_latest_feature_worktree() in common.sh
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
repo_root: Repository root directory
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
Path to latest worktree, or None if no worktrees exist
|
|
513
|
+
"""
|
|
514
|
+
worktrees_dir = repo_root / ".worktrees"
|
|
515
|
+
if not worktrees_dir.exists():
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
latest_num = 0
|
|
519
|
+
latest_worktree = None
|
|
520
|
+
|
|
521
|
+
for worktree_dir in worktrees_dir.iterdir():
|
|
522
|
+
if not worktree_dir.is_dir():
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
# Match pattern: 001-feature-name
|
|
526
|
+
match = re.match(r"^(\d{3})-", worktree_dir.name)
|
|
527
|
+
if match:
|
|
528
|
+
num = int(match.group(1))
|
|
529
|
+
if num > latest_num:
|
|
530
|
+
latest_num = num
|
|
531
|
+
latest_worktree = worktree_dir
|
|
532
|
+
|
|
533
|
+
return latest_worktree
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _get_current_branch(repo_root: Path) -> str:
|
|
537
|
+
"""Get current git branch name.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
repo_root: Repository root directory
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Current branch name, or 'main' if not in a git repo
|
|
544
|
+
"""
|
|
545
|
+
result = subprocess.run(
|
|
546
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
547
|
+
cwd=repo_root,
|
|
548
|
+
capture_output=True,
|
|
549
|
+
text=True,
|
|
550
|
+
check=False
|
|
551
|
+
)
|
|
552
|
+
return result.stdout.strip() if result.returncode == 0 else "main"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@app.command(name="accept")
|
|
556
|
+
def accept_feature(
|
|
557
|
+
feature: Annotated[
|
|
558
|
+
Optional[str],
|
|
559
|
+
typer.Option(
|
|
560
|
+
"--feature",
|
|
561
|
+
help="Feature directory slug (auto-detected if not specified)"
|
|
562
|
+
)
|
|
563
|
+
] = None,
|
|
564
|
+
mode: Annotated[
|
|
565
|
+
str,
|
|
566
|
+
typer.Option(
|
|
567
|
+
"--mode",
|
|
568
|
+
help="Acceptance mode: auto, pr, local, checklist"
|
|
569
|
+
)
|
|
570
|
+
] = "auto",
|
|
571
|
+
json_output: Annotated[
|
|
572
|
+
bool,
|
|
573
|
+
typer.Option(
|
|
574
|
+
"--json",
|
|
575
|
+
help="Output results as JSON for agent parsing"
|
|
576
|
+
)
|
|
577
|
+
] = False,
|
|
578
|
+
lenient: Annotated[
|
|
579
|
+
bool,
|
|
580
|
+
typer.Option(
|
|
581
|
+
"--lenient",
|
|
582
|
+
help="Skip strict metadata validation"
|
|
583
|
+
)
|
|
584
|
+
] = False,
|
|
585
|
+
no_commit: Annotated[
|
|
586
|
+
bool,
|
|
587
|
+
typer.Option(
|
|
588
|
+
"--no-commit",
|
|
589
|
+
help="Skip auto-commit (report only)"
|
|
590
|
+
)
|
|
591
|
+
] = False,
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Perform feature acceptance workflow.
|
|
594
|
+
|
|
595
|
+
This command:
|
|
596
|
+
1. Validates all tasks are in 'done' lane
|
|
597
|
+
2. Runs acceptance checks from checklist files
|
|
598
|
+
3. Creates acceptance report
|
|
599
|
+
4. Marks feature as ready for merge
|
|
600
|
+
|
|
601
|
+
Delegates to existing tasks_cli.py accept implementation.
|
|
602
|
+
|
|
603
|
+
Examples:
|
|
604
|
+
# Run acceptance workflow
|
|
605
|
+
spec-kitty agent feature accept
|
|
606
|
+
|
|
607
|
+
# With JSON output for agents
|
|
608
|
+
spec-kitty agent feature accept --json
|
|
609
|
+
|
|
610
|
+
# Lenient mode (skip strict validation)
|
|
611
|
+
spec-kitty agent feature accept --lenient --json
|
|
612
|
+
"""
|
|
613
|
+
try:
|
|
614
|
+
repo_root = locate_project_root()
|
|
615
|
+
if repo_root is None:
|
|
616
|
+
error = "Could not locate project root"
|
|
617
|
+
if json_output:
|
|
618
|
+
print(json.dumps({"error": error, "success": False}))
|
|
619
|
+
else:
|
|
620
|
+
console.print(f"[red]Error:[/red] {error}")
|
|
621
|
+
sys.exit(1)
|
|
622
|
+
|
|
623
|
+
# Build command to call tasks_cli.py
|
|
624
|
+
tasks_cli = repo_root / "scripts" / "tasks" / "tasks_cli.py"
|
|
625
|
+
if not tasks_cli.exists():
|
|
626
|
+
error = f"tasks_cli.py not found: {tasks_cli}"
|
|
627
|
+
if json_output:
|
|
628
|
+
print(json.dumps({"error": error, "success": False}))
|
|
629
|
+
else:
|
|
630
|
+
console.print(f"[red]Error:[/red] {error}")
|
|
631
|
+
sys.exit(1)
|
|
632
|
+
|
|
633
|
+
cmd = ["python3", str(tasks_cli), "accept"]
|
|
634
|
+
if feature:
|
|
635
|
+
cmd.extend(["--feature", feature])
|
|
636
|
+
cmd.extend(["--mode", mode])
|
|
637
|
+
if json_output:
|
|
638
|
+
cmd.append("--json")
|
|
639
|
+
if lenient:
|
|
640
|
+
cmd.append("--lenient")
|
|
641
|
+
if no_commit:
|
|
642
|
+
cmd.append("--no-commit")
|
|
643
|
+
|
|
644
|
+
# Execute accept command
|
|
645
|
+
result = subprocess.run(
|
|
646
|
+
cmd,
|
|
647
|
+
cwd=repo_root,
|
|
648
|
+
capture_output=True,
|
|
649
|
+
text=True,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Pass through output
|
|
653
|
+
if result.stdout:
|
|
654
|
+
print(result.stdout, end="")
|
|
655
|
+
if result.stderr and not json_output:
|
|
656
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
657
|
+
|
|
658
|
+
sys.exit(result.returncode)
|
|
659
|
+
|
|
660
|
+
except Exception as e:
|
|
661
|
+
if json_output:
|
|
662
|
+
print(json.dumps({"error": str(e), "success": False}))
|
|
663
|
+
else:
|
|
664
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
665
|
+
sys.exit(1)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@app.command(name="merge")
|
|
669
|
+
def merge_feature(
|
|
670
|
+
feature: Annotated[
|
|
671
|
+
Optional[str],
|
|
672
|
+
typer.Option(
|
|
673
|
+
"--feature",
|
|
674
|
+
help="Feature directory slug (auto-detected if not specified)"
|
|
675
|
+
)
|
|
676
|
+
] = None,
|
|
677
|
+
target: Annotated[
|
|
678
|
+
str,
|
|
679
|
+
typer.Option(
|
|
680
|
+
"--target",
|
|
681
|
+
help="Target branch to merge into"
|
|
682
|
+
)
|
|
683
|
+
] = "main",
|
|
684
|
+
strategy: Annotated[
|
|
685
|
+
str,
|
|
686
|
+
typer.Option(
|
|
687
|
+
"--strategy",
|
|
688
|
+
help="Merge strategy: merge, squash, rebase"
|
|
689
|
+
)
|
|
690
|
+
] = "merge",
|
|
691
|
+
push: Annotated[
|
|
692
|
+
bool,
|
|
693
|
+
typer.Option(
|
|
694
|
+
"--push",
|
|
695
|
+
help="Push to origin after merging"
|
|
696
|
+
)
|
|
697
|
+
] = False,
|
|
698
|
+
dry_run: Annotated[
|
|
699
|
+
bool,
|
|
700
|
+
typer.Option(
|
|
701
|
+
"--dry-run",
|
|
702
|
+
help="Show actions without executing"
|
|
703
|
+
)
|
|
704
|
+
] = False,
|
|
705
|
+
keep_branch: Annotated[
|
|
706
|
+
bool,
|
|
707
|
+
typer.Option(
|
|
708
|
+
"--keep-branch",
|
|
709
|
+
help="Keep feature branch after merge (default: delete)"
|
|
710
|
+
)
|
|
711
|
+
] = False,
|
|
712
|
+
keep_worktree: Annotated[
|
|
713
|
+
bool,
|
|
714
|
+
typer.Option(
|
|
715
|
+
"--keep-worktree",
|
|
716
|
+
help="Keep worktree after merge (default: remove)"
|
|
717
|
+
)
|
|
718
|
+
] = False,
|
|
719
|
+
auto_retry: Annotated[
|
|
720
|
+
bool,
|
|
721
|
+
typer.Option(
|
|
722
|
+
"--auto-retry/--no-auto-retry",
|
|
723
|
+
help="Auto-navigate to latest worktree if in wrong location"
|
|
724
|
+
)
|
|
725
|
+
] = True,
|
|
726
|
+
) -> None:
|
|
727
|
+
"""Merge feature branch into target branch.
|
|
728
|
+
|
|
729
|
+
This command:
|
|
730
|
+
1. Validates feature is accepted
|
|
731
|
+
2. Merges feature branch into target (usually 'main')
|
|
732
|
+
3. Cleans up worktree
|
|
733
|
+
4. Deletes feature branch
|
|
734
|
+
|
|
735
|
+
Auto-retry logic (from merge-feature.sh):
|
|
736
|
+
If current branch doesn't match feature pattern (XXX-name) and auto-retry is enabled,
|
|
737
|
+
automatically finds and navigates to latest worktree.
|
|
738
|
+
|
|
739
|
+
Delegates to existing tasks_cli.py merge implementation.
|
|
740
|
+
|
|
741
|
+
Examples:
|
|
742
|
+
# Merge into main branch
|
|
743
|
+
spec-kitty agent feature merge
|
|
744
|
+
|
|
745
|
+
# Merge into specific branch with push
|
|
746
|
+
spec-kitty agent feature merge --target develop --push
|
|
747
|
+
|
|
748
|
+
# Dry-run mode
|
|
749
|
+
spec-kitty agent feature merge --dry-run
|
|
750
|
+
|
|
751
|
+
# Keep worktree and branch after merge
|
|
752
|
+
spec-kitty agent feature merge --keep-worktree --keep-branch
|
|
753
|
+
"""
|
|
754
|
+
try:
|
|
755
|
+
repo_root = locate_project_root()
|
|
756
|
+
if repo_root is None:
|
|
757
|
+
error = "Could not locate project root"
|
|
758
|
+
print(json.dumps({"error": error, "success": False}))
|
|
759
|
+
sys.exit(1)
|
|
760
|
+
|
|
761
|
+
# Auto-retry logic: Check if we're on a feature branch
|
|
762
|
+
if auto_retry and not os.environ.get("SPEC_KITTY_AUTORETRY"):
|
|
763
|
+
current_branch = _get_current_branch(repo_root)
|
|
764
|
+
is_feature_branch = re.match(r"^\d{3}-", current_branch)
|
|
765
|
+
|
|
766
|
+
if not is_feature_branch:
|
|
767
|
+
# Try to find latest worktree and retry there
|
|
768
|
+
latest_worktree = _find_latest_feature_worktree(repo_root)
|
|
769
|
+
if latest_worktree:
|
|
770
|
+
console.print(
|
|
771
|
+
f"[yellow]Auto-retry:[/yellow] Not on feature branch ({current_branch}). "
|
|
772
|
+
f"Running merge in {latest_worktree.name}"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Set env var to prevent infinite recursion
|
|
776
|
+
env = os.environ.copy()
|
|
777
|
+
env["SPEC_KITTY_AUTORETRY"] = "1"
|
|
778
|
+
|
|
779
|
+
# Re-run command in worktree
|
|
780
|
+
retry_cmd = ["spec-kitty", "agent", "feature", "merge"]
|
|
781
|
+
if feature:
|
|
782
|
+
retry_cmd.extend(["--feature", feature])
|
|
783
|
+
retry_cmd.extend(["--target", target, "--strategy", strategy])
|
|
784
|
+
if push:
|
|
785
|
+
retry_cmd.append("--push")
|
|
786
|
+
if dry_run:
|
|
787
|
+
retry_cmd.append("--dry-run")
|
|
788
|
+
if keep_branch:
|
|
789
|
+
retry_cmd.append("--keep-branch")
|
|
790
|
+
if keep_worktree:
|
|
791
|
+
retry_cmd.append("--keep-worktree")
|
|
792
|
+
retry_cmd.append("--no-auto-retry")
|
|
793
|
+
|
|
794
|
+
result = subprocess.run(
|
|
795
|
+
retry_cmd,
|
|
796
|
+
cwd=latest_worktree,
|
|
797
|
+
env=env,
|
|
798
|
+
)
|
|
799
|
+
sys.exit(result.returncode)
|
|
800
|
+
|
|
801
|
+
# Build command to call tasks_cli.py
|
|
802
|
+
tasks_cli = repo_root / "scripts" / "tasks" / "tasks_cli.py"
|
|
803
|
+
if not tasks_cli.exists():
|
|
804
|
+
error = f"tasks_cli.py not found: {tasks_cli}"
|
|
805
|
+
print(json.dumps({"error": error, "success": False}))
|
|
806
|
+
sys.exit(1)
|
|
807
|
+
|
|
808
|
+
cmd = ["python3", str(tasks_cli), "merge"]
|
|
809
|
+
if feature:
|
|
810
|
+
cmd.extend(["--feature", feature])
|
|
811
|
+
cmd.extend(["--target", target, "--strategy", strategy])
|
|
812
|
+
if push:
|
|
813
|
+
cmd.append("--push")
|
|
814
|
+
if dry_run:
|
|
815
|
+
cmd.append("--dry-run")
|
|
816
|
+
if keep_branch:
|
|
817
|
+
cmd.append("--keep-branch")
|
|
818
|
+
else:
|
|
819
|
+
cmd.append("--delete-branch")
|
|
820
|
+
if keep_worktree:
|
|
821
|
+
cmd.append("--keep-worktree")
|
|
822
|
+
else:
|
|
823
|
+
cmd.append("--remove-worktree")
|
|
824
|
+
|
|
825
|
+
# Execute merge command
|
|
826
|
+
result = subprocess.run(
|
|
827
|
+
cmd,
|
|
828
|
+
cwd=repo_root,
|
|
829
|
+
capture_output=True,
|
|
830
|
+
text=True,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Pass through output
|
|
834
|
+
if result.stdout:
|
|
835
|
+
print(result.stdout, end="")
|
|
836
|
+
if result.stderr:
|
|
837
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
838
|
+
|
|
839
|
+
sys.exit(result.returncode)
|
|
840
|
+
|
|
841
|
+
except Exception as e:
|
|
842
|
+
print(json.dumps({"error": str(e), "success": False}))
|
|
843
|
+
sys.exit(1)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@app.command(name="finalize-tasks")
|
|
847
|
+
def finalize_tasks(
|
|
848
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output JSON format")] = False,
|
|
849
|
+
) -> None:
|
|
850
|
+
"""Parse dependencies from tasks.md and update WP frontmatter, then commit to main.
|
|
851
|
+
|
|
852
|
+
This command is designed to be called after LLM generates WP files via /spec-kitty.tasks.
|
|
853
|
+
It post-processes the generated files to add dependency information and commits everything.
|
|
854
|
+
|
|
855
|
+
Examples:
|
|
856
|
+
spec-kitty agent feature finalize-tasks --json
|
|
857
|
+
"""
|
|
858
|
+
try:
|
|
859
|
+
repo_root = locate_project_root()
|
|
860
|
+
if repo_root is None:
|
|
861
|
+
error_msg = "Could not locate project root"
|
|
862
|
+
if json_output:
|
|
863
|
+
print(json.dumps({"error": error_msg}))
|
|
864
|
+
else:
|
|
865
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
866
|
+
raise typer.Exit(1)
|
|
867
|
+
|
|
868
|
+
# Determine feature directory
|
|
869
|
+
cwd = Path.cwd().resolve()
|
|
870
|
+
feature_dir = _find_feature_directory(repo_root, cwd)
|
|
871
|
+
|
|
872
|
+
tasks_dir = feature_dir / "tasks"
|
|
873
|
+
if not tasks_dir.exists():
|
|
874
|
+
error_msg = f"Tasks directory not found: {tasks_dir}"
|
|
875
|
+
if json_output:
|
|
876
|
+
print(json.dumps({"error": error_msg}))
|
|
877
|
+
else:
|
|
878
|
+
console.print(f"[red]Error:[/red] {error_msg}")
|
|
879
|
+
raise typer.Exit(1)
|
|
880
|
+
|
|
881
|
+
# Parse dependencies from tasks.md (if it exists)
|
|
882
|
+
tasks_md = feature_dir / "tasks.md"
|
|
883
|
+
wp_dependencies = {}
|
|
884
|
+
if tasks_md.exists():
|
|
885
|
+
# Read tasks.md and parse dependencies
|
|
886
|
+
tasks_content = tasks_md.read_text(encoding="utf-8")
|
|
887
|
+
wp_dependencies = _parse_dependencies_from_tasks_md(tasks_content)
|
|
888
|
+
|
|
889
|
+
# Validate dependencies (detect cycles, invalid references)
|
|
890
|
+
if wp_dependencies:
|
|
891
|
+
# Check for circular dependencies
|
|
892
|
+
cycles = detect_cycles(wp_dependencies)
|
|
893
|
+
if cycles:
|
|
894
|
+
error_msg = f"Circular dependencies detected: {cycles}"
|
|
895
|
+
if json_output:
|
|
896
|
+
print(json.dumps({"error": error_msg, "cycles": cycles}))
|
|
897
|
+
else:
|
|
898
|
+
console.print(f"[red]Error:[/red] Circular dependencies detected:")
|
|
899
|
+
for cycle in cycles:
|
|
900
|
+
console.print(f" {' → '.join(cycle)}")
|
|
901
|
+
raise typer.Exit(1)
|
|
902
|
+
|
|
903
|
+
# Validate each WP's dependencies
|
|
904
|
+
for wp_id, deps in wp_dependencies.items():
|
|
905
|
+
is_valid, errors = validate_dependencies(wp_id, deps, wp_dependencies)
|
|
906
|
+
if not is_valid:
|
|
907
|
+
error_msg = f"Invalid dependencies for {wp_id}: {errors}"
|
|
908
|
+
if json_output:
|
|
909
|
+
print(json.dumps({"error": error_msg, "wp_id": wp_id, "errors": errors}))
|
|
910
|
+
else:
|
|
911
|
+
console.print(f"[red]Error:[/red] Invalid dependencies for {wp_id}:")
|
|
912
|
+
for err in errors:
|
|
913
|
+
console.print(f" - {err}")
|
|
914
|
+
raise typer.Exit(1)
|
|
915
|
+
|
|
916
|
+
# Update each WP file's frontmatter with dependencies
|
|
917
|
+
wp_files = list(tasks_dir.glob("WP*.md"))
|
|
918
|
+
updated_count = 0
|
|
919
|
+
|
|
920
|
+
for wp_file in wp_files:
|
|
921
|
+
# Extract WP ID from filename
|
|
922
|
+
wp_id_match = re.match(r"(WP\d{2})", wp_file.name)
|
|
923
|
+
if not wp_id_match:
|
|
924
|
+
continue
|
|
925
|
+
|
|
926
|
+
wp_id = wp_id_match.group(1)
|
|
927
|
+
|
|
928
|
+
# Detect whether dependencies field exists in raw frontmatter
|
|
929
|
+
raw_content = wp_file.read_text(encoding="utf-8")
|
|
930
|
+
has_dependencies_line = False
|
|
931
|
+
if raw_content.startswith("---"):
|
|
932
|
+
parts = raw_content.split("---", 2)
|
|
933
|
+
if len(parts) >= 3:
|
|
934
|
+
frontmatter_text = parts[1]
|
|
935
|
+
has_dependencies_line = re.search(
|
|
936
|
+
r"^\s*dependencies\s*:", frontmatter_text, re.MULTILINE
|
|
937
|
+
) is not None
|
|
938
|
+
|
|
939
|
+
# Read current frontmatter
|
|
940
|
+
try:
|
|
941
|
+
frontmatter, body = read_frontmatter(wp_file)
|
|
942
|
+
except Exception as e:
|
|
943
|
+
console.print(f"[yellow]Warning:[/yellow] Could not read {wp_file.name}: {e}")
|
|
944
|
+
continue
|
|
945
|
+
|
|
946
|
+
# Get dependencies for this WP (default to empty list)
|
|
947
|
+
deps = wp_dependencies.get(wp_id, [])
|
|
948
|
+
|
|
949
|
+
# Update frontmatter with dependencies
|
|
950
|
+
if not has_dependencies_line or frontmatter.get("dependencies") != deps:
|
|
951
|
+
frontmatter["dependencies"] = deps
|
|
952
|
+
|
|
953
|
+
# Write updated frontmatter
|
|
954
|
+
write_frontmatter(wp_file, frontmatter, body)
|
|
955
|
+
updated_count += 1
|
|
956
|
+
|
|
957
|
+
# Commit tasks.md and WP files to main
|
|
958
|
+
feature_slug = feature_dir.name
|
|
959
|
+
try:
|
|
960
|
+
# Add tasks.md (if present) and all WP files
|
|
961
|
+
if tasks_md.exists():
|
|
962
|
+
run_command(
|
|
963
|
+
["git", "add", str(tasks_md)],
|
|
964
|
+
check_return=True,
|
|
965
|
+
capture=True,
|
|
966
|
+
cwd=repo_root
|
|
967
|
+
)
|
|
968
|
+
run_command(
|
|
969
|
+
["git", "add", str(tasks_dir)],
|
|
970
|
+
check_return=True,
|
|
971
|
+
capture=True,
|
|
972
|
+
cwd=repo_root
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Commit with descriptive message
|
|
976
|
+
commit_msg = f"Add tasks for feature {feature_slug}"
|
|
977
|
+
run_command(
|
|
978
|
+
["git", "commit", "-m", commit_msg],
|
|
979
|
+
check_return=True,
|
|
980
|
+
capture=True,
|
|
981
|
+
cwd=repo_root
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
if not json_output:
|
|
985
|
+
console.print(f"[green]✓[/green] Tasks committed to main")
|
|
986
|
+
console.print(f"[dim]Updated {updated_count} WP files with dependencies[/dim]")
|
|
987
|
+
|
|
988
|
+
except subprocess.CalledProcessError as e:
|
|
989
|
+
# Check if it's just "nothing to commit"
|
|
990
|
+
stderr = e.stderr if hasattr(e, 'stderr') and e.stderr else ""
|
|
991
|
+
if "nothing to commit" in stderr or "nothing added to commit" in stderr:
|
|
992
|
+
if not json_output:
|
|
993
|
+
console.print(f"[dim]Tasks unchanged, no commit needed[/dim]")
|
|
994
|
+
else:
|
|
995
|
+
raise
|
|
996
|
+
|
|
997
|
+
if json_output:
|
|
998
|
+
print(json.dumps({
|
|
999
|
+
"result": "success",
|
|
1000
|
+
"updated_wp_count": updated_count,
|
|
1001
|
+
"tasks_dir": str(tasks_dir)
|
|
1002
|
+
}))
|
|
1003
|
+
|
|
1004
|
+
except Exception as e:
|
|
1005
|
+
if json_output:
|
|
1006
|
+
print(json.dumps({"error": str(e)}))
|
|
1007
|
+
else:
|
|
1008
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
1009
|
+
raise typer.Exit(1)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _parse_dependencies_from_tasks_md(tasks_content: str) -> dict[str, list[str]]:
|
|
1013
|
+
"""Parse WP dependencies from tasks.md content.
|
|
1014
|
+
|
|
1015
|
+
Parsing strategy (priority order):
|
|
1016
|
+
1. Explicit dependency markers ("Depends on WP01", "Dependencies: WP01, WP02")
|
|
1017
|
+
2. Phase grouping (Phase 2 WPs depend on Phase 1 WPs)
|
|
1018
|
+
3. Default to empty list if ambiguous
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
Dict mapping WP ID to list of dependencies
|
|
1022
|
+
Example: {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01", "WP02"]}
|
|
1023
|
+
"""
|
|
1024
|
+
dependencies = {}
|
|
1025
|
+
|
|
1026
|
+
# Split into WP sections
|
|
1027
|
+
wp_sections = re.split(r'##\s+Work Package (WP\d{2})', tasks_content)
|
|
1028
|
+
|
|
1029
|
+
# Process sections (they come in pairs: WP ID, then content)
|
|
1030
|
+
for i in range(1, len(wp_sections), 2):
|
|
1031
|
+
if i + 1 >= len(wp_sections):
|
|
1032
|
+
break
|
|
1033
|
+
|
|
1034
|
+
wp_id = wp_sections[i]
|
|
1035
|
+
section_content = wp_sections[i + 1]
|
|
1036
|
+
|
|
1037
|
+
# Method 1: Explicit "Depends on" or "Dependencies:"
|
|
1038
|
+
explicit_deps = []
|
|
1039
|
+
|
|
1040
|
+
# Pattern: "Depends on WP01" or "Depends on WP01, WP02"
|
|
1041
|
+
depends_matches = re.findall(r'Depends?\s+on\s+(WP\d{2}(?:\s*,\s*WP\d{2})*)', section_content, re.IGNORECASE)
|
|
1042
|
+
for match in depends_matches:
|
|
1043
|
+
explicit_deps.extend(re.findall(r'WP\d{2}', match))
|
|
1044
|
+
|
|
1045
|
+
# Pattern: "Dependencies: WP01, WP02"
|
|
1046
|
+
deps_line = re.search(r'Dependencies:\s*(.+)', section_content)
|
|
1047
|
+
if deps_line:
|
|
1048
|
+
explicit_deps.extend(re.findall(r'WP\d{2}', deps_line.group(1)))
|
|
1049
|
+
|
|
1050
|
+
if explicit_deps:
|
|
1051
|
+
# Remove duplicates and sort
|
|
1052
|
+
dependencies[wp_id] = sorted(list(set(explicit_deps)))
|
|
1053
|
+
else:
|
|
1054
|
+
# Default to empty
|
|
1055
|
+
dependencies[wp_id] = []
|
|
1056
|
+
|
|
1057
|
+
return dependencies
|