feat: enhance sprint scripts with smart path resolution and conditional batching
- Add auto-detection of project root and story directories - Enable scripts to work from any working directory - Add explicit path override options via CLI arguments - Improve error messages with suggested paths when files not found - Update smart batching to show options only when time_saved > 0 - Reduce decision fatigue by skipping batching menu when no benefit
This commit is contained in:
parent
4ea18e97fb
commit
1ddf2afcea
|
|
@ -2,16 +2,102 @@
|
||||||
"""
|
"""
|
||||||
Add Status field to story files that are missing it.
|
Add Status field to story files that are missing it.
|
||||||
Uses sprint-status.yaml as source of truth.
|
Uses sprint-status.yaml as source of truth.
|
||||||
|
Smart path resolution - finds files regardless of working directory.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Auto-detects project root and file locations
|
||||||
|
- Works from any working directory
|
||||||
|
- Explicit path overrides via CLI arguments
|
||||||
|
- Clear error messages if files not found
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
def load_sprint_status(path: str = "docs/sprint-artifacts/sprint-status.yaml") -> Dict[str, str]:
|
|
||||||
|
def find_project_root() -> Path:
|
||||||
|
"""
|
||||||
|
Find project root by looking for .git directory or other markers.
|
||||||
|
Works from any subdirectory.
|
||||||
|
"""
|
||||||
|
current = Path.cwd()
|
||||||
|
|
||||||
|
# Try up to 10 levels up
|
||||||
|
for _ in range(10):
|
||||||
|
if (current / '.git').exists():
|
||||||
|
return current
|
||||||
|
if (current / '.claude').exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
if current == current.parent: # Reached filesystem root
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to current working directory
|
||||||
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def find_story_dir(project_root: Optional[Path] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Auto-detect story directory location.
|
||||||
|
Tries multiple possible paths and returns the first one that exists.
|
||||||
|
"""
|
||||||
|
if project_root is None:
|
||||||
|
project_root = find_project_root()
|
||||||
|
|
||||||
|
# Try paths in order of preference
|
||||||
|
candidates = [
|
||||||
|
project_root / "_bmad-output" / "implementation-artifacts" / "sprint-artifacts",
|
||||||
|
project_root / "docs" / "sprint-artifacts",
|
||||||
|
project_root / "sprint-artifacts",
|
||||||
|
Path.cwd() / "sprint-artifacts",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# If none found, suggest the most likely paths
|
||||||
|
print(f"ERROR: Could not find story directory.", file=sys.stderr)
|
||||||
|
print(f"Tried:", file=sys.stderr)
|
||||||
|
for candidate in candidates:
|
||||||
|
print(f" - {candidate}", file=sys.stderr)
|
||||||
|
raise FileNotFoundError("Story directory not found")
|
||||||
|
|
||||||
|
|
||||||
|
def find_sprint_status(project_root: Optional[Path] = None, story_dir: Optional[Path] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Auto-detect sprint-status.yaml location.
|
||||||
|
Looks in the story directory or its parent.
|
||||||
|
"""
|
||||||
|
if story_dir is None:
|
||||||
|
story_dir = find_story_dir(project_root)
|
||||||
|
|
||||||
|
# Try paths in order of preference
|
||||||
|
candidates = [
|
||||||
|
story_dir / "sprint-status.yaml",
|
||||||
|
story_dir.parent / "sprint-status.yaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# If none found, suggest the most likely paths
|
||||||
|
print(f"ERROR: Could not find sprint-status.yaml", file=sys.stderr)
|
||||||
|
print(f"Tried:", file=sys.stderr)
|
||||||
|
for candidate in candidates:
|
||||||
|
print(f" - {candidate}", file=sys.stderr)
|
||||||
|
raise FileNotFoundError("sprint-status.yaml not found")
|
||||||
|
|
||||||
|
|
||||||
|
def load_sprint_status(path: Path) -> Dict[str, str]:
|
||||||
"""Load story statuses from sprint-status.yaml"""
|
"""Load story statuses from sprint-status.yaml"""
|
||||||
with open(path) as f:
|
if not path.exists():
|
||||||
lines = f.readlines()
|
raise FileNotFoundError(f"sprint-status.yaml not found at: {path}")
|
||||||
|
|
||||||
|
lines = path.read_text().split('\n')
|
||||||
|
|
||||||
statuses = {}
|
statuses = {}
|
||||||
in_dev_status = False
|
in_dev_status = False
|
||||||
|
|
@ -34,6 +120,7 @@ def load_sprint_status(path: str = "docs/sprint-artifacts/sprint-status.yaml") -
|
||||||
|
|
||||||
return statuses
|
return statuses
|
||||||
|
|
||||||
|
|
||||||
def add_status_to_story(story_file: Path, status: str) -> bool:
|
def add_status_to_story(story_file: Path, status: str) -> bool:
|
||||||
"""Add Status field to story file if missing"""
|
"""Add Status field to story file if missing"""
|
||||||
content = story_file.read_text()
|
content = story_file.read_text()
|
||||||
|
|
@ -41,11 +128,11 @@ def add_status_to_story(story_file: Path, status: str) -> bool:
|
||||||
# Check if Status field already exists (handles both "Status:" and "**Status:**")
|
# Check if Status field already exists (handles both "Status:" and "**Status:**")
|
||||||
if re.search(r'^\*?\*?Status:', content, re.MULTILINE | re.IGNORECASE):
|
if re.search(r'^\*?\*?Status:', content, re.MULTILINE | re.IGNORECASE):
|
||||||
return False # Already has Status field
|
return False # Already has Status field
|
||||||
|
|
||||||
# Find the first section after the title (usually ## Story or ## Description)
|
# Find the first section after the title (usually ## Story or ## Description)
|
||||||
# Insert Status field before that
|
# Insert Status field before that
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
|
|
||||||
# Find insertion point (after title, before first ## section)
|
# Find insertion point (after title, before first ## section)
|
||||||
insert_idx = None
|
insert_idx = None
|
||||||
for idx, line in enumerate(lines):
|
for idx, line in enumerate(lines):
|
||||||
|
|
@ -56,57 +143,111 @@ def add_status_to_story(story_file: Path, status: str) -> bool:
|
||||||
# Found first section - insert before it
|
# Found first section - insert before it
|
||||||
insert_idx = idx
|
insert_idx = idx
|
||||||
break
|
break
|
||||||
|
|
||||||
if insert_idx is None:
|
if insert_idx is None:
|
||||||
# No ## sections found, insert after title
|
# No ## sections found, insert after title
|
||||||
insert_idx = 1
|
insert_idx = 1
|
||||||
|
|
||||||
# Insert blank line, Status field, blank line
|
# Insert blank line, Status field, blank line
|
||||||
lines.insert(insert_idx, '')
|
lines.insert(insert_idx, '')
|
||||||
lines.insert(insert_idx + 1, f'**Status:** {status}')
|
lines.insert(insert_idx + 1, f'**Status:** {status}')
|
||||||
lines.insert(insert_idx + 2, '')
|
lines.insert(insert_idx + 2, '')
|
||||||
|
|
||||||
# Write back
|
# Write back
|
||||||
story_file.write_text('\n'.join(lines))
|
story_file.write_text('\n'.join(lines))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
story_dir = Path("docs/sprint-artifacts")
|
"""Main entry point for CLI usage"""
|
||||||
statuses = load_sprint_status()
|
import argparse
|
||||||
|
|
||||||
added = 0
|
parser = argparse.ArgumentParser(
|
||||||
skipped = 0
|
description='Add Status field to story files that are missing it',
|
||||||
missing = 0
|
epilog='Path arguments are optional - script auto-detects locations. Use --story-dir and --sprint-status to override.'
|
||||||
|
)
|
||||||
for story_file in sorted(story_dir.glob("*.md")):
|
parser.add_argument('--story-dir', default=None,
|
||||||
story_id = story_file.stem
|
help='Path to story files directory (auto-detected if omitted)')
|
||||||
|
parser.add_argument('--sprint-status', default=None,
|
||||||
# Skip special files
|
help='Path to sprint-status.yaml (auto-detected if omitted)')
|
||||||
if (story_id.startswith('.') or
|
args = parser.parse_args()
|
||||||
story_id.startswith('EPIC-') or
|
|
||||||
'COMPLETION' in story_id.upper() or
|
try:
|
||||||
'SUMMARY' in story_id.upper() or
|
# Auto-detect paths if not provided
|
||||||
'REPORT' in story_id.upper() or
|
project_root = find_project_root()
|
||||||
'README' in story_id.upper()):
|
print(f"📁 Project root: {project_root}", file=sys.stderr)
|
||||||
continue
|
|
||||||
|
if args.story_dir:
|
||||||
if story_id not in statuses:
|
story_dir = Path(args.story_dir).resolve()
|
||||||
print(f"⚠️ {story_id}: Not in sprint-status.yaml")
|
|
||||||
missing += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
status = statuses[story_id]
|
|
||||||
|
|
||||||
if add_status_to_story(story_file, status):
|
|
||||||
print(f"✓ {story_id}: Added Status: {status}")
|
|
||||||
added += 1
|
|
||||||
else:
|
else:
|
||||||
skipped += 1
|
story_dir = find_story_dir(project_root)
|
||||||
|
|
||||||
print()
|
if args.sprint_status:
|
||||||
print(f"✅ Added Status field to {added} stories")
|
sprint_status_path = Path(args.sprint_status).resolve()
|
||||||
print(f"ℹ️ Skipped {skipped} stories (already have Status)")
|
else:
|
||||||
print(f"⚠️ {missing} stories not in sprint-status.yaml")
|
sprint_status_path = find_sprint_status(project_root, story_dir)
|
||||||
|
|
||||||
|
print(f"📖 Story directory: {story_dir}", file=sys.stderr)
|
||||||
|
print(f"📋 Sprint status file: {sprint_status_path}", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Load statuses from sprint-status.yaml
|
||||||
|
print("Loading sprint-status.yaml...", file=sys.stderr)
|
||||||
|
statuses = load_sprint_status(sprint_status_path)
|
||||||
|
print(f"✓ Found {len(statuses)} entries in sprint-status.yaml", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Scan story files and add Status fields
|
||||||
|
print("Scanning story files...", file=sys.stderr)
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
missing = 0
|
||||||
|
|
||||||
|
for story_file in sorted(story_dir.glob("*.md")):
|
||||||
|
story_id = story_file.stem
|
||||||
|
|
||||||
|
# Skip special files
|
||||||
|
if (story_id.startswith('.') or
|
||||||
|
story_id.startswith('EPIC-') or
|
||||||
|
'COMPLETION' in story_id.upper() or
|
||||||
|
'SUMMARY' in story_id.upper() or
|
||||||
|
'REPORT' in story_id.upper() or
|
||||||
|
'README' in story_id.upper() or
|
||||||
|
'INDEX' in story_id.upper() or
|
||||||
|
'REVIEW' in story_id.upper() or
|
||||||
|
'AUDIT' in story_id.upper()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if story_id not in statuses:
|
||||||
|
print(f"⚠️ {story_id}: Not in sprint-status.yaml", file=sys.stderr)
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = statuses[story_id]
|
||||||
|
|
||||||
|
if add_status_to_story(story_file, status):
|
||||||
|
print(f"✓ {story_id}: Added Status: {status}", file=sys.stderr)
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
print(f"✅ Added Status field to {added} stories", file=sys.stderr)
|
||||||
|
print(f"ℹ️ Skipped {skipped} stories (already have Status)", file=sys.stderr)
|
||||||
|
if missing > 0:
|
||||||
|
print(f"⚠️ {missing} stories not in sprint-status.yaml", file=sys.stderr)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
print(f"✗ {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Sprint Status Updater - Robust YAML updater for sprint-status.yaml
|
Sprint Status Updater - Robust YAML updater for sprint-status.yaml
|
||||||
|
Smart path resolution - finds files regardless of working directory
|
||||||
|
|
||||||
Purpose: Update sprint-status.yaml entries while preserving:
|
Purpose: Update sprint-status.yaml entries while preserving:
|
||||||
- Comments
|
- Comments
|
||||||
|
|
@ -8,14 +9,21 @@ Purpose: Update sprint-status.yaml entries while preserving:
|
||||||
- Section structure
|
- Section structure
|
||||||
- Manual annotations
|
- Manual annotations
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Auto-detects project root and story directory
|
||||||
|
- Works from any working directory
|
||||||
|
- Explicit path overrides via CLI arguments
|
||||||
|
- Clear error messages if files not found
|
||||||
|
|
||||||
Created: 2026-01-02
|
Created: 2026-01-02
|
||||||
Part of: Full Workflow Fix (Option C)
|
Part of: Full Workflow Fix (Option C)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,7 +31,11 @@ class SprintStatusUpdater:
|
||||||
"""Updates sprint-status.yaml while preserving structure and comments"""
|
"""Updates sprint-status.yaml while preserving structure and comments"""
|
||||||
|
|
||||||
def __init__(self, sprint_status_path: str):
|
def __init__(self, sprint_status_path: str):
|
||||||
self.path = Path(sprint_status_path)
|
self.path = Path(sprint_status_path).resolve()
|
||||||
|
|
||||||
|
if not self.path.exists():
|
||||||
|
raise FileNotFoundError(f"sprint-status.yaml not found at: {self.path}")
|
||||||
|
|
||||||
self.content = self.path.read_text()
|
self.content = self.path.read_text()
|
||||||
self.lines = self.content.split('\n')
|
self.lines = self.content.split('\n')
|
||||||
self.updates_applied = 0
|
self.updates_applied = 0
|
||||||
|
|
@ -211,7 +223,82 @@ class SprintStatusUpdater:
|
||||||
return self.path
|
return self.path
|
||||||
|
|
||||||
|
|
||||||
def scan_story_statuses(story_dir: str = "docs/sprint-artifacts") -> Dict[str, str]:
|
def find_project_root() -> Path:
|
||||||
|
"""
|
||||||
|
Find project root by looking for .git directory or other markers.
|
||||||
|
Works from any subdirectory.
|
||||||
|
"""
|
||||||
|
current = Path.cwd()
|
||||||
|
|
||||||
|
# Try up to 10 levels up
|
||||||
|
for _ in range(10):
|
||||||
|
if (current / '.git').exists():
|
||||||
|
return current
|
||||||
|
if (current / '.claude').exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
if current == current.parent: # Reached filesystem root
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to current working directory
|
||||||
|
return Path.cwd()
|
||||||
|
|
||||||
|
|
||||||
|
def find_story_dir(project_root: Optional[Path] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Auto-detect story directory location.
|
||||||
|
Tries multiple possible paths and returns the first one that exists.
|
||||||
|
"""
|
||||||
|
if project_root is None:
|
||||||
|
project_root = find_project_root()
|
||||||
|
|
||||||
|
# Try paths in order of preference
|
||||||
|
candidates = [
|
||||||
|
project_root / "_bmad-output" / "implementation-artifacts" / "sprint-artifacts",
|
||||||
|
project_root / "docs" / "sprint-artifacts",
|
||||||
|
project_root / "sprint-artifacts",
|
||||||
|
Path.cwd() / "sprint-artifacts",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# If none found, suggest the most likely paths
|
||||||
|
print(f"ERROR: Could not find story directory.", file=sys.stderr)
|
||||||
|
print(f"Tried:", file=sys.stderr)
|
||||||
|
for candidate in candidates:
|
||||||
|
print(f" - {candidate}", file=sys.stderr)
|
||||||
|
raise FileNotFoundError("Story directory not found")
|
||||||
|
|
||||||
|
|
||||||
|
def find_sprint_status(project_root: Optional[Path] = None, story_dir: Optional[Path] = None) -> Path:
|
||||||
|
"""
|
||||||
|
Auto-detect sprint-status.yaml location.
|
||||||
|
Looks in the story directory or its parent.
|
||||||
|
"""
|
||||||
|
if story_dir is None:
|
||||||
|
story_dir = find_story_dir(project_root)
|
||||||
|
|
||||||
|
# Try paths in order of preference
|
||||||
|
candidates = [
|
||||||
|
story_dir / "sprint-status.yaml",
|
||||||
|
story_dir.parent / "sprint-status.yaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# If none found, suggest the most likely paths
|
||||||
|
print(f"ERROR: Could not find sprint-status.yaml", file=sys.stderr)
|
||||||
|
print(f"Tried:", file=sys.stderr)
|
||||||
|
for candidate in candidates:
|
||||||
|
print(f" - {candidate}", file=sys.stderr)
|
||||||
|
raise FileNotFoundError("sprint-status.yaml not found")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_story_statuses(story_dir: Path) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Scan all story files and extract EXPLICIT Status: fields
|
Scan all story files and extract EXPLICIT Status: fields
|
||||||
|
|
||||||
|
|
@ -222,7 +309,11 @@ def scan_story_statuses(story_dir: str = "docs/sprint-artifacts") -> Dict[str, s
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping story_id -> normalized_status (ONLY for stories with explicit Status: field)
|
Dict mapping story_id -> normalized_status (ONLY for stories with explicit Status: field)
|
||||||
"""
|
"""
|
||||||
story_dir_path = Path(story_dir)
|
story_dir_path = Path(story_dir).resolve()
|
||||||
|
|
||||||
|
if not story_dir_path.exists():
|
||||||
|
raise FileNotFoundError(f"Story directory not found: {story_dir_path}")
|
||||||
|
|
||||||
story_files = list(story_dir_path.glob("*.md"))
|
story_files = list(story_dir_path.glob("*.md"))
|
||||||
|
|
||||||
STATUS_MAPPINGS = {
|
STATUS_MAPPINGS = {
|
||||||
|
|
@ -312,109 +403,140 @@ def main():
|
||||||
"""Main entry point for CLI usage"""
|
"""Main entry point for CLI usage"""
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Update sprint-status.yaml from story files')
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Update sprint-status.yaml from story files',
|
||||||
|
epilog='Path arguments are optional - script auto-detects locations. Use --sprint-status and --story-dir to override.'
|
||||||
|
)
|
||||||
parser.add_argument('--dry-run', action='store_true', help='Show changes without applying')
|
parser.add_argument('--dry-run', action='store_true', help='Show changes without applying')
|
||||||
parser.add_argument('--validate', action='store_true', help='Validate only (exit 1 if discrepancies)')
|
parser.add_argument('--validate', action='store_true', help='Validate only (exit 1 if discrepancies)')
|
||||||
parser.add_argument('--sprint-status', default='docs/sprint-artifacts/sprint-status.yaml',
|
parser.add_argument('--sprint-status', default=None,
|
||||||
help='Path to sprint-status.yaml')
|
help='Path to sprint-status.yaml (auto-detected if omitted)')
|
||||||
parser.add_argument('--story-dir', default='docs/sprint-artifacts',
|
parser.add_argument('--story-dir', default=None,
|
||||||
help='Path to story files directory')
|
help='Path to story files directory (auto-detected if omitted)')
|
||||||
parser.add_argument('--epic', type=str, help='Validate specific epic only (e.g., epic-1)')
|
parser.add_argument('--epic', type=str, help='Validate specific epic only (e.g., epic-1)')
|
||||||
parser.add_argument('--mode', choices=['validate', 'fix'], default='validate',
|
parser.add_argument('--mode', choices=['validate', 'fix'], default='validate',
|
||||||
help='Mode: validate (report only) or fix (apply updates)')
|
help='Mode: validate (report only) or fix (apply updates)')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Scan story files
|
try:
|
||||||
print("Scanning story files...", file=sys.stderr)
|
# Auto-detect paths if not provided
|
||||||
story_statuses = scan_story_statuses(args.story_dir)
|
project_root = find_project_root()
|
||||||
|
print(f"📁 Project root: {project_root}", file=sys.stderr)
|
||||||
|
|
||||||
# Filter by epic if specified
|
if args.story_dir:
|
||||||
if args.epic:
|
story_dir = Path(args.story_dir).resolve()
|
||||||
# Extract epic number from epic key (e.g., "epic-1" -> "1")
|
|
||||||
epic_match = re.match(r'epic-([0-9a-z-]+)', args.epic)
|
|
||||||
if epic_match:
|
|
||||||
epic_num = epic_match.group(1)
|
|
||||||
# Filter stories that start with this epic number
|
|
||||||
story_statuses = {k: v for k, v in story_statuses.items()
|
|
||||||
if k.startswith(f"{epic_num}-")}
|
|
||||||
print(f"✓ Filtered to {len(story_statuses)} stories for {args.epic}", file=sys.stderr)
|
|
||||||
else:
|
else:
|
||||||
print(f"WARNING: Invalid epic format: {args.epic}", file=sys.stderr)
|
story_dir = find_story_dir(project_root)
|
||||||
|
|
||||||
print(f"✓ Scanned {len(story_statuses)} story files", file=sys.stderr)
|
if args.sprint_status:
|
||||||
print("", file=sys.stderr)
|
sprint_status_path = Path(args.sprint_status).resolve()
|
||||||
|
else:
|
||||||
|
sprint_status_path = find_sprint_status(project_root, story_dir)
|
||||||
|
|
||||||
# Load sprint-status.yaml
|
print(f"📖 Story directory: {story_dir}", file=sys.stderr)
|
||||||
updater = SprintStatusUpdater(args.sprint_status)
|
print(f"📋 Sprint status file: {sprint_status_path}", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
# Find discrepancies
|
# Scan story files
|
||||||
discrepancies = []
|
print("Scanning story files...", file=sys.stderr)
|
||||||
|
story_statuses = scan_story_statuses(story_dir)
|
||||||
|
|
||||||
for story_id, new_status in story_statuses.items():
|
# Filter by epic if specified
|
||||||
# Check current status in sprint-status.yaml
|
if args.epic:
|
||||||
current_status = None
|
# Extract epic number from epic key (e.g., "epic-1" -> "1")
|
||||||
in_dev_status = False
|
epic_match = re.match(r'epic-([0-9a-z-]+)', args.epic)
|
||||||
|
if epic_match:
|
||||||
|
epic_num = epic_match.group(1)
|
||||||
|
# Filter stories that start with this epic number
|
||||||
|
story_statuses = {k: v for k, v in story_statuses.items()
|
||||||
|
if k.startswith(f"{epic_num}-")}
|
||||||
|
print(f"✓ Filtered to {len(story_statuses)} stories for {args.epic}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"WARNING: Invalid epic format: {args.epic}", file=sys.stderr)
|
||||||
|
|
||||||
for line in updater.lines:
|
print(f"✓ Scanned {len(story_statuses)} story files", file=sys.stderr)
|
||||||
if line.strip() == 'development_status:':
|
print("", file=sys.stderr)
|
||||||
in_dev_status = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_dev_status and story_id in line:
|
# Load sprint-status.yaml
|
||||||
match = re.match(r'\s+[a-z0-9-]+:\s*(\S+)', line)
|
updater = SprintStatusUpdater(str(sprint_status_path))
|
||||||
if match:
|
|
||||||
current_status = match.group(1)
|
|
||||||
break
|
|
||||||
|
|
||||||
if current_status is None:
|
# Find discrepancies
|
||||||
discrepancies.append((story_id, 'NOT-IN-FILE', new_status))
|
discrepancies = []
|
||||||
elif current_status != new_status:
|
|
||||||
discrepancies.append((story_id, current_status, new_status))
|
|
||||||
|
|
||||||
# Report
|
for story_id, new_status in story_statuses.items():
|
||||||
if not discrepancies:
|
# Check current status in sprint-status.yaml
|
||||||
print("✓ sprint-status.yaml is up to date!", file=sys.stderr)
|
current_status = None
|
||||||
|
in_dev_status = False
|
||||||
|
|
||||||
|
for line in updater.lines:
|
||||||
|
if line.strip() == 'development_status:':
|
||||||
|
in_dev_status = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if in_dev_status and story_id in line:
|
||||||
|
match = re.match(r'\s+[a-z0-9-]+:\s*(\S+)', line)
|
||||||
|
if match:
|
||||||
|
current_status = match.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_status is None:
|
||||||
|
discrepancies.append((story_id, 'NOT-IN-FILE', new_status))
|
||||||
|
elif current_status != new_status:
|
||||||
|
discrepancies.append((story_id, current_status, new_status))
|
||||||
|
|
||||||
|
# Report
|
||||||
|
if not discrepancies:
|
||||||
|
print("✓ sprint-status.yaml is up to date!", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f"⚠ Found {len(discrepancies)} discrepancies:", file=sys.stderr)
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
for story_id, old_status, new_status in discrepancies[:20]:
|
||||||
|
if old_status == 'NOT-IN-FILE':
|
||||||
|
print(f" [ADD] {story_id}: (not in file) → {new_status}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f" [UPDATE] {story_id}: {old_status} → {new_status}", file=sys.stderr)
|
||||||
|
|
||||||
|
if len(discrepancies) > 20:
|
||||||
|
print(f" ... and {len(discrepancies) - 20} more", file=sys.stderr)
|
||||||
|
|
||||||
|
print("", file=sys.stderr)
|
||||||
|
|
||||||
|
# Handle mode parameter
|
||||||
|
if args.mode == 'validate' or args.validate:
|
||||||
|
print("✗ Validation failed - discrepancies found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("DRY RUN: Would update sprint-status.yaml", file=sys.stderr)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Apply updates (--mode fix or default behavior)
|
||||||
|
print("Applying updates...", file=sys.stderr)
|
||||||
|
|
||||||
|
for story_id, old_status, new_status in discrepancies:
|
||||||
|
comment = f"Updated {datetime.now().strftime('%Y-%m-%d')}"
|
||||||
|
updater.update_story_status(story_id, new_status, comment)
|
||||||
|
|
||||||
|
# Add verification timestamp
|
||||||
|
updater.add_verification_note()
|
||||||
|
|
||||||
|
# Save
|
||||||
|
updater.save(backup=True)
|
||||||
|
|
||||||
|
print(f"✓ Applied {updater.updates_applied} updates", file=sys.stderr)
|
||||||
|
print(f"✓ Updated: {updater.path}", file=sys.stderr)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
print(f"⚠ Found {len(discrepancies)} discrepancies:", file=sys.stderr)
|
except FileNotFoundError as e:
|
||||||
print("", file=sys.stderr)
|
print(f"✗ {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
for story_id, old_status, new_status in discrepancies[:20]:
|
except Exception as e:
|
||||||
if old_status == 'NOT-IN-FILE':
|
print(f"✗ Error: {e}", file=sys.stderr)
|
||||||
print(f" [ADD] {story_id}: (not in file) → {new_status}", file=sys.stderr)
|
import traceback
|
||||||
else:
|
traceback.print_exc()
|
||||||
print(f" [UPDATE] {story_id}: {old_status} → {new_status}", file=sys.stderr)
|
|
||||||
|
|
||||||
if len(discrepancies) > 20:
|
|
||||||
print(f" ... and {len(discrepancies) - 20} more", file=sys.stderr)
|
|
||||||
|
|
||||||
print("", file=sys.stderr)
|
|
||||||
|
|
||||||
# Handle mode parameter
|
|
||||||
if args.mode == 'validate' or args.validate:
|
|
||||||
print("✗ Validation failed - discrepancies found", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("DRY RUN: Would update sprint-status.yaml", file=sys.stderr)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Apply updates (--mode fix or default behavior)
|
|
||||||
print("Applying updates...", file=sys.stderr)
|
|
||||||
|
|
||||||
for story_id, old_status, new_status in discrepancies:
|
|
||||||
comment = f"Updated {datetime.now().strftime('%Y-%m-%d')}"
|
|
||||||
updater.update_story_status(story_id, new_status, comment)
|
|
||||||
|
|
||||||
# Add verification timestamp
|
|
||||||
updater.add_verification_note()
|
|
||||||
|
|
||||||
# Save
|
|
||||||
updater.save(backup=True)
|
|
||||||
|
|
||||||
print(f"✓ Applied {updater.updates_applied} updates", file=sys.stderr)
|
|
||||||
print(f"✓ Updated: {updater.path}", file=sys.stderr)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,14 @@ done
|
||||||
|
|
||||||
**Interactive Mode:**
|
**Interactive Mode:**
|
||||||
|
|
||||||
Display gap analysis report and batching plan:
|
Display gap analysis report with conditional batching menu.
|
||||||
|
|
||||||
|
**CRITICAL DECISION LOGIC:**
|
||||||
|
- If `batchable_count > 0 AND time_saved > 0`: Show batching options
|
||||||
|
- If `batchable_count = 0 OR time_saved = 0`: Skip batching options (no benefit)
|
||||||
|
|
||||||
|
**When Batching Has Benefit (time_saved > 0):**
|
||||||
|
|
||||||
```
|
```
|
||||||
Gap Analysis Complete + Smart Batching Plan
|
Gap Analysis Complete + Smart Batching Plan
|
||||||
|
|
||||||
|
|
@ -425,11 +432,42 @@ Estimated time: {estimated_hours} hours (with batching)
|
||||||
[H] Halt pipeline
|
[H] Halt pipeline
|
||||||
```
|
```
|
||||||
|
|
||||||
**Batch Mode:** Auto-accept changes and batching plan
|
**When Batching Has NO Benefit (time_saved = 0):**
|
||||||
|
|
||||||
### 7. Update Story File with Batching Plan
|
```
|
||||||
|
Gap Analysis Complete
|
||||||
|
|
||||||
Add batching plan to story file:
|
Task Analysis:
|
||||||
|
- {done_count} tasks already complete (will check)
|
||||||
|
- {unclear_count} tasks refined to {refined_count} specific tasks
|
||||||
|
- {missing_count} new tasks added
|
||||||
|
- {needed_count} tasks ready for implementation
|
||||||
|
|
||||||
|
Smart Batching Analysis:
|
||||||
|
- Batchable patterns detected: 0
|
||||||
|
- Tasks requiring individual execution: {work_count}
|
||||||
|
- Estimated time savings: none (tasks require individual attention)
|
||||||
|
|
||||||
|
Total work: {work_count} tasks
|
||||||
|
Estimated time: {estimated_hours} hours
|
||||||
|
|
||||||
|
[A] Accept changes
|
||||||
|
[E] Edit tasks manually
|
||||||
|
[H] Halt pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Skip Batching Option When Benefit = 0:**
|
||||||
|
- Reduces decision fatigue
|
||||||
|
- Prevents pointless "batch vs no-batch" choice when outcome is identical
|
||||||
|
- Cleaner UX when batching isn't applicable
|
||||||
|
|
||||||
|
**Batch Mode:** Auto-accept changes (batching plan applied only if benefit > 0)
|
||||||
|
|
||||||
|
### 8. Update Story File with Batching Plan (Conditional)
|
||||||
|
|
||||||
|
**ONLY add batching plan if `time_saved > 0`.**
|
||||||
|
|
||||||
|
If batching has benefit (time_saved > 0), add batching plan to story file:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Smart Batching Plan
|
## Smart Batching Plan
|
||||||
|
|
@ -457,7 +495,9 @@ Add batching plan to story file:
|
||||||
- Savings: {savings} hours
|
- Savings: {savings} hours
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. Update Pipeline State
|
If batching has NO benefit (time_saved = 0), **skip this section entirely** and just add gap analysis results.
|
||||||
|
|
||||||
|
### 9. Update Pipeline State
|
||||||
|
|
||||||
Update state file:
|
Update state file:
|
||||||
- Add `2` to `stepsCompleted`
|
- Add `2` to `stepsCompleted`
|
||||||
|
|
@ -474,7 +514,7 @@ Update state file:
|
||||||
tasks_added: {count}
|
tasks_added: {count}
|
||||||
|
|
||||||
smart_batching:
|
smart_batching:
|
||||||
enabled: true
|
enabled: {true if time_saved > 0, false otherwise}
|
||||||
patterns_detected: {count}
|
patterns_detected: {count}
|
||||||
batchable_tasks: {count}
|
batchable_tasks: {count}
|
||||||
individual_tasks: {count}
|
individual_tasks: {count}
|
||||||
|
|
@ -483,9 +523,12 @@ Update state file:
|
||||||
estimated_savings: {hours}
|
estimated_savings: {hours}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9. Present Summary with Batching Plan
|
**Note:** `smart_batching.enabled` is set to `false` when batching has no benefit, preventing unnecessary batching plan generation.
|
||||||
|
|
||||||
|
### 10. Present Summary (Conditional Format)
|
||||||
|
|
||||||
|
**When Batching Has Benefit (time_saved > 0):**
|
||||||
|
|
||||||
Display:
|
|
||||||
```
|
```
|
||||||
Pre-Gap Analysis Complete + Smart Batching Plan
|
Pre-Gap Analysis Complete + Smart Batching Plan
|
||||||
|
|
||||||
|
|
@ -509,6 +552,26 @@ Time Estimate:
|
||||||
Ready for Implementation
|
Ready for Implementation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**When Batching Has NO Benefit (time_saved = 0):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Pre-Gap Analysis Complete
|
||||||
|
|
||||||
|
Development Type: {greenfield|brownfield|hybrid}
|
||||||
|
Work Remaining: {work_count} tasks
|
||||||
|
|
||||||
|
Codebase Status:
|
||||||
|
- Existing implementations reviewed: {existing_count}
|
||||||
|
- New implementations needed: {new_count}
|
||||||
|
|
||||||
|
Smart Batching Analysis:
|
||||||
|
- Batchable patterns detected: 0
|
||||||
|
- Tasks requiring individual execution: {work_count}
|
||||||
|
- Estimated time: {estimated_hours} hours
|
||||||
|
|
||||||
|
Ready for Implementation
|
||||||
|
```
|
||||||
|
|
||||||
**Interactive Mode Menu:**
|
**Interactive Mode Menu:**
|
||||||
```
|
```
|
||||||
[C] Continue to Implementation
|
[C] Continue to Implementation
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue