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:
Jonah Schulte 2026-01-06 08:21:44 -05:00
parent 4ea18e97fb
commit 1ddf2afcea
3 changed files with 465 additions and 139 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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