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.
|
||||
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 sys
|
||||
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"""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"sprint-status.yaml not found at: {path}")
|
||||
|
||||
lines = path.read_text().split('\n')
|
||||
|
||||
statuses = {}
|
||||
in_dev_status = False
|
||||
|
|
@ -34,6 +120,7 @@ def load_sprint_status(path: str = "docs/sprint-artifacts/sprint-status.yaml") -
|
|||
|
||||
return statuses
|
||||
|
||||
|
||||
def add_status_to_story(story_file: Path, status: str) -> bool:
|
||||
"""Add Status field to story file if missing"""
|
||||
content = story_file.read_text()
|
||||
|
|
@ -70,10 +157,48 @@ def add_status_to_story(story_file: Path, status: str) -> bool:
|
|||
story_file.write_text('\n'.join(lines))
|
||||
return True
|
||||
|
||||
def main():
|
||||
story_dir = Path("docs/sprint-artifacts")
|
||||
statuses = load_sprint_status()
|
||||
|
||||
def main():
|
||||
"""Main entry point for CLI usage"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Add Status field to story files that are missing it',
|
||||
epilog='Path arguments are optional - script auto-detects locations. Use --story-dir and --sprint-status to override.'
|
||||
)
|
||||
parser.add_argument('--story-dir', default=None,
|
||||
help='Path to story files directory (auto-detected if omitted)')
|
||||
parser.add_argument('--sprint-status', default=None,
|
||||
help='Path to sprint-status.yaml (auto-detected if omitted)')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Auto-detect paths if not provided
|
||||
project_root = find_project_root()
|
||||
print(f"📁 Project root: {project_root}", file=sys.stderr)
|
||||
|
||||
if args.story_dir:
|
||||
story_dir = Path(args.story_dir).resolve()
|
||||
else:
|
||||
story_dir = find_story_dir(project_root)
|
||||
|
||||
if args.sprint_status:
|
||||
sprint_status_path = Path(args.sprint_status).resolve()
|
||||
else:
|
||||
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
|
||||
|
|
@ -87,26 +212,42 @@ def main():
|
|||
'COMPLETION' in story_id.upper() or
|
||||
'SUMMARY' in story_id.upper() or
|
||||
'REPORT' in story_id.upper() or
|
||||
'README' in story_id.upper()):
|
||||
'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")
|
||||
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}")
|
||||
print(f"✓ {story_id}: Added Status: {status}", file=sys.stderr)
|
||||
added += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
print()
|
||||
print(f"✅ Added Status field to {added} stories")
|
||||
print(f"ℹ️ Skipped {skipped} stories (already have Status)")
|
||||
print(f"⚠️ {missing} stories not in sprint-status.yaml")
|
||||
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__':
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
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:
|
||||
- Comments
|
||||
|
|
@ -8,14 +9,21 @@ Purpose: Update sprint-status.yaml entries while preserving:
|
|||
- Section structure
|
||||
- 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
|
||||
Part of: Full Workflow Fix (Option C)
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
|
@ -23,7 +31,11 @@ class SprintStatusUpdater:
|
|||
"""Updates sprint-status.yaml while preserving structure and comments"""
|
||||
|
||||
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.lines = self.content.split('\n')
|
||||
self.updates_applied = 0
|
||||
|
|
@ -211,7 +223,82 @@ class SprintStatusUpdater:
|
|||
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
|
||||
|
||||
|
|
@ -222,7 +309,11 @@ def scan_story_statuses(story_dir: str = "docs/sprint-artifacts") -> Dict[str, s
|
|||
Returns:
|
||||
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"))
|
||||
|
||||
STATUS_MAPPINGS = {
|
||||
|
|
@ -312,21 +403,43 @@ def main():
|
|||
"""Main entry point for CLI usage"""
|
||||
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('--validate', action='store_true', help='Validate only (exit 1 if discrepancies)')
|
||||
parser.add_argument('--sprint-status', default='docs/sprint-artifacts/sprint-status.yaml',
|
||||
help='Path to sprint-status.yaml')
|
||||
parser.add_argument('--story-dir', default='docs/sprint-artifacts',
|
||||
help='Path to story files directory')
|
||||
parser.add_argument('--sprint-status', default=None,
|
||||
help='Path to sprint-status.yaml (auto-detected if omitted)')
|
||||
parser.add_argument('--story-dir', default=None,
|
||||
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('--mode', choices=['validate', 'fix'], default='validate',
|
||||
help='Mode: validate (report only) or fix (apply updates)')
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Auto-detect paths if not provided
|
||||
project_root = find_project_root()
|
||||
print(f"📁 Project root: {project_root}", file=sys.stderr)
|
||||
|
||||
if args.story_dir:
|
||||
story_dir = Path(args.story_dir).resolve()
|
||||
else:
|
||||
story_dir = find_story_dir(project_root)
|
||||
|
||||
if args.sprint_status:
|
||||
sprint_status_path = Path(args.sprint_status).resolve()
|
||||
else:
|
||||
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)
|
||||
|
||||
# Scan story files
|
||||
print("Scanning story files...", file=sys.stderr)
|
||||
story_statuses = scan_story_statuses(args.story_dir)
|
||||
story_statuses = scan_story_statuses(story_dir)
|
||||
|
||||
# Filter by epic if specified
|
||||
if args.epic:
|
||||
|
|
@ -345,7 +458,7 @@ def main():
|
|||
print("", file=sys.stderr)
|
||||
|
||||
# Load sprint-status.yaml
|
||||
updater = SprintStatusUpdater(args.sprint_status)
|
||||
updater = SprintStatusUpdater(str(sprint_status_path))
|
||||
|
||||
# Find discrepancies
|
||||
discrepancies = []
|
||||
|
|
@ -416,6 +529,15 @@ def main():
|
|||
print(f"✓ Updated: {updater.path}", 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__':
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -401,7 +401,14 @@ done
|
|||
|
||||
**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
|
||||
|
||||
|
|
@ -425,11 +432,42 @@ Estimated time: {estimated_hours} hours (with batching)
|
|||
[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
|
||||
## Smart Batching Plan
|
||||
|
|
@ -457,7 +495,9 @@ Add batching plan to story file:
|
|||
- 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:
|
||||
- Add `2` to `stepsCompleted`
|
||||
|
|
@ -474,7 +514,7 @@ Update state file:
|
|||
tasks_added: {count}
|
||||
|
||||
smart_batching:
|
||||
enabled: true
|
||||
enabled: {true if time_saved > 0, false otherwise}
|
||||
patterns_detected: {count}
|
||||
batchable_tasks: {count}
|
||||
individual_tasks: {count}
|
||||
|
|
@ -483,9 +523,12 @@ Update state file:
|
|||
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
|
||||
|
||||
|
|
@ -509,6 +552,26 @@ Time Estimate:
|
|||
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:**
|
||||
```
|
||||
[C] Continue to Implementation
|
||||
|
|
|
|||
Loading…
Reference in New Issue