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,848 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI utilities for managing Spec Kitty work-package prompts and acceptance."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
16
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
18
|
+
|
|
19
|
+
from task_helpers import ( # noqa: E402
|
|
20
|
+
LANES,
|
|
21
|
+
TaskCliError,
|
|
22
|
+
WorkPackage,
|
|
23
|
+
append_activity_log,
|
|
24
|
+
activity_entries,
|
|
25
|
+
build_document,
|
|
26
|
+
detect_conflicting_wp_status,
|
|
27
|
+
ensure_lane,
|
|
28
|
+
find_repo_root,
|
|
29
|
+
get_lane_from_frontmatter,
|
|
30
|
+
git_status_lines,
|
|
31
|
+
is_legacy_format,
|
|
32
|
+
normalize_note,
|
|
33
|
+
now_utc,
|
|
34
|
+
path_has_changes,
|
|
35
|
+
run_git,
|
|
36
|
+
set_scalar,
|
|
37
|
+
split_frontmatter,
|
|
38
|
+
locate_work_package,
|
|
39
|
+
)
|
|
40
|
+
from acceptance_support import ( # noqa: E402
|
|
41
|
+
AcceptanceError,
|
|
42
|
+
AcceptanceResult,
|
|
43
|
+
AcceptanceSummary,
|
|
44
|
+
ArtifactEncodingError,
|
|
45
|
+
choose_mode,
|
|
46
|
+
collect_feature_summary,
|
|
47
|
+
detect_feature_slug,
|
|
48
|
+
normalize_feature_encoding,
|
|
49
|
+
perform_acceptance,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stage_update(
|
|
54
|
+
repo_root: Path,
|
|
55
|
+
wp: WorkPackage,
|
|
56
|
+
target_lane: str,
|
|
57
|
+
agent: str,
|
|
58
|
+
shell_pid: str,
|
|
59
|
+
note: str,
|
|
60
|
+
timestamp: str,
|
|
61
|
+
dry_run: bool = False,
|
|
62
|
+
) -> Path:
|
|
63
|
+
"""Update work package lane in frontmatter (no file movement).
|
|
64
|
+
|
|
65
|
+
The frontmatter-only lane system keeps all WP files in a flat tasks/ directory.
|
|
66
|
+
Lane changes update the `lane:` field in frontmatter without moving the file.
|
|
67
|
+
"""
|
|
68
|
+
if dry_run:
|
|
69
|
+
return wp.path
|
|
70
|
+
|
|
71
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "lane", target_lane)
|
|
72
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "agent", agent)
|
|
73
|
+
if shell_pid:
|
|
74
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "shell_pid", shell_pid)
|
|
75
|
+
log_entry = f"- {timestamp} – {agent} – shell_pid={shell_pid} – lane={target_lane} – {note}"
|
|
76
|
+
new_body = append_activity_log(wp.body, log_entry)
|
|
77
|
+
|
|
78
|
+
new_content = build_document(wp.frontmatter, new_body, wp.padding)
|
|
79
|
+
wp.path.write_text(new_content, encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
run_git(["add", str(wp.path.relative_to(repo_root))], cwd=repo_root, check=True)
|
|
82
|
+
|
|
83
|
+
return wp.path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _collect_summary_with_encoding(
|
|
87
|
+
repo_root: Path,
|
|
88
|
+
feature: str,
|
|
89
|
+
*,
|
|
90
|
+
strict_metadata: bool,
|
|
91
|
+
normalize_encoding: bool,
|
|
92
|
+
) -> AcceptanceSummary:
|
|
93
|
+
try:
|
|
94
|
+
return collect_feature_summary(
|
|
95
|
+
repo_root,
|
|
96
|
+
feature,
|
|
97
|
+
strict_metadata=strict_metadata,
|
|
98
|
+
)
|
|
99
|
+
except ArtifactEncodingError as exc:
|
|
100
|
+
if not normalize_encoding:
|
|
101
|
+
raise
|
|
102
|
+
cleaned = normalize_feature_encoding(repo_root, feature)
|
|
103
|
+
if cleaned:
|
|
104
|
+
print("[spec-kitty] Normalized artifact encoding for:", file=sys.stderr)
|
|
105
|
+
for path in cleaned:
|
|
106
|
+
try:
|
|
107
|
+
rel = path.relative_to(repo_root)
|
|
108
|
+
except ValueError:
|
|
109
|
+
rel = path
|
|
110
|
+
print(f" - {rel}", file=sys.stderr)
|
|
111
|
+
else:
|
|
112
|
+
print(
|
|
113
|
+
"[spec-kitty] normalize-encoding enabled but no files required updates.",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
return collect_feature_summary(
|
|
117
|
+
repo_root,
|
|
118
|
+
feature,
|
|
119
|
+
strict_metadata=strict_metadata,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _handle_encoding_failure(exc: ArtifactEncodingError, attempted_fix: bool) -> None:
|
|
124
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
125
|
+
if attempted_fix:
|
|
126
|
+
print(
|
|
127
|
+
"Encoding issues persist after normalization attempt. Please correct the file manually.",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
print(
|
|
132
|
+
"Re-run with --normalize-encoding to attempt automatic repair.",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_legacy_warning_shown = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_legacy_format(feature: str, repo_root: Path) -> bool:
|
|
142
|
+
"""Check for legacy format and warn once. Returns True if legacy format detected."""
|
|
143
|
+
global _legacy_warning_shown
|
|
144
|
+
feature_path = repo_root / "kitty-specs" / feature
|
|
145
|
+
if is_legacy_format(feature_path):
|
|
146
|
+
if not _legacy_warning_shown:
|
|
147
|
+
print("\n" + "=" * 60, file=sys.stderr)
|
|
148
|
+
print("Legacy directory-based lanes detected.", file=sys.stderr)
|
|
149
|
+
print("", file=sys.stderr)
|
|
150
|
+
print("Your project uses the old lane structure (tasks/planned/, tasks/doing/, etc.).", file=sys.stderr)
|
|
151
|
+
print("Run `spec-kitty upgrade` to migrate to frontmatter-only lanes.", file=sys.stderr)
|
|
152
|
+
print("", file=sys.stderr)
|
|
153
|
+
print("Benefits of upgrading:", file=sys.stderr)
|
|
154
|
+
print(" - No file conflicts during lane changes", file=sys.stderr)
|
|
155
|
+
print(" - Direct editing of lane: field supported", file=sys.stderr)
|
|
156
|
+
print(" - Better multi-agent compatibility", file=sys.stderr)
|
|
157
|
+
print("=" * 60 + "\n", file=sys.stderr)
|
|
158
|
+
_legacy_warning_shown = True
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def update_command(args: argparse.Namespace) -> None:
|
|
164
|
+
"""Update a work package's lane in frontmatter (no file movement)."""
|
|
165
|
+
# Validate lane value first
|
|
166
|
+
try:
|
|
167
|
+
validated_lane = ensure_lane(args.lane)
|
|
168
|
+
except TaskCliError as e:
|
|
169
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
repo_root = find_repo_root()
|
|
173
|
+
feature = args.feature
|
|
174
|
+
|
|
175
|
+
# Check for legacy format and error out
|
|
176
|
+
if _check_legacy_format(feature, repo_root):
|
|
177
|
+
print("Error: Cannot use 'update' command on legacy format.", file=sys.stderr)
|
|
178
|
+
print("Run 'spec-kitty upgrade' first, then retry.", file=sys.stderr)
|
|
179
|
+
sys.exit(1)
|
|
180
|
+
|
|
181
|
+
wp = locate_work_package(repo_root, feature, args.work_package)
|
|
182
|
+
|
|
183
|
+
if wp.current_lane == validated_lane:
|
|
184
|
+
raise TaskCliError(f"Work package already in lane '{validated_lane}'.")
|
|
185
|
+
|
|
186
|
+
timestamp = args.timestamp or now_utc()
|
|
187
|
+
agent = args.agent or wp.agent or "system"
|
|
188
|
+
shell_pid = args.shell_pid or wp.shell_pid or ""
|
|
189
|
+
note = normalize_note(args.note, validated_lane)
|
|
190
|
+
|
|
191
|
+
# Stage the update (frontmatter only, no file movement)
|
|
192
|
+
updated_path = stage_update(
|
|
193
|
+
repo_root=repo_root,
|
|
194
|
+
wp=wp,
|
|
195
|
+
target_lane=validated_lane,
|
|
196
|
+
agent=agent,
|
|
197
|
+
shell_pid=shell_pid,
|
|
198
|
+
note=note,
|
|
199
|
+
timestamp=timestamp,
|
|
200
|
+
dry_run=args.dry_run,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if args.dry_run:
|
|
204
|
+
print(f"[dry-run] Would update {wp.work_package_id or wp.path.name} to lane '{validated_lane}'")
|
|
205
|
+
print(f"[dry-run] File stays at: {updated_path.relative_to(repo_root)}")
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
print(f"✅ Updated {wp.work_package_id or wp.path.name} → {validated_lane}")
|
|
209
|
+
print(f" {wp.path.relative_to(repo_root)}")
|
|
210
|
+
print(
|
|
211
|
+
f" Logged: - {timestamp} – {agent} – shell_pid={shell_pid} – lane={validated_lane} – {note}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def history_command(args: argparse.Namespace) -> None:
|
|
216
|
+
repo_root = find_repo_root()
|
|
217
|
+
wp = locate_work_package(repo_root, args.feature, args.work_package)
|
|
218
|
+
agent = args.agent or wp.agent or "system"
|
|
219
|
+
shell_pid = args.shell_pid or wp.shell_pid or ""
|
|
220
|
+
lane = ensure_lane(args.lane or wp.current_lane)
|
|
221
|
+
timestamp = args.timestamp or now_utc()
|
|
222
|
+
note = normalize_note(args.note, lane)
|
|
223
|
+
|
|
224
|
+
if lane != wp.current_lane:
|
|
225
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "lane", lane)
|
|
226
|
+
|
|
227
|
+
log_entry = f"- {timestamp} – {agent} – shell_pid={shell_pid} – lane={lane} – {note}"
|
|
228
|
+
updated_body = append_activity_log(wp.body, log_entry)
|
|
229
|
+
|
|
230
|
+
if args.update_shell and shell_pid:
|
|
231
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "shell_pid", shell_pid)
|
|
232
|
+
if args.assignee is not None:
|
|
233
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "assignee", args.assignee)
|
|
234
|
+
if args.agent:
|
|
235
|
+
wp.frontmatter = set_scalar(wp.frontmatter, "agent", agent)
|
|
236
|
+
|
|
237
|
+
if args.dry_run:
|
|
238
|
+
print(f"[dry-run] Would append activity entry: {log_entry}")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
new_content = build_document(wp.frontmatter, updated_body, wp.padding)
|
|
242
|
+
wp.path.write_text(new_content, encoding="utf-8")
|
|
243
|
+
run_git(["add", str(wp.path.relative_to(repo_root))], cwd=repo_root, check=True)
|
|
244
|
+
|
|
245
|
+
print(f"📝 Appended activity for {wp.work_package_id or wp.path.name}")
|
|
246
|
+
print(f" {log_entry}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def list_command(args: argparse.Namespace) -> None:
|
|
250
|
+
repo_root = find_repo_root()
|
|
251
|
+
feature_path = repo_root / "kitty-specs" / args.feature
|
|
252
|
+
feature_dir = feature_path / "tasks"
|
|
253
|
+
if not feature_dir.exists():
|
|
254
|
+
raise TaskCliError(f"Feature '{args.feature}' has no tasks directory at {feature_dir}.")
|
|
255
|
+
|
|
256
|
+
# Check for legacy format and warn
|
|
257
|
+
use_legacy = is_legacy_format(feature_path)
|
|
258
|
+
if use_legacy:
|
|
259
|
+
_check_legacy_format(args.feature, repo_root)
|
|
260
|
+
|
|
261
|
+
rows = []
|
|
262
|
+
|
|
263
|
+
if use_legacy:
|
|
264
|
+
# Legacy format: scan lane subdirectories
|
|
265
|
+
for lane in LANES:
|
|
266
|
+
lane_dir = feature_dir / lane
|
|
267
|
+
if not lane_dir.exists():
|
|
268
|
+
continue
|
|
269
|
+
for path in sorted(lane_dir.rglob("*.md")):
|
|
270
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
271
|
+
front, body, padding = split_frontmatter(text)
|
|
272
|
+
wp = WorkPackage(
|
|
273
|
+
feature=args.feature,
|
|
274
|
+
path=path,
|
|
275
|
+
current_lane=lane,
|
|
276
|
+
relative_subpath=path.relative_to(lane_dir),
|
|
277
|
+
frontmatter=front,
|
|
278
|
+
body=body,
|
|
279
|
+
padding=padding,
|
|
280
|
+
)
|
|
281
|
+
wp_id = wp.work_package_id or path.stem
|
|
282
|
+
title = (wp.title or "").strip('"')
|
|
283
|
+
assignee = (wp.assignee or "").strip()
|
|
284
|
+
agent = (wp.agent or "").strip()
|
|
285
|
+
rows.append(
|
|
286
|
+
{
|
|
287
|
+
"lane": lane,
|
|
288
|
+
"id": wp_id,
|
|
289
|
+
"title": title,
|
|
290
|
+
"assignee": assignee,
|
|
291
|
+
"agent": agent,
|
|
292
|
+
"path": str(path.relative_to(repo_root)),
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
# New format: scan flat tasks/ directory and group by frontmatter lane
|
|
297
|
+
for path in sorted(feature_dir.glob("*.md")):
|
|
298
|
+
if path.name.lower() == "readme.md":
|
|
299
|
+
continue
|
|
300
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
301
|
+
front, body, padding = split_frontmatter(text)
|
|
302
|
+
lane = get_lane_from_frontmatter(path, warn_on_missing=False)
|
|
303
|
+
wp = WorkPackage(
|
|
304
|
+
feature=args.feature,
|
|
305
|
+
path=path,
|
|
306
|
+
current_lane=lane,
|
|
307
|
+
relative_subpath=path.relative_to(feature_dir),
|
|
308
|
+
frontmatter=front,
|
|
309
|
+
body=body,
|
|
310
|
+
padding=padding,
|
|
311
|
+
)
|
|
312
|
+
wp_id = wp.work_package_id or path.stem
|
|
313
|
+
title = (wp.title or "").strip('"')
|
|
314
|
+
assignee = (wp.assignee or "").strip()
|
|
315
|
+
agent = (wp.agent or "").strip()
|
|
316
|
+
rows.append(
|
|
317
|
+
{
|
|
318
|
+
"lane": lane,
|
|
319
|
+
"id": wp_id,
|
|
320
|
+
"title": title,
|
|
321
|
+
"assignee": assignee,
|
|
322
|
+
"agent": agent,
|
|
323
|
+
"path": str(path.relative_to(repo_root)),
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if not rows:
|
|
328
|
+
print(f"No work packages found for feature '{args.feature}'.")
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
width_id = max(len(row["id"]) for row in rows)
|
|
332
|
+
width_lane = max(len(row["lane"]) for row in rows)
|
|
333
|
+
width_agent = max(len(row["agent"]) for row in rows) if any(row["agent"] for row in rows) else 5
|
|
334
|
+
width_assignee = (
|
|
335
|
+
max(len(row["assignee"]) for row in rows) if any(row["assignee"] for row in rows) else 8
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
header = (
|
|
339
|
+
f"{'Lane'.ljust(width_lane)} "
|
|
340
|
+
f"{'WP'.ljust(width_id)} "
|
|
341
|
+
f"{'Agent'.ljust(width_agent)} "
|
|
342
|
+
f"{'Assignee'.ljust(width_assignee)} "
|
|
343
|
+
"Title"
|
|
344
|
+
)
|
|
345
|
+
print(header)
|
|
346
|
+
print("-" * len(header))
|
|
347
|
+
for row in rows:
|
|
348
|
+
print(
|
|
349
|
+
f"{row['lane'].ljust(width_lane)} "
|
|
350
|
+
f"{row['id'].ljust(width_id)} "
|
|
351
|
+
f"{row['agent'].ljust(width_agent)} "
|
|
352
|
+
f"{row['assignee'].ljust(width_assignee)} "
|
|
353
|
+
f"{row['title']} ({row['path']})"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def rollback_command(args: argparse.Namespace) -> None:
|
|
358
|
+
repo_root = find_repo_root()
|
|
359
|
+
wp = locate_work_package(repo_root, args.feature, args.work_package)
|
|
360
|
+
entries = activity_entries(wp.body)
|
|
361
|
+
if len(entries) < 2:
|
|
362
|
+
raise TaskCliError("Not enough activity entries to determine the previous lane.")
|
|
363
|
+
|
|
364
|
+
previous_lane = ensure_lane(entries[-2]["lane"])
|
|
365
|
+
note = args.note or f"Rolled back to {previous_lane}"
|
|
366
|
+
args_for_update = argparse.Namespace(
|
|
367
|
+
feature=args.feature,
|
|
368
|
+
work_package=args.work_package,
|
|
369
|
+
lane=previous_lane,
|
|
370
|
+
note=note,
|
|
371
|
+
agent=args.agent or entries[-1]["agent"],
|
|
372
|
+
assignee=args.assignee,
|
|
373
|
+
shell_pid=args.shell_pid or entries[-1].get("shell_pid", ""),
|
|
374
|
+
timestamp=args.timestamp or now_utc(),
|
|
375
|
+
dry_run=args.dry_run,
|
|
376
|
+
force=args.force,
|
|
377
|
+
)
|
|
378
|
+
update_command(args_for_update)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resolve_feature(repo_root: Path, requested: Optional[str]) -> str:
|
|
382
|
+
if requested:
|
|
383
|
+
return requested
|
|
384
|
+
return detect_feature_slug(repo_root)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _summary_to_text(summary: AcceptanceSummary) -> List[str]:
|
|
388
|
+
lines: List[str] = []
|
|
389
|
+
lines.append(f"Feature: {summary.feature}")
|
|
390
|
+
lines.append(f"Branch: {summary.branch or 'N/A'}")
|
|
391
|
+
lines.append(f"Worktree: {summary.worktree_root}")
|
|
392
|
+
lines.append("")
|
|
393
|
+
lines.append("Work packages by lane:")
|
|
394
|
+
for lane in LANES:
|
|
395
|
+
items = summary.lanes.get(lane, [])
|
|
396
|
+
lines.append(f" {lane} ({len(items)}): {', '.join(items) if items else '-'}")
|
|
397
|
+
lines.append("")
|
|
398
|
+
outstanding = summary.outstanding()
|
|
399
|
+
if outstanding:
|
|
400
|
+
lines.append("Outstanding items:")
|
|
401
|
+
for key, values in outstanding.items():
|
|
402
|
+
lines.append(f" {key}:")
|
|
403
|
+
for value in values:
|
|
404
|
+
lines.append(f" - {value}")
|
|
405
|
+
else:
|
|
406
|
+
lines.append("All acceptance checks passed.")
|
|
407
|
+
if summary.optional_missing:
|
|
408
|
+
lines.append("")
|
|
409
|
+
lines.append("Optional artifacts missing: " + ", ".join(summary.optional_missing))
|
|
410
|
+
return lines
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def status_command(args: argparse.Namespace) -> None:
|
|
414
|
+
repo_root = find_repo_root()
|
|
415
|
+
feature = _resolve_feature(repo_root, args.feature)
|
|
416
|
+
try:
|
|
417
|
+
summary = _collect_summary_with_encoding(
|
|
418
|
+
repo_root,
|
|
419
|
+
feature,
|
|
420
|
+
strict_metadata=not args.lenient,
|
|
421
|
+
normalize_encoding=args.normalize_encoding,
|
|
422
|
+
)
|
|
423
|
+
except ArtifactEncodingError as exc:
|
|
424
|
+
_handle_encoding_failure(exc, args.normalize_encoding)
|
|
425
|
+
return
|
|
426
|
+
if args.json:
|
|
427
|
+
print(json.dumps(summary.to_dict(), indent=2))
|
|
428
|
+
return
|
|
429
|
+
for line in _summary_to_text(summary):
|
|
430
|
+
print(line)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def verify_command(args: argparse.Namespace) -> None:
|
|
434
|
+
repo_root = find_repo_root()
|
|
435
|
+
feature = _resolve_feature(repo_root, args.feature)
|
|
436
|
+
try:
|
|
437
|
+
summary = _collect_summary_with_encoding(
|
|
438
|
+
repo_root,
|
|
439
|
+
feature,
|
|
440
|
+
strict_metadata=not args.lenient,
|
|
441
|
+
normalize_encoding=args.normalize_encoding,
|
|
442
|
+
)
|
|
443
|
+
except ArtifactEncodingError as exc:
|
|
444
|
+
_handle_encoding_failure(exc, args.normalize_encoding)
|
|
445
|
+
return
|
|
446
|
+
if args.json:
|
|
447
|
+
print(json.dumps(summary.to_dict(), indent=2))
|
|
448
|
+
sys.exit(0 if summary.ok else 1)
|
|
449
|
+
lines = _summary_to_text(summary)
|
|
450
|
+
for line in lines:
|
|
451
|
+
print(line)
|
|
452
|
+
sys.exit(0 if summary.ok else 1)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def accept_command(args: argparse.Namespace) -> None:
|
|
456
|
+
repo_root = find_repo_root()
|
|
457
|
+
feature = _resolve_feature(repo_root, args.feature)
|
|
458
|
+
try:
|
|
459
|
+
summary = _collect_summary_with_encoding(
|
|
460
|
+
repo_root,
|
|
461
|
+
feature,
|
|
462
|
+
strict_metadata=not args.lenient,
|
|
463
|
+
normalize_encoding=args.normalize_encoding,
|
|
464
|
+
)
|
|
465
|
+
except ArtifactEncodingError as exc:
|
|
466
|
+
_handle_encoding_failure(exc, args.normalize_encoding)
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
if args.mode == "checklist":
|
|
470
|
+
if args.json:
|
|
471
|
+
print(json.dumps(summary.to_dict(), indent=2))
|
|
472
|
+
else:
|
|
473
|
+
for line in _summary_to_text(summary):
|
|
474
|
+
print(line)
|
|
475
|
+
sys.exit(0 if summary.ok else 1)
|
|
476
|
+
|
|
477
|
+
mode = choose_mode(args.mode, repo_root)
|
|
478
|
+
tests = list(args.test or [])
|
|
479
|
+
|
|
480
|
+
if not summary.ok and not args.allow_fail:
|
|
481
|
+
for line in _summary_to_text(summary):
|
|
482
|
+
print(line)
|
|
483
|
+
print("\n❌ Outstanding items detected. Fix them or re-run with --allow-fail for checklist mode.")
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
result = perform_acceptance(
|
|
488
|
+
summary,
|
|
489
|
+
mode=mode,
|
|
490
|
+
actor=args.actor,
|
|
491
|
+
tests=tests,
|
|
492
|
+
auto_commit=not args.no_commit,
|
|
493
|
+
)
|
|
494
|
+
except AcceptanceError as exc:
|
|
495
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
496
|
+
sys.exit(1)
|
|
497
|
+
|
|
498
|
+
if args.json:
|
|
499
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
print(f"✅ Feature '{feature}' accepted at {result.accepted_at} by {result.accepted_by}")
|
|
503
|
+
if result.accept_commit:
|
|
504
|
+
print(f" Acceptance commit: {result.accept_commit}")
|
|
505
|
+
if result.parent_commit:
|
|
506
|
+
print(f" Parent commit: {result.parent_commit}")
|
|
507
|
+
if result.notes:
|
|
508
|
+
print("\nNotes:")
|
|
509
|
+
for note in result.notes:
|
|
510
|
+
print(f" {note}")
|
|
511
|
+
print("\nNext steps:")
|
|
512
|
+
for instruction in result.instructions:
|
|
513
|
+
print(f" - {instruction}")
|
|
514
|
+
if result.cleanup_instructions:
|
|
515
|
+
print("\nCleanup:")
|
|
516
|
+
for instruction in result.cleanup_instructions:
|
|
517
|
+
print(f" - {instruction}")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _merge_actor(repo_root: Path) -> str:
|
|
521
|
+
configured = run_git(["config", "user.name"], cwd=repo_root, check=False)
|
|
522
|
+
if configured.returncode == 0:
|
|
523
|
+
name = configured.stdout.strip()
|
|
524
|
+
if name:
|
|
525
|
+
return name
|
|
526
|
+
return os.getenv("GIT_AUTHOR_NAME") or os.getenv("USER") or os.getenv("USERNAME") or "system"
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _prepare_merge_metadata(
|
|
530
|
+
repo_root: Path,
|
|
531
|
+
feature: str,
|
|
532
|
+
target: str,
|
|
533
|
+
strategy: str,
|
|
534
|
+
pushed: bool,
|
|
535
|
+
) -> Optional[Path]:
|
|
536
|
+
feature_dir = repo_root / "kitty-specs" / feature
|
|
537
|
+
feature_dir.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
meta_path = feature_dir / "meta.json"
|
|
539
|
+
|
|
540
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
541
|
+
merged_by = _merge_actor(repo_root)
|
|
542
|
+
|
|
543
|
+
entry: Dict[str, Any] = {
|
|
544
|
+
"merged_at": timestamp,
|
|
545
|
+
"merged_by": merged_by,
|
|
546
|
+
"target": target,
|
|
547
|
+
"strategy": strategy,
|
|
548
|
+
"pushed": pushed,
|
|
549
|
+
"merge_commit": None,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
meta: Dict[str, Any] = {}
|
|
553
|
+
if meta_path.exists():
|
|
554
|
+
try:
|
|
555
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8-sig"))
|
|
556
|
+
except json.JSONDecodeError:
|
|
557
|
+
meta = {}
|
|
558
|
+
|
|
559
|
+
history = meta.get("merge_history", [])
|
|
560
|
+
if not isinstance(history, list):
|
|
561
|
+
history = []
|
|
562
|
+
history.append(entry)
|
|
563
|
+
if len(history) > 20:
|
|
564
|
+
history = history[-20:]
|
|
565
|
+
meta["merge_history"] = history
|
|
566
|
+
|
|
567
|
+
meta["merged_at"] = timestamp
|
|
568
|
+
meta["merged_by"] = merged_by
|
|
569
|
+
meta["merged_into"] = target
|
|
570
|
+
meta["merged_strategy"] = strategy
|
|
571
|
+
meta["merged_push"] = pushed
|
|
572
|
+
|
|
573
|
+
meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
574
|
+
return meta_path
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _finalize_merge_metadata(meta_path: Optional[Path], merge_commit: str) -> None:
|
|
578
|
+
if not meta_path or not meta_path.exists():
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8-sig"))
|
|
583
|
+
except json.JSONDecodeError:
|
|
584
|
+
meta = {}
|
|
585
|
+
|
|
586
|
+
history = meta.get("merge_history")
|
|
587
|
+
if isinstance(history, list) and history:
|
|
588
|
+
if isinstance(history[-1], dict):
|
|
589
|
+
history[-1]["merge_commit"] = merge_commit
|
|
590
|
+
meta["merged_commit"] = merge_commit
|
|
591
|
+
|
|
592
|
+
meta_path.write_text(json.dumps(meta, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
593
|
+
|
|
594
|
+
def merge_command(args: argparse.Namespace) -> None:
|
|
595
|
+
repo_root = find_repo_root()
|
|
596
|
+
feature = _resolve_feature(repo_root, args.feature)
|
|
597
|
+
|
|
598
|
+
current_branch = run_git([
|
|
599
|
+
"rev-parse",
|
|
600
|
+
"--abbrev-ref",
|
|
601
|
+
"HEAD",
|
|
602
|
+
], cwd=repo_root, check=True).stdout.strip()
|
|
603
|
+
|
|
604
|
+
if current_branch == args.target:
|
|
605
|
+
raise TaskCliError(
|
|
606
|
+
f"Already on target branch '{args.target}'. Switch to the feature branch before merging."
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if current_branch != feature:
|
|
610
|
+
raise TaskCliError(
|
|
611
|
+
f"Current branch '{current_branch}' does not match detected feature '{feature}'."
|
|
612
|
+
" Run this command from the feature worktree or specify --feature explicitly."
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
try:
|
|
616
|
+
git_common = run_git(["rev-parse", "--git-common-dir"], cwd=repo_root, check=True).stdout.strip()
|
|
617
|
+
primary_repo_root = Path(git_common).resolve().parent
|
|
618
|
+
except TaskCliError:
|
|
619
|
+
primary_repo_root = Path(repo_root).resolve()
|
|
620
|
+
|
|
621
|
+
repo_root = Path(repo_root).resolve()
|
|
622
|
+
primary_repo_root = primary_repo_root.resolve()
|
|
623
|
+
in_worktree = repo_root != primary_repo_root
|
|
624
|
+
|
|
625
|
+
def ensure_clean(cwd: Path) -> None:
|
|
626
|
+
status = run_git(["status", "--porcelain"], cwd=cwd, check=True).stdout.strip()
|
|
627
|
+
if status:
|
|
628
|
+
raise TaskCliError(
|
|
629
|
+
f"Working directory at {cwd} has uncommitted changes. Commit or stash before merging."
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
ensure_clean(repo_root)
|
|
633
|
+
if in_worktree:
|
|
634
|
+
ensure_clean(primary_repo_root)
|
|
635
|
+
|
|
636
|
+
if args.dry_run:
|
|
637
|
+
steps = ["Planned actions:"]
|
|
638
|
+
steps.append(f" - Checkout {args.target} in {primary_repo_root}")
|
|
639
|
+
steps.append(" - Fetch remote (if configured)")
|
|
640
|
+
if args.strategy == "squash":
|
|
641
|
+
steps.append(f" - Merge {feature} with --squash and commit")
|
|
642
|
+
elif args.strategy == "rebase":
|
|
643
|
+
steps.append(
|
|
644
|
+
f" - Rebase {feature} onto {args.target} manually (command exits before merge)"
|
|
645
|
+
)
|
|
646
|
+
else:
|
|
647
|
+
steps.append(f" - Merge {feature} with --no-ff")
|
|
648
|
+
if args.push:
|
|
649
|
+
steps.append(f" - Push {args.target} to origin (if upstream configured)")
|
|
650
|
+
if in_worktree and args.remove_worktree:
|
|
651
|
+
steps.append(f" - Remove worktree at {repo_root}")
|
|
652
|
+
if args.delete_branch:
|
|
653
|
+
steps.append(f" - Delete branch {feature}")
|
|
654
|
+
print("\n".join(steps))
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
def git(cmd: List[str], *, cwd: Path = primary_repo_root, check: bool = True) -> subprocess.CompletedProcess:
|
|
658
|
+
return run_git(cmd, cwd=cwd, check=check)
|
|
659
|
+
|
|
660
|
+
git(["checkout", args.target])
|
|
661
|
+
|
|
662
|
+
remotes = run_git(["remote"], cwd=primary_repo_root, check=False)
|
|
663
|
+
has_remote = remotes.returncode == 0 and bool(remotes.stdout.strip())
|
|
664
|
+
if has_remote:
|
|
665
|
+
git(["fetch"], check=False)
|
|
666
|
+
pull = git(["pull", "--ff-only"], check=False)
|
|
667
|
+
if pull.returncode != 0:
|
|
668
|
+
raise TaskCliError(
|
|
669
|
+
"Failed to fast-forward target branch. Resolve upstream changes and retry."
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
if args.strategy == "rebase":
|
|
673
|
+
raise TaskCliError(
|
|
674
|
+
"Rebase strategy requires manual steps. Run `git checkout {feature}` followed by `git rebase {args.target}`."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
meta_path: Optional[Path] = None
|
|
678
|
+
meta_rel: Optional[str] = None
|
|
679
|
+
|
|
680
|
+
if args.strategy == "squash":
|
|
681
|
+
merge_proc = git(["merge", "--squash", feature], check=False)
|
|
682
|
+
if merge_proc.returncode != 0:
|
|
683
|
+
raise TaskCliError(
|
|
684
|
+
"Merge failed. Resolve conflicts manually, commit, then rerun with --keep-worktree --keep-branch."
|
|
685
|
+
)
|
|
686
|
+
meta_path = _prepare_merge_metadata(primary_repo_root, feature, args.target, args.strategy, args.push)
|
|
687
|
+
if meta_path:
|
|
688
|
+
meta_rel = str(meta_path.relative_to(primary_repo_root))
|
|
689
|
+
git(["add", meta_rel])
|
|
690
|
+
git(["commit", "-m", f"Merge feature {feature}"])
|
|
691
|
+
else:
|
|
692
|
+
merge_proc = git(["merge", "--no-ff", "--no-commit", feature], check=False)
|
|
693
|
+
if merge_proc.returncode != 0:
|
|
694
|
+
raise TaskCliError(
|
|
695
|
+
"Merge failed. Resolve conflicts manually, commit, then rerun with --keep-worktree --keep-branch."
|
|
696
|
+
)
|
|
697
|
+
meta_path = _prepare_merge_metadata(primary_repo_root, feature, args.target, args.strategy, args.push)
|
|
698
|
+
if meta_path:
|
|
699
|
+
meta_rel = str(meta_path.relative_to(primary_repo_root))
|
|
700
|
+
git(["add", meta_rel])
|
|
701
|
+
git(["commit", "-m", f"Merge feature {feature}"])
|
|
702
|
+
|
|
703
|
+
if meta_path:
|
|
704
|
+
merge_commit = git(["rev-parse", "HEAD"]).stdout.strip()
|
|
705
|
+
_finalize_merge_metadata(meta_path, merge_commit)
|
|
706
|
+
meta_rel = meta_rel or str(meta_path.relative_to(primary_repo_root))
|
|
707
|
+
git(["add", meta_rel])
|
|
708
|
+
git(["commit", "--amend", "--no-edit"])
|
|
709
|
+
|
|
710
|
+
if args.push and has_remote:
|
|
711
|
+
push_result = git(["push", "origin", args.target], check=False)
|
|
712
|
+
if push_result.returncode != 0:
|
|
713
|
+
raise TaskCliError(f"Merge succeeded but push failed. Run `git push origin {args.target}` manually.")
|
|
714
|
+
elif args.push and not has_remote:
|
|
715
|
+
print("[spec-kitty] Skipping push: no remote configured.", file=sys.stderr)
|
|
716
|
+
|
|
717
|
+
if in_worktree and args.remove_worktree:
|
|
718
|
+
if repo_root.exists():
|
|
719
|
+
git(["worktree", "remove", str(repo_root), "--force"])
|
|
720
|
+
|
|
721
|
+
if args.delete_branch:
|
|
722
|
+
delete = git(["branch", "-d", feature], check=False)
|
|
723
|
+
if delete.returncode != 0:
|
|
724
|
+
git(["branch", "-D", feature])
|
|
725
|
+
|
|
726
|
+
print(f"Merge complete: {feature} -> {args.target}")
|
|
727
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
728
|
+
parser = argparse.ArgumentParser(description="Spec Kitty task utilities")
|
|
729
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
730
|
+
|
|
731
|
+
update = subparsers.add_parser("update", help="Update a work package's lane in frontmatter")
|
|
732
|
+
update.add_argument("feature", help="Feature directory slug (e.g., 008-awesome-feature)")
|
|
733
|
+
update.add_argument("work_package", help="Work package identifier (e.g., WP03)")
|
|
734
|
+
update.add_argument("lane", help=f"Target lane ({', '.join(LANES)})")
|
|
735
|
+
update.add_argument("--note", help="Activity note to record with the update")
|
|
736
|
+
update.add_argument("--agent", help="Agent identifier to record (defaults to existing agent/system)")
|
|
737
|
+
update.add_argument("--assignee", help="Friendly assignee name to store in frontmatter")
|
|
738
|
+
update.add_argument("--shell-pid", help="Shell PID to capture in frontmatter/history")
|
|
739
|
+
update.add_argument("--timestamp", help="Override UTC timestamp (YYYY-MM-DDTHH:mm:ssZ)")
|
|
740
|
+
update.add_argument("--dry-run", action="store_true", help="Show what would happen without touching files or git")
|
|
741
|
+
update.add_argument("--force", action="store_true", help="Ignore other staged work-package files")
|
|
742
|
+
|
|
743
|
+
history = subparsers.add_parser("history", help="Append a history entry without changing lanes")
|
|
744
|
+
history.add_argument("feature", help="Feature directory slug")
|
|
745
|
+
history.add_argument("work_package", help="Work package identifier (e.g., WP03)")
|
|
746
|
+
history.add_argument("--note", required=True, help="History note to append")
|
|
747
|
+
history.add_argument("--lane", help="Lane to record (defaults to current lane)")
|
|
748
|
+
history.add_argument("--agent", help="Agent identifier (defaults to frontmatter/system)")
|
|
749
|
+
history.add_argument("--assignee", help="Assignee value to set/override")
|
|
750
|
+
history.add_argument("--shell-pid", help="Shell PID to record")
|
|
751
|
+
history.add_argument("--update-shell", action="store_true", help="Persist the provided shell PID to frontmatter")
|
|
752
|
+
history.add_argument("--timestamp", help="Override UTC timestamp")
|
|
753
|
+
history.add_argument("--dry-run", action="store_true", help="Show the log entry without updating files")
|
|
754
|
+
|
|
755
|
+
list_parser = subparsers.add_parser("list", help="List work packages by lane")
|
|
756
|
+
list_parser.add_argument("feature", help="Feature directory slug")
|
|
757
|
+
|
|
758
|
+
rollback = subparsers.add_parser("rollback", help="Return a work package to its prior lane")
|
|
759
|
+
rollback.add_argument("feature", help="Feature directory slug")
|
|
760
|
+
rollback.add_argument("work_package", help="Work package identifier (e.g., WP03)")
|
|
761
|
+
rollback.add_argument("--note", help="History note to record (default: Rolled back to <lane>)")
|
|
762
|
+
rollback.add_argument("--agent", help="Agent identifier to record for the rollback entry")
|
|
763
|
+
rollback.add_argument("--assignee", help="Assignee override to apply")
|
|
764
|
+
rollback.add_argument("--shell-pid", help="Shell PID to capture")
|
|
765
|
+
rollback.add_argument("--timestamp", help="Override UTC timestamp")
|
|
766
|
+
rollback.add_argument("--dry-run", action="store_true", help="Report planned rollback without modifying files")
|
|
767
|
+
rollback.add_argument("--force", action="store_true", help="Ignore other staged work-package files")
|
|
768
|
+
|
|
769
|
+
status = subparsers.add_parser("status", help="Summarize work packages for a feature")
|
|
770
|
+
status.add_argument("--feature", help="Feature directory slug (auto-detect by default)")
|
|
771
|
+
status.add_argument("--json", action="store_true", help="Emit JSON summary")
|
|
772
|
+
status.add_argument("--lenient", action="store_true", help="Skip strict metadata validation")
|
|
773
|
+
status.add_argument(
|
|
774
|
+
"--normalize-encoding",
|
|
775
|
+
action="store_true",
|
|
776
|
+
help="Automatically repair non-UTF-8 artifact files",
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
verify = subparsers.add_parser("verify", help="Run acceptance checks without committing")
|
|
780
|
+
verify.add_argument("--feature", help="Feature directory slug (auto-detect by default)")
|
|
781
|
+
verify.add_argument("--json", action="store_true", help="Emit JSON summary")
|
|
782
|
+
verify.add_argument("--lenient", action="store_true", help="Skip strict metadata validation")
|
|
783
|
+
verify.add_argument(
|
|
784
|
+
"--normalize-encoding",
|
|
785
|
+
action="store_true",
|
|
786
|
+
help="Automatically repair non-UTF-8 artifact files",
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
accept = subparsers.add_parser("accept", help="Perform feature acceptance workflow")
|
|
790
|
+
accept.add_argument("--feature", help="Feature directory slug (auto-detect by default)")
|
|
791
|
+
accept.add_argument("--mode", choices=["auto", "pr", "local", "checklist"], default="auto")
|
|
792
|
+
accept.add_argument("--actor", help="Override acceptance author (defaults to system/user)")
|
|
793
|
+
accept.add_argument("--test", action="append", help="Record validation command executed (repeatable)")
|
|
794
|
+
accept.add_argument("--json", action="store_true", help="Emit JSON result")
|
|
795
|
+
accept.add_argument("--lenient", action="store_true", help="Skip strict metadata validation")
|
|
796
|
+
accept.add_argument("--no-commit", action="store_true", help="Skip auto-commit (report only)")
|
|
797
|
+
accept.add_argument("--allow-fail", action="store_true", help="Allow outstanding issues (for manual workflows)")
|
|
798
|
+
accept.add_argument(
|
|
799
|
+
"--normalize-encoding",
|
|
800
|
+
action="store_true",
|
|
801
|
+
help="Automatically repair non-UTF-8 artifact files before acceptance",
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
merge = subparsers.add_parser("merge", help="Merge a feature branch into the target branch")
|
|
805
|
+
merge.add_argument("--feature", help="Feature directory slug (auto-detect by default)")
|
|
806
|
+
merge.add_argument("--strategy", choices=["merge", "squash", "rebase"], default="merge")
|
|
807
|
+
merge.add_argument("--target", default="main", help="Target branch to merge into")
|
|
808
|
+
merge.add_argument("--push", action="store_true", help="Push to origin after merging")
|
|
809
|
+
merge.add_argument("--delete-branch", dest="delete_branch", action="store_true", default=True)
|
|
810
|
+
merge.add_argument("--keep-branch", dest="delete_branch", action="store_false")
|
|
811
|
+
merge.add_argument("--remove-worktree", dest="remove_worktree", action="store_true", default=True)
|
|
812
|
+
merge.add_argument("--keep-worktree", dest="remove_worktree", action="store_false")
|
|
813
|
+
merge.add_argument("--dry-run", action="store_true", help="Show actions without executing")
|
|
814
|
+
|
|
815
|
+
return parser
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
819
|
+
parser = build_parser()
|
|
820
|
+
args = parser.parse_args(argv)
|
|
821
|
+
try:
|
|
822
|
+
if args.command == "update":
|
|
823
|
+
update_command(args)
|
|
824
|
+
elif args.command == "history":
|
|
825
|
+
history_command(args)
|
|
826
|
+
elif args.command == "list":
|
|
827
|
+
list_command(args)
|
|
828
|
+
elif args.command == "rollback":
|
|
829
|
+
rollback_command(args)
|
|
830
|
+
elif args.command == "status":
|
|
831
|
+
status_command(args)
|
|
832
|
+
elif args.command == "verify":
|
|
833
|
+
verify_command(args)
|
|
834
|
+
elif args.command == "merge":
|
|
835
|
+
merge_command(args)
|
|
836
|
+
elif args.command == "accept":
|
|
837
|
+
accept_command(args)
|
|
838
|
+
else:
|
|
839
|
+
parser.error(f"Unknown command {args.command}")
|
|
840
|
+
return 1
|
|
841
|
+
except TaskCliError as exc:
|
|
842
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
843
|
+
return 1
|
|
844
|
+
return 0
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
if __name__ == "__main__":
|
|
848
|
+
sys.exit(main())
|