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,506 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Standalone helpers for Spec Kitty task prompt management."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
# IMPORTANT: Keep in sync with src/specify_cli/tasks_support.py
|
|
16
|
+
LANES: Tuple[str, ...] = ("planned", "doing", "for_review", "done")
|
|
17
|
+
|
|
18
|
+
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
19
|
+
|
|
20
|
+
# Lane directories that indicate legacy format when they contain .md files
|
|
21
|
+
LEGACY_LANE_DIRS: List[str] = ["planned", "doing", "for_review", "done"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_legacy_format(feature_path: Path) -> bool:
|
|
25
|
+
"""Check if feature uses legacy directory-based lanes.
|
|
26
|
+
|
|
27
|
+
A feature is considered to use legacy format if:
|
|
28
|
+
- It has a tasks/ subdirectory
|
|
29
|
+
- Any of the lane subdirectories (planned/, doing/, for_review/, done/)
|
|
30
|
+
exist AND contain at least one .md file
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
feature_path: Path to the feature directory (e.g., kitty-specs/007-feature/)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if legacy directory-based lanes detected, False otherwise.
|
|
37
|
+
|
|
38
|
+
Note:
|
|
39
|
+
Empty lane directories (containing only .gitkeep) are NOT considered
|
|
40
|
+
legacy format - only directories with actual .md work package files.
|
|
41
|
+
"""
|
|
42
|
+
tasks_dir = feature_path / "tasks"
|
|
43
|
+
if not tasks_dir.exists():
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
for lane in LEGACY_LANE_DIRS:
|
|
47
|
+
lane_path = tasks_dir / lane
|
|
48
|
+
if lane_path.is_dir():
|
|
49
|
+
# Check if there are any .md files (not just .gitkeep)
|
|
50
|
+
md_files = list(lane_path.glob("*.md"))
|
|
51
|
+
if md_files:
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TaskCliError(RuntimeError):
|
|
58
|
+
"""Raised when task operations cannot be completed safely."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_repo_root(start: Optional[Path] = None) -> Path:
|
|
62
|
+
"""Find the MAIN repository root, even when inside a worktree.
|
|
63
|
+
|
|
64
|
+
This function correctly handles git worktrees by detecting when .git is a
|
|
65
|
+
file (worktree pointer) vs a directory (main repo), and following the
|
|
66
|
+
pointer back to the main repository.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
start: Starting directory for search (defaults to cwd)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Path to the main repository root
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
TaskCliError: If repository root cannot be found
|
|
76
|
+
"""
|
|
77
|
+
current = (start or Path.cwd()).resolve()
|
|
78
|
+
|
|
79
|
+
for candidate in [current, *current.parents]:
|
|
80
|
+
git_path = candidate / ".git"
|
|
81
|
+
|
|
82
|
+
if git_path.is_file():
|
|
83
|
+
# This is a worktree! The .git file contains a pointer to the main repo.
|
|
84
|
+
# Format: "gitdir: /path/to/main/.git/worktrees/worktree-name"
|
|
85
|
+
try:
|
|
86
|
+
content = git_path.read_text().strip()
|
|
87
|
+
if content.startswith("gitdir:"):
|
|
88
|
+
gitdir = Path(content.split(":", 1)[1].strip())
|
|
89
|
+
# Navigate: .git/worktrees/name -> .git -> main repo root
|
|
90
|
+
# gitdir points to .git/worktrees/xxx, so .parent.parent is .git
|
|
91
|
+
main_git_dir = gitdir.parent.parent
|
|
92
|
+
main_repo = main_git_dir.parent
|
|
93
|
+
if main_repo.exists():
|
|
94
|
+
return main_repo
|
|
95
|
+
except (OSError, ValueError):
|
|
96
|
+
# If we can't read or parse the .git file, continue searching
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
elif git_path.is_dir():
|
|
100
|
+
# This is the main repo (or a regular git repo)
|
|
101
|
+
return candidate
|
|
102
|
+
|
|
103
|
+
# Also check for .kittify marker (fallback for non-git scenarios)
|
|
104
|
+
if (candidate / ".kittify").exists():
|
|
105
|
+
return candidate
|
|
106
|
+
|
|
107
|
+
raise TaskCliError("Unable to locate repository root (missing .git or .kittify).")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_git(args: List[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess:
|
|
111
|
+
"""Run a git command inside the repository."""
|
|
112
|
+
try:
|
|
113
|
+
return subprocess.run(
|
|
114
|
+
["git", *args],
|
|
115
|
+
cwd=str(cwd),
|
|
116
|
+
check=check,
|
|
117
|
+
text=True,
|
|
118
|
+
capture_output=True,
|
|
119
|
+
)
|
|
120
|
+
except FileNotFoundError as exc:
|
|
121
|
+
raise TaskCliError("git is not available on PATH.") from exc
|
|
122
|
+
except subprocess.CalledProcessError as exc:
|
|
123
|
+
if check:
|
|
124
|
+
message = exc.stderr.strip() or exc.stdout.strip() or "Unknown git error"
|
|
125
|
+
raise TaskCliError(message)
|
|
126
|
+
return exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ensure_lane(value: str) -> str:
|
|
130
|
+
lane = value.strip().lower()
|
|
131
|
+
if lane not in LANES:
|
|
132
|
+
raise TaskCliError(f"Invalid lane '{value}'. Expected one of {', '.join(LANES)}.")
|
|
133
|
+
return lane
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def now_utc() -> str:
|
|
137
|
+
return datetime.now(timezone.utc).strftime(TIMESTAMP_FORMAT)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def git_status_lines(repo_root: Path) -> List[str]:
|
|
141
|
+
result = run_git(["status", "--porcelain"], cwd=repo_root, check=True)
|
|
142
|
+
return [line for line in result.stdout.splitlines() if line.strip()]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _normalize_status_path(raw: str) -> str:
|
|
146
|
+
candidate = raw.split(" -> ", 1)[0].strip()
|
|
147
|
+
candidate = candidate.lstrip("./")
|
|
148
|
+
return candidate.replace("\\", "/")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def path_has_changes(status_lines: List[str], path: Path) -> bool:
|
|
152
|
+
"""Return True if git status indicates modifications for the given path."""
|
|
153
|
+
normalized = _normalize_status_path(str(path))
|
|
154
|
+
for line in status_lines:
|
|
155
|
+
if len(line) < 4:
|
|
156
|
+
continue
|
|
157
|
+
candidate = _normalize_status_path(line[3:])
|
|
158
|
+
if candidate == normalized:
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def normalize_note(note: Optional[str], target_lane: str) -> str:
|
|
164
|
+
default = f"Moved to {target_lane}"
|
|
165
|
+
cleaned = (note or default).strip()
|
|
166
|
+
return cleaned or default
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def detect_conflicting_wp_status(
|
|
170
|
+
status_lines: List[str], feature: str, old_path: Path, new_path: Path
|
|
171
|
+
) -> List[str]:
|
|
172
|
+
"""Return staged work-package entries unrelated to the requested move."""
|
|
173
|
+
base_path = Path("kitty-specs") / feature / "tasks"
|
|
174
|
+
prefix = f"{base_path.as_posix()}/"
|
|
175
|
+
allowed = {
|
|
176
|
+
str(old_path).lstrip("./"),
|
|
177
|
+
str(new_path).lstrip("./"),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def _wp_suffix(path: Path) -> Optional[str]:
|
|
181
|
+
try:
|
|
182
|
+
relative = path.relative_to(base_path)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
parts = relative.parts
|
|
186
|
+
if not parts:
|
|
187
|
+
return None
|
|
188
|
+
if len(parts) == 1:
|
|
189
|
+
return parts[0]
|
|
190
|
+
return Path(*parts[1:]).as_posix()
|
|
191
|
+
|
|
192
|
+
suffixes = {suffix for suffix in (_wp_suffix(old_path), _wp_suffix(new_path)) if suffix}
|
|
193
|
+
conflicts = []
|
|
194
|
+
for line in status_lines:
|
|
195
|
+
path = line[3:] if len(line) > 3 else ""
|
|
196
|
+
if not path.startswith(prefix):
|
|
197
|
+
continue
|
|
198
|
+
clean = path.strip()
|
|
199
|
+
if clean not in allowed:
|
|
200
|
+
if suffixes and line and line[0] == "D":
|
|
201
|
+
for suffix in suffixes:
|
|
202
|
+
if clean.endswith(suffix):
|
|
203
|
+
break
|
|
204
|
+
else:
|
|
205
|
+
conflicts.append(line)
|
|
206
|
+
continue
|
|
207
|
+
continue
|
|
208
|
+
conflicts.append(line)
|
|
209
|
+
return conflicts
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def match_frontmatter_line(frontmatter: str, key: str) -> Optional[re.Match]:
|
|
213
|
+
pattern = re.compile(
|
|
214
|
+
rf"^({re.escape(key)}:\s*)(\".*?\"|'.*?'|[^#\n]*)(.*)$",
|
|
215
|
+
flags=re.MULTILINE,
|
|
216
|
+
)
|
|
217
|
+
return pattern.search(frontmatter)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def extract_scalar(frontmatter: str, key: str) -> Optional[str]:
|
|
221
|
+
match = match_frontmatter_line(frontmatter, key)
|
|
222
|
+
if not match:
|
|
223
|
+
return None
|
|
224
|
+
raw_value = match.group(2).strip()
|
|
225
|
+
if raw_value.startswith('"') and raw_value.endswith('"'):
|
|
226
|
+
return raw_value[1:-1]
|
|
227
|
+
if raw_value.startswith("'") and raw_value.endswith("'"):
|
|
228
|
+
return raw_value[1:-1]
|
|
229
|
+
return raw_value.strip() or None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def set_scalar(frontmatter: str, key: str, value: str) -> str:
|
|
233
|
+
"""Replace or insert a scalar value while preserving trailing comments."""
|
|
234
|
+
match = match_frontmatter_line(frontmatter, key)
|
|
235
|
+
replacement_line = f'{key}: "{value}"'
|
|
236
|
+
if match:
|
|
237
|
+
prefix = match.group(1)
|
|
238
|
+
comment = match.group(3)
|
|
239
|
+
comment_suffix = f"{comment}" if comment else ""
|
|
240
|
+
return (
|
|
241
|
+
frontmatter[: match.start()]
|
|
242
|
+
+ f'{prefix}"{value}"{comment_suffix}'
|
|
243
|
+
+ frontmatter[match.end() :]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
insertion = f"{replacement_line}\n"
|
|
247
|
+
history_match = re.compile(r"^\s*history:\s*$", flags=re.MULTILINE).search(frontmatter)
|
|
248
|
+
if history_match:
|
|
249
|
+
idx = history_match.start()
|
|
250
|
+
return frontmatter[:idx] + insertion + frontmatter[idx:]
|
|
251
|
+
|
|
252
|
+
if frontmatter and not frontmatter.endswith("\n"):
|
|
253
|
+
frontmatter += "\n"
|
|
254
|
+
return frontmatter + insertion
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def split_frontmatter(text: str) -> Tuple[str, str, str]:
|
|
258
|
+
"""Return (frontmatter, body, padding) while preserving spacing after frontmatter."""
|
|
259
|
+
normalized = text.replace("\r\n", "\n")
|
|
260
|
+
if not normalized.startswith("---\n"):
|
|
261
|
+
return "", normalized, ""
|
|
262
|
+
|
|
263
|
+
closing_idx = normalized.find("\n---", 4)
|
|
264
|
+
if closing_idx == -1:
|
|
265
|
+
return "", normalized, ""
|
|
266
|
+
|
|
267
|
+
front = normalized[4:closing_idx]
|
|
268
|
+
tail = normalized[closing_idx + 4 :]
|
|
269
|
+
padding = ""
|
|
270
|
+
while tail.startswith("\n"):
|
|
271
|
+
padding += "\n"
|
|
272
|
+
tail = tail[1:]
|
|
273
|
+
return front, tail, padding
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_document(frontmatter: str, body: str, padding: str) -> str:
|
|
277
|
+
frontmatter = frontmatter.rstrip("\n")
|
|
278
|
+
doc = f"---\n{frontmatter}\n---"
|
|
279
|
+
if padding or body:
|
|
280
|
+
doc += padding or "\n"
|
|
281
|
+
doc += body
|
|
282
|
+
if not doc.endswith("\n"):
|
|
283
|
+
doc += "\n"
|
|
284
|
+
return doc
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def append_activity_log(body: str, entry: str) -> str:
|
|
288
|
+
header = "## Activity Log"
|
|
289
|
+
if header not in body:
|
|
290
|
+
block = f"{header}\n\n{entry}\n"
|
|
291
|
+
if body and not body.endswith("\n\n"):
|
|
292
|
+
return body.rstrip() + "\n\n" + block
|
|
293
|
+
return body + "\n" + block if body else block
|
|
294
|
+
|
|
295
|
+
pattern = re.compile(r"(## Activity Log.*?)(?=\n## |\Z)", flags=re.DOTALL)
|
|
296
|
+
match = pattern.search(body)
|
|
297
|
+
if not match:
|
|
298
|
+
return body + ("\n" if not body.endswith("\n") else "") + entry + "\n"
|
|
299
|
+
|
|
300
|
+
section = match.group(1).rstrip()
|
|
301
|
+
if not section.endswith("\n"):
|
|
302
|
+
section += "\n"
|
|
303
|
+
section += f"{entry}\n"
|
|
304
|
+
return body[: match.start(1)] + section + body[match.end(1) :]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def activity_entries(body: str) -> List[Dict[str, str]]:
|
|
308
|
+
# Match both en-dash (–) and hyphen (-) as separators
|
|
309
|
+
# The separator is always surrounded by whitespace, so we match non-whitespace for fields
|
|
310
|
+
pattern = re.compile(
|
|
311
|
+
r"^\s*-\s*"
|
|
312
|
+
r"(?P<timestamp>[0-9T:-]+Z)\s+[–-]\s+"
|
|
313
|
+
r"(?P<agent>\S+(?:\s+\S+)*?)\s+[–-]\s+"
|
|
314
|
+
r"(?:shell_pid=(?P<shell>\S*)\s+[–-]\s+)?"
|
|
315
|
+
r"lane=(?P<lane>[a-z_]+)\s+[–-]\s+"
|
|
316
|
+
r"(?P<note>.*)$",
|
|
317
|
+
flags=re.MULTILINE,
|
|
318
|
+
)
|
|
319
|
+
entries: List[Dict[str, str]] = []
|
|
320
|
+
for match in pattern.finditer(body):
|
|
321
|
+
entries.append(
|
|
322
|
+
{
|
|
323
|
+
"timestamp": match.group("timestamp").strip(),
|
|
324
|
+
"agent": match.group("agent").strip(),
|
|
325
|
+
"lane": match.group("lane").strip(),
|
|
326
|
+
"note": match.group("note").strip(),
|
|
327
|
+
"shell_pid": (match.group("shell") or "").strip(),
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
return entries
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class WorkPackage:
|
|
335
|
+
feature: str
|
|
336
|
+
path: Path
|
|
337
|
+
current_lane: str
|
|
338
|
+
relative_subpath: Path
|
|
339
|
+
frontmatter: str
|
|
340
|
+
body: str
|
|
341
|
+
padding: str
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def work_package_id(self) -> Optional[str]:
|
|
345
|
+
return extract_scalar(self.frontmatter, "work_package_id")
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def title(self) -> Optional[str]:
|
|
349
|
+
return extract_scalar(self.frontmatter, "title")
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def assignee(self) -> Optional[str]:
|
|
353
|
+
return extract_scalar(self.frontmatter, "assignee")
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def agent(self) -> Optional[str]:
|
|
357
|
+
return extract_scalar(self.frontmatter, "agent")
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def shell_pid(self) -> Optional[str]:
|
|
361
|
+
return extract_scalar(self.frontmatter, "shell_pid")
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def lane(self) -> Optional[str]:
|
|
365
|
+
return extract_scalar(self.frontmatter, "lane")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def locate_work_package(repo_root: Path, feature: str, wp_id: str) -> WorkPackage:
|
|
369
|
+
"""Locate a work package by ID, supporting both legacy and new formats.
|
|
370
|
+
|
|
371
|
+
Legacy format: WP files in tasks/{lane}/ subdirectories
|
|
372
|
+
New format: WP files in flat tasks/ directory with lane in frontmatter
|
|
373
|
+
"""
|
|
374
|
+
feature_path = repo_root / "kitty-specs" / feature
|
|
375
|
+
tasks_root = feature_path / "tasks"
|
|
376
|
+
if not tasks_root.exists():
|
|
377
|
+
raise TaskCliError(f"Feature '{feature}' has no tasks directory at {tasks_root}.")
|
|
378
|
+
|
|
379
|
+
# Use exact WP ID matching with word boundary to avoid WP04 matching WP04b
|
|
380
|
+
# Matches: WP04.md, WP04-something.md, WP04_something.md
|
|
381
|
+
# Does NOT match: WP04b.md, WP04b-something.md
|
|
382
|
+
wp_pattern = re.compile(rf"^{re.escape(wp_id)}(?:[-_.]|\.md$)")
|
|
383
|
+
|
|
384
|
+
use_legacy = is_legacy_format(feature_path)
|
|
385
|
+
candidates = []
|
|
386
|
+
|
|
387
|
+
if use_legacy:
|
|
388
|
+
# Legacy format: search lane subdirectories
|
|
389
|
+
for lane_dir in tasks_root.iterdir():
|
|
390
|
+
if not lane_dir.is_dir():
|
|
391
|
+
continue
|
|
392
|
+
lane = lane_dir.name
|
|
393
|
+
for path in lane_dir.rglob("*.md"):
|
|
394
|
+
if wp_pattern.match(path.name):
|
|
395
|
+
candidates.append((lane, path, lane_dir))
|
|
396
|
+
else:
|
|
397
|
+
# New format: search flat tasks/ directory
|
|
398
|
+
for path in tasks_root.glob("*.md"):
|
|
399
|
+
if path.name.lower() == "readme.md":
|
|
400
|
+
continue
|
|
401
|
+
if wp_pattern.match(path.name):
|
|
402
|
+
# Get lane from frontmatter
|
|
403
|
+
lane = get_lane_from_frontmatter(path, warn_on_missing=False)
|
|
404
|
+
candidates.append((lane, path, tasks_root))
|
|
405
|
+
|
|
406
|
+
if not candidates:
|
|
407
|
+
raise TaskCliError(f"Work package '{wp_id}' not found under kitty-specs/{feature}/tasks.")
|
|
408
|
+
if len(candidates) > 1:
|
|
409
|
+
joined = "\n".join(str(item[1].relative_to(repo_root)) for item in candidates)
|
|
410
|
+
raise TaskCliError(
|
|
411
|
+
f"Multiple files matched '{wp_id}'. Refine the ID or clean duplicates:\n{joined}"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
lane, path, base_dir = candidates[0]
|
|
415
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
416
|
+
front, body, padding = split_frontmatter(text)
|
|
417
|
+
relative = path.relative_to(base_dir)
|
|
418
|
+
return WorkPackage(
|
|
419
|
+
feature=feature,
|
|
420
|
+
path=path,
|
|
421
|
+
current_lane=lane,
|
|
422
|
+
relative_subpath=relative,
|
|
423
|
+
frontmatter=front,
|
|
424
|
+
body=body,
|
|
425
|
+
padding=padding,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def load_meta(meta_path: Path) -> Dict:
|
|
430
|
+
if not meta_path.exists():
|
|
431
|
+
raise TaskCliError(f"Meta file not found at {meta_path}")
|
|
432
|
+
return json.loads(meta_path.read_text(encoding="utf-8-sig"))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def get_lane_from_frontmatter(wp_path: Path, warn_on_missing: bool = True) -> str:
|
|
436
|
+
"""Extract lane from WP file frontmatter.
|
|
437
|
+
|
|
438
|
+
This is the authoritative way to determine a work package's lane
|
|
439
|
+
in the frontmatter-only lane system.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
wp_path: Path to the work package markdown file
|
|
443
|
+
warn_on_missing: If True, print warning when lane field is missing
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Lane value (planned, doing, for_review, done)
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
ValueError: If lane value is not in LANES
|
|
450
|
+
"""
|
|
451
|
+
content = wp_path.read_text(encoding="utf-8-sig")
|
|
452
|
+
frontmatter, _, _ = split_frontmatter(content)
|
|
453
|
+
|
|
454
|
+
lane = extract_scalar(frontmatter, "lane")
|
|
455
|
+
|
|
456
|
+
if lane is None:
|
|
457
|
+
if warn_on_missing:
|
|
458
|
+
# Import here to avoid circular dependency issues
|
|
459
|
+
try:
|
|
460
|
+
from rich.console import Console
|
|
461
|
+
console = Console(stderr=True)
|
|
462
|
+
console.print(
|
|
463
|
+
f"[yellow]Warning: {wp_path.name} missing lane field, "
|
|
464
|
+
f"defaulting to 'planned'[/yellow]"
|
|
465
|
+
)
|
|
466
|
+
except ImportError:
|
|
467
|
+
import sys
|
|
468
|
+
print(
|
|
469
|
+
f"Warning: {wp_path.name} missing lane field, defaulting to 'planned'",
|
|
470
|
+
file=sys.stderr
|
|
471
|
+
)
|
|
472
|
+
return "planned"
|
|
473
|
+
|
|
474
|
+
if lane not in LANES:
|
|
475
|
+
raise ValueError(
|
|
476
|
+
f"Invalid lane '{lane}' in {wp_path.name}. "
|
|
477
|
+
f"Valid lanes: {', '.join(LANES)}"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return lane
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
__all__ = [
|
|
484
|
+
"LANES",
|
|
485
|
+
"TIMESTAMP_FORMAT",
|
|
486
|
+
"TaskCliError",
|
|
487
|
+
"WorkPackage",
|
|
488
|
+
"append_activity_log",
|
|
489
|
+
"activity_entries",
|
|
490
|
+
"build_document",
|
|
491
|
+
"detect_conflicting_wp_status",
|
|
492
|
+
"ensure_lane",
|
|
493
|
+
"extract_scalar",
|
|
494
|
+
"find_repo_root",
|
|
495
|
+
"get_lane_from_frontmatter",
|
|
496
|
+
"git_status_lines",
|
|
497
|
+
"is_legacy_format",
|
|
498
|
+
"load_meta",
|
|
499
|
+
"locate_work_package",
|
|
500
|
+
"normalize_note",
|
|
501
|
+
"now_utc",
|
|
502
|
+
"path_has_changes",
|
|
503
|
+
"run_git",
|
|
504
|
+
"set_scalar",
|
|
505
|
+
"split_frontmatter",
|
|
506
|
+
]
|