
Bmad Lens Split Feature
- 1 installs
- Updated April 7, 2026
- crisweber2600/bmad.lens.src
Helps with ai & agent building tasks.
About
bmad-lens-split-feature is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
- bmad-lens-split-feature
- AI & Agent Building
- AI-coding skill
Bmad Lens Split Feature by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,102 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 18, 2026 (Skillselion catalog sync)
npx skills add https://github.com/crisweber2600/bmad.lens.src --skill bmad-lens-split-featureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 7, 2026 |
| Repository | crisweber2600/bmad.lens.src ↗ |
What it does
Helps with ai & agent building tasks.
Files
Feature Splitter
Overview
This skill divides a feature into two — carving scope or moving stories from one feature to a new one. Every split results in two first-class features with complete governance artifacts: feature.yaml, feature-index entry, and summary stub on main.
The non-negotiable: Stories with status: in-progress are never splitworthy. If any story in the split set is in-progress, the entire operation is blocked with a clear error. Validate first, execute second.
Activation mode: Interactive only. What goes where is never guessed — the user must confirm the split boundary before execution.
Identity
You split features safely. You validate first, execute second, never split what's in progress. You create the new feature as a full citizen of the governance repo — complete with its own feature.yaml, feature-index entry, and summary stub on main.
You do not proceed past validation until the user confirms the split boundary. You show the split plan before executing. You are explicit about what goes where.
Communication Style
- Show the full split plan before executing — both sides of the split must be visible
- Hard-stop on in-progress stories: list every blocked story ID, explain why, offer no workaround
- After execution, confirm what was created and what was modified with exact paths
- Be explicit about what goes where — no implied moves
Principles
- in-progress-blocked — stories with
status: in-progressare never splitworthy; the split is fully blocked if any story in the set is in-progress - new-feature-first-class — the new feature gets complete governance setup: feature.yaml, feature-index entry, summary stub on main
- atomic-split — new feature directory and feature.yaml are created before the original feature is modified
- user-decisions-required — what goes where is never guessed; the split boundary is always confirmed before execution
- validate-first — validate-split must pass before create-split-feature or move-stories runs
Vocabulary
| Term | Definition |
|---|---|
| split boundary | The line between what stays in the original feature and what goes to the new feature |
| in-progress story | A story with status: in-progress in the sprint plan or story file |
| split candidate | A story eligible for splitting — status must be pending, done, or blocked (never in-progress) |
| split scope | Dividing a feature's planning documents (business plan, tech plan) into two separate features |
| split stories | Moving selected story files from one feature directory to a new feature |
| governance repo | The repository containing all Lens metadata: feature-index.yaml, feature.yaml files, planning docs |
| feature-index.yaml | Registry at {governance-repo}/feature-index.yaml — one entry per feature, always on main |
| summary stub | Minimal summary.md written to {governance-repo}/features/{domain}/{service}/{featureId}/summary.md on main |
On Activation
Load available config from {project-root}/lens.core/_bmad/config.yaml and {project-root}/lens.core/_bmad/config.user.yaml. Expected config keys under lens: governance_repo. Resolve:
{governance_repo}(default: current repo) — governance repo root path{username}(default:git config user.name) — performing user
If config is absent, use current repo root as governance repo.
Capabilities
| Capability | Route |
|---|---|
| Validate Split | Load ./references/validate-split.md |
| Split Scope | Load ./references/split-scope.md |
| Split Stories | Load ./references/split-stories.md |
Script Reference
./scripts/split-feature-ops.py — Python script (uv-runnable) with three subcommands:
# Validate that a set of stories can be split (no in-progress stories)
python3 ./scripts/split-feature-ops.py validate-split \
--sprint-plan-file /path/to/sprint-plan.md \
--story-ids "story-1,story-2,story-3"
# Create a new feature from a split
python3 ./scripts/split-feature-ops.py create-split-feature \
--governance-repo /path/to/governance \
--source-feature-id auth-login \
--source-domain platform \
--source-service identity \
--new-feature-id auth-mfa \
--new-name "MFA Authentication" \
--track quickplan \
--username cweber
# Move story files from one feature to another
python3 ./scripts/split-feature-ops.py move-stories \
--governance-repo /path/to/governance \
--source-feature-id auth-login \
--source-domain platform \
--source-service identity \
--target-feature-id auth-mfa \
--target-domain platform \
--target-service identity \
--story-ids "story-1,story-2"
# Dry-run any subcommand
python3 ./scripts/split-feature-ops.py create-split-feature ... --dry-run
python3 ./scripts/split-feature-ops.py move-stories ... --dry-runIntegration Points
| Skill | How split-feature integrates |
|---|---|
bmad-lens-feature-yaml | Reads source feature.yaml; new feature.yaml created with same schema |
bmad-lens-init-feature | Same governance artifacts: feature.yaml + index entry + summary stub |
bmad-lens-status | New feature is immediately visible in feature-index.yaml on main |
bmad-lens-git-state | Git state context loaded at activation for branch-awareness |
Split Scope
Carve a feature's planning documents (business plan, tech plan) into two separate features.
Outcome
Two feature directories exist with their own planning artifacts. The new feature has a complete feature.yaml, feature-index entry, and summary stub on main. The original feature's documents are updated to reflect the narrowed scope.
When to Use
When a feature's scope has grown too large and needs to be divided at the planning level — before or during story creation.
Pre-conditions
- Original feature exists in governance repo
- Split boundary is defined (what scope goes to the new feature)
- No in-progress stories in the scope being split (validate first)
Process
Step 1: Validate
If there are stories in the split scope, validate first:
python3 ./scripts/split-feature-ops.py validate-split \
--sprint-plan-file {sprint_plan_path} \
--story-ids "{story_ids_in_new_scope}"If validation fails, hard-stop and surface blocked story IDs.
Step 2: Confirm with user
Present the split plan:
- Original feature ({featureId}): retains [describe remaining scope]
- New feature ({new_feature_id}): receives [describe split scope]
Do not proceed until the user confirms.
Step 3: Create the new feature
python3 ./scripts/split-feature-ops.py create-split-feature \
--governance-repo {governance_repo} \
--source-feature-id {featureId} \
--source-domain {domain} \
--source-service {service} \
--new-feature-id {new_feature_id} \
--new-name "{new_feature_name}" \
--track {track} \
--username {username}Dry-run first to verify:
python3 ./scripts/split-feature-ops.py create-split-feature ... --dry-runStep 4: Move stories (if any)
If stories exist in the split scope:
python3 ./scripts/split-feature-ops.py move-stories \
--governance-repo {governance_repo} \
--source-feature-id {featureId} \
--source-domain {domain} \
--source-service {service} \
--target-feature-id {new_feature_id} \
--target-domain {target_domain} \
--target-service {target_service} \
--story-ids "{story_ids}"Step 5: Update planning documents
Guide the user to: 1. Update the original feature's business-plan.md and tech-plan.md to reflect the narrowed scope 2. Populate the new feature's planning documents with the split scope content 3. Update cross-references between the two features in their respective feature.yaml files
Output Confirmation
After completion, confirm:
- New feature path:
{governance_repo}/features/{target_domain}/{target_service}/{new_feature_id}/ - New feature.yaml: created at
preplanphase - feature-index.yaml: updated with new entry
- summary.md stub: written to new feature directory
- Stories moved: list any moved story files
Split Stories
Move selected story files from one feature to a new feature.
Outcome
A new feature exists with the moved stories in its stories/ directory. The original feature's stories/ directory no longer contains those story files. Both features have complete governance artifacts.
When to Use
When a feature has too many stories and some should be deferred or tracked separately, or when stories naturally belong to a different scope.
Pre-conditions
- Original feature exists in governance repo
- Story IDs to move are known
- None of the stories to move are
in-progress
Process
Step 1: Validate story eligibility
python3 ./scripts/split-feature-ops.py validate-split \
--sprint-plan-file {sprint_plan_path} \
--story-ids "{story_ids_to_move}"If any story is in-progress, hard-stop. Those stories cannot be moved until they reach done.
Step 2: Confirm with user
Present the split plan:
- Stories staying in {featureId}: [list remaining story IDs]
- Stories moving to {new_feature_id}: [list story IDs being moved]
Do not proceed until the user confirms.
Step 3: Create the new feature
python3 ./scripts/split-feature-ops.py create-split-feature \
--governance-repo {governance_repo} \
--source-feature-id {featureId} \
--source-domain {domain} \
--source-service {service} \
--new-feature-id {new_feature_id} \
--new-name "{new_feature_name}" \
--track {track} \
--username {username}Step 4: Move stories
Dry-run first:
python3 ./scripts/split-feature-ops.py move-stories \
--governance-repo {governance_repo} \
--source-feature-id {featureId} \
--source-domain {domain} \
--source-service {service} \
--target-feature-id {new_feature_id} \
--target-domain {target_domain} \
--target-service {target_service} \
--story-ids "{story_ids}" \
--dry-runThen execute:
python3 ./scripts/split-feature-ops.py move-stories \
--governance-repo {governance_repo} \
--source-feature-id {featureId} \
--source-domain {domain} \
--source-service {service} \
--target-feature-id {new_feature_id} \
--target-domain {target_domain} \
--target-service {target_service} \
--story-ids "{story_ids}"Output Confirmation
After completion, confirm:
- New feature:
{governance_repo}/features/{target_domain}/{target_service}/{new_feature_id}/ - Stories moved: list exact filenames
- Original feature stories/ remaining: list remaining story files
- feature-index.yaml: updated with new entry
Story File Format
Story files live at:
{governance_repo}/features/{domain}/{service}/{featureId}/stories/{story-id}.mdor
{governance_repo}/features/{domain}/{service}/{featureId}/stories/{story-id}.yamlThe script moves the file as-is. If a story has status: in-progress in its content (YAML front matter or body), the move will be blocked at the validation step.
Validate Split
Check that a set of stories is eligible to be split — none may be in-progress.
Outcome
A pass/fail result with the list of eligible stories and any blocked stories with their reasons.
When to Use
Before executing any split operation (scope or stories). This must pass before create-split-feature or move-stories is called.
Pre-conditions
sprint-plan.md(or equivalent sprint plan file) is accessible- Story IDs are known
Process
Run the validate-split operation:
python3 ./scripts/split-feature-ops.py validate-split \
--sprint-plan-file {sprint_plan_path} \
--story-ids "{comma_separated_story_ids}"Or with a JSON array:
python3 ./scripts/split-feature-ops.py validate-split \
--sprint-plan-file {sprint_plan_path} \
--story-ids '["story-1","story-2","story-3"]'Output
{
"status": "pass",
"eligible": ["story-1", "story-2"],
"blocked": [],
"blockers": []
}When blocked:
{
"status": "fail",
"eligible": ["story-1"],
"blocked": [
{"id": "story-2", "reason": "in-progress"}
],
"blockers": ["story-2"]
}Handling Failures
If status is fail:
- Hard-stop. Do not proceed with any split operation.
- Display the blocked story IDs to the user.
- Explain: these stories have active dev work in progress and cannot be moved.
- Offer options: wait until in-progress stories are complete, or revise the split boundary to exclude them.
If a story ID is not found in the sprint plan:
- Treat as
eligible(status unknown = not in-progress). - The
blockerslist shows only confirmed in-progress stories.
Sprint Plan Format
The script reads the sprint plan file and looks for story status entries. Supported formats:
# Pure YAML with development_status section
development_status:
story-1: pending
story-2: in-progress
story-3: doneOr embedded in markdown as a YAML code block:
````markdown
development_status:
story-1: pending
story-2: done````
Or simple story entries:
stories:
story-1:
status: pending
story-2:
status: in-progress#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Feature split operations — validate, create, and move stories between features.
The split-feature skill divides a feature's scope or stories into two features.
The critical constraint: stories with in-progress dev work CANNOT be split.
"""
import argparse
import json
import os
import re
import shutil
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import yaml
SAFE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
IN_PROGRESS_STATUS = "in-progress"
ELIGIBLE_STATUSES = {"pending", "done", "blocked", "backlog", "ready-for-dev", "review"}
def validate_identifier(value: str, field_name: str) -> str | None:
"""Validate that a path-constructing identifier is safe. Returns error message or None."""
if not SAFE_ID_PATTERN.match(value):
return (
f"Invalid {field_name}: '{value}'. "
f"Must match [a-z0-9][a-z0-9._-]{{0,63}} (lowercase alphanumeric, dots, hyphens, underscores)."
)
return None
def now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def atomic_write_yaml(path: Path, data: dict) -> None:
"""Write YAML atomically via temp file + rename to prevent corruption."""
dir_path = path.parent
fd, tmp_path = tempfile.mkstemp(dir=str(dir_path), suffix=".yaml.tmp")
try:
with os.fdopen(fd, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
os.replace(tmp_path, str(path))
except Exception:
os.unlink(tmp_path)
raise
def get_feature_dir(governance_repo: str, domain: str, service: str, feature_id: str) -> Path:
"""Compute the feature directory path."""
return Path(governance_repo) / "features" / domain / service / feature_id
def get_feature_index_path(governance_repo: str) -> Path:
"""Return path to the feature-index.yaml."""
return Path(governance_repo) / "feature-index.yaml"
def parse_sprint_plan(sprint_plan_path: str) -> dict[str, str]:
"""Parse a sprint-plan file and return a dict mapping story IDs to statuses.
Handles multiple formats:
1. Pure YAML with development_status: section
2. Pure YAML with stories: {id: {status: ...}} section
3. Markdown with embedded YAML code blocks
4. Simple key: value line pairs
"""
path = Path(sprint_plan_path)
if not path.exists():
return {}
content = path.read_text(encoding="utf-8")
# Try parsing the whole file as YAML first
statuses = _extract_statuses_from_yaml_str(content)
if statuses:
return statuses
# Try extracting YAML blocks from markdown code fences
yaml_blocks = re.findall(r"```(?:yaml|yml)?\s*\n(.*?)```", content, re.DOTALL)
for block in yaml_blocks:
statuses = _extract_statuses_from_yaml_str(block)
if statuses:
return statuses
# Fall back to line-by-line: "story-id: status" patterns
statuses = {}
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
match = re.match(r"^([a-z0-9][a-z0-9._-]{0,63})\s*:\s*([a-z-]+)\s*$", line)
if match:
story_id, status = match.group(1), match.group(2)
statuses[story_id] = status
return statuses
def _extract_statuses_from_yaml_str(content: str) -> dict[str, str]:
"""Try to extract story_id -> status mappings from a YAML string."""
try:
data = yaml.safe_load(content)
if not isinstance(data, dict):
return {}
# Format: development_status: {story-id: status}
dev_status = data.get("development_status")
if isinstance(dev_status, dict):
return {k: str(v) for k, v in dev_status.items() if isinstance(v, str)}
# Format: stories: {story-id: {status: ...}}
stories = data.get("stories")
if isinstance(stories, dict):
result = {}
for story_id, story_data in stories.items():
if isinstance(story_data, dict) and "status" in story_data:
result[story_id] = str(story_data["status"])
elif isinstance(story_data, str):
result[story_id] = story_data
if result:
return result
except yaml.YAMLError:
pass
return {}
def get_story_status_from_file(story_path: Path) -> str | None:
"""Read a story file and return its status if found, else None."""
try:
content = story_path.read_text(encoding="utf-8")
except OSError:
return None
# Try YAML front matter (--- ... ---)
front_matter_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
if front_matter_match:
try:
data = yaml.safe_load(front_matter_match.group(1))
if isinstance(data, dict) and "status" in data:
return str(data["status"])
except yaml.YAMLError:
pass
# Try parsing whole file as YAML
try:
data = yaml.safe_load(content)
if isinstance(data, dict) and "status" in data:
return str(data["status"])
except yaml.YAMLError:
pass
# Try inline pattern: "status: in-progress"
match = re.search(r"^\s*status\s*:\s*([a-z-]+)\s*$", content, re.MULTILINE)
if match:
return match.group(1)
return None
def parse_story_ids(raw: str) -> list[str]:
"""Parse story IDs from comma-separated string or JSON array."""
raw = raw.strip()
if raw.startswith("["):
try:
ids = json.loads(raw)
return [str(i).strip() for i in ids if str(i).strip()]
except json.JSONDecodeError:
pass
return [s.strip() for s in raw.split(",") if s.strip()]
# ---------------------------------------------------------------------------
# Subcommand: validate-split
# ---------------------------------------------------------------------------
def cmd_validate_split(args: argparse.Namespace) -> dict:
"""Check if a set of stories can be split (none may be in-progress)."""
story_ids = parse_story_ids(args.story_ids)
if not story_ids:
return {"status": "fail", "error": "No story IDs provided.", "eligible": [], "blocked": [], "blockers": []}
sprint_statuses = parse_sprint_plan(args.sprint_plan_file)
eligible = []
blocked = []
for story_id in story_ids:
status = sprint_statuses.get(story_id)
if status == IN_PROGRESS_STATUS:
blocked.append({"id": story_id, "reason": IN_PROGRESS_STATUS})
else:
eligible.append(story_id)
overall = "fail" if blocked else "pass"
return {
"status": overall,
"eligible": eligible,
"blocked": blocked,
"blockers": [b["id"] for b in blocked],
}
# ---------------------------------------------------------------------------
# Subcommand: create-split-feature
# ---------------------------------------------------------------------------
def cmd_create_split_feature(args: argparse.Namespace) -> dict:
"""Create a new feature from a split — feature.yaml, index entry, summary stub."""
# Validate all identifiers
for field_name, value in [
("source-feature-id", args.source_feature_id),
("source-domain", args.source_domain),
("source-service", args.source_service),
("new-feature-id", args.new_feature_id),
]:
err = validate_identifier(value, field_name)
if err:
return {"status": "fail", "error": err}
governance_repo = args.governance_repo
new_feature_dir = get_feature_dir(governance_repo, args.source_domain, args.source_service, args.new_feature_id)
new_feature_yaml_path = new_feature_dir / "feature.yaml"
new_summary_path = new_feature_dir / "summary.md"
index_path = get_feature_index_path(governance_repo)
if new_feature_yaml_path.exists():
return {"status": "fail", "error": f"Feature already exists: {new_feature_yaml_path}"}
timestamp = now_iso()
# Build new feature.yaml based on source metadata
new_feature_data = _build_new_feature_yaml(args, timestamp)
# Build index entry
index_entry = {
"featureId": args.new_feature_id,
"name": args.new_name,
"domain": args.source_domain,
"service": args.source_service,
"status": "preplan",
"track": args.track,
"split_from": args.source_feature_id,
"created": timestamp,
}
if args.dry_run:
return {
"status": "pass",
"dry_run": True,
"new_feature_id": args.new_feature_id,
"new_feature_path": str(new_feature_dir),
"new_feature_yaml": new_feature_data,
"index_entry": index_entry,
"index_updated": True,
"summary_written": True,
}
# Execute: create feature directory
new_feature_dir.mkdir(parents=True, exist_ok=False)
stories_dir = new_feature_dir / "stories"
stories_dir.mkdir(exist_ok=True)
# Write feature.yaml
atomic_write_yaml(new_feature_yaml_path, new_feature_data)
# Write summary stub
summary_content = _build_summary_stub(args, timestamp)
new_summary_path.write_text(summary_content, encoding="utf-8")
# Update feature-index.yaml
index_updated = _update_feature_index(index_path, index_entry)
return {
"status": "pass",
"new_feature_id": args.new_feature_id,
"new_feature_path": str(new_feature_dir),
"new_feature_yaml": str(new_feature_yaml_path),
"summary_path": str(new_summary_path),
"index_updated": index_updated,
}
def _build_new_feature_yaml(args: argparse.Namespace, timestamp: str) -> dict:
"""Build the feature.yaml content for the new split feature."""
team = []
if args.username:
team = [{"username": args.username, "role": "lead"}]
return {
"name": args.new_name,
"description": f"Split from feature '{args.source_feature_id}'.",
"featureId": args.new_feature_id,
"domain": args.source_domain,
"service": args.source_service,
"phase": "preplan",
"track": args.track,
"milestones": {
"businessplan": None,
"techplan": None,
"sprintplan": None,
"dev-ready": None,
"dev-complete": None,
},
"team": team,
"dependencies": {
"depends_on": [],
"depended_by": [],
},
"target_repos": [],
"links": {
"retrospective": None,
"issues": [],
"pull_request": None,
},
"priority": "medium",
"created": timestamp,
"updated": timestamp,
"phase_transitions": [
{"phase": "preplan", "timestamp": timestamp, "user": args.username or ""},
],
"split_from": args.source_feature_id,
}
def _build_summary_stub(args: argparse.Namespace, timestamp: str) -> str:
"""Build the summary.md stub content."""
return (
f"# {args.new_name}\n\n"
f"**Feature ID:** {args.new_feature_id} \n"
f"**Domain:** {args.source_domain} / {args.source_service} \n"
f"**Phase:** preplan \n"
f"**Track:** {args.track} \n"
f"**Split from:** {args.source_feature_id} \n"
f"**Created:** {timestamp} \n\n"
f"<!-- summary stub — populated when planning artifacts are committed -->\n"
)
def _update_feature_index(index_path: Path, entry: dict) -> bool:
"""Add or update an entry in feature-index.yaml. Returns True on success."""
if index_path.exists():
try:
with open(index_path) as f:
index_data = yaml.safe_load(f) or {}
except (yaml.YAMLError, OSError):
index_data = {}
else:
index_data = {}
if not isinstance(index_data, dict):
index_data = {}
features = index_data.get("features", [])
if not isinstance(features, list):
features = []
# Remove existing entry for this featureId if present
features = [f for f in features if not (isinstance(f, dict) and f.get("featureId") == entry["featureId"])]
features.append(entry)
index_data["features"] = features
index_path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_yaml(index_path, index_data)
return True
# ---------------------------------------------------------------------------
# Subcommand: move-stories
# ---------------------------------------------------------------------------
def cmd_move_stories(args: argparse.Namespace) -> dict:
"""Move story files from one feature to another."""
for field_name, value in [
("source-feature-id", args.source_feature_id),
("source-domain", args.source_domain),
("source-service", args.source_service),
("target-feature-id", args.target_feature_id),
("target-domain", args.target_domain),
("target-service", args.target_service),
]:
err = validate_identifier(value, field_name)
if err:
return {"status": "fail", "error": err}
story_ids = parse_story_ids(args.story_ids)
if not story_ids:
return {"status": "fail", "error": "No story IDs provided.", "moved": [], "total_moved": 0}
source_stories_dir = (
get_feature_dir(args.governance_repo, args.source_domain, args.source_service, args.source_feature_id)
/ "stories"
)
target_stories_dir = (
get_feature_dir(args.governance_repo, args.target_domain, args.target_service, args.target_feature_id)
/ "stories"
)
if not source_stories_dir.exists():
return {"status": "fail", "error": f"Source stories directory not found: {source_stories_dir}"}
# Resolve story files and check for in-progress stories
resolved = []
blocked = []
not_found = []
for story_id in story_ids:
story_file = _find_story_file(source_stories_dir, story_id)
if story_file is None:
not_found.append(story_id)
continue
status = get_story_status_from_file(story_file)
if status == IN_PROGRESS_STATUS:
blocked.append({"id": story_id, "reason": IN_PROGRESS_STATUS, "file": str(story_file)})
else:
resolved.append((story_id, story_file))
if blocked:
return {
"status": "fail",
"error": "Cannot move in-progress stories.",
"blocked": blocked,
"not_found": not_found,
"moved": [],
"total_moved": 0,
}
if not_found:
return {
"status": "fail",
"error": f"Story files not found in source: {not_found}",
"not_found": not_found,
"moved": [],
"total_moved": 0,
}
if args.dry_run:
return {
"status": "pass",
"dry_run": True,
"moved": [
{"id": sid, "from": str(sf), "to": str(target_stories_dir / sf.name)}
for sid, sf in resolved
],
"total_moved": len(resolved),
}
# Execute moves
target_stories_dir.mkdir(parents=True, exist_ok=True)
moved = []
for story_id, story_file in resolved:
target_path = target_stories_dir / story_file.name
shutil.move(str(story_file), str(target_path))
moved.append({"id": story_id, "from": str(story_file), "to": str(target_path)})
return {
"status": "pass",
"moved": moved,
"total_moved": len(moved),
}
def _find_story_file(stories_dir: Path, story_id: str) -> Path | None:
"""Find a story file by story ID (tries .md and .yaml extensions)."""
for ext in (".md", ".yaml", ".yml"):
candidate = stories_dir / f"{story_id}{ext}"
if candidate.exists():
return candidate
return None
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Feature split operations — validate, create, and move stories between features.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s validate-split --sprint-plan-file /path/to/sprint-plan.md \\
--story-ids "story-1,story-2,story-3"
%(prog)s create-split-feature --governance-repo /repo \\
--source-feature-id auth-login --source-domain platform --source-service identity \\
--new-feature-id auth-mfa --new-name "MFA Authentication" --track quickplan --username cweber
%(prog)s move-stories --governance-repo /repo \\
--source-feature-id auth-login --source-domain platform --source-service identity \\
--target-feature-id auth-mfa --target-domain platform --target-service identity \\
--story-ids "story-3,story-4"
""",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# validate-split
vs = subparsers.add_parser("validate-split", help="Check if stories can be split (none in-progress)")
vs.add_argument("--sprint-plan-file", required=True, help="Path to sprint-plan.md")
vs.add_argument("--story-ids", required=True, help="Comma-separated or JSON array of story IDs")
# create-split-feature
csf = subparsers.add_parser("create-split-feature", help="Create a new feature from a split")
csf.add_argument("--governance-repo", required=True, help="Path to governance repo root")
csf.add_argument("--source-feature-id", required=True, help="Source feature ID")
csf.add_argument("--source-domain", required=True, help="Source feature domain")
csf.add_argument("--source-service", required=True, help="Source feature service")
csf.add_argument("--new-feature-id", required=True, help="New feature ID")
csf.add_argument("--new-name", required=True, help="New feature human-readable name")
csf.add_argument("--track", default="quickplan", help="Lifecycle track for new feature")
csf.add_argument("--username", default="", help="Username creating the split")
csf.add_argument("--dry-run", action="store_true", help="Show what would be created without writing")
# move-stories
ms = subparsers.add_parser("move-stories", help="Move story files from one feature to another")
ms.add_argument("--governance-repo", required=True, help="Path to governance repo root")
ms.add_argument("--source-feature-id", required=True, help="Source feature ID")
ms.add_argument("--source-domain", required=True, help="Source feature domain")
ms.add_argument("--source-service", required=True, help="Source feature service")
ms.add_argument("--target-feature-id", required=True, help="Target feature ID")
ms.add_argument("--target-domain", required=True, help="Target feature domain")
ms.add_argument("--target-service", required=True, help="Target feature service")
ms.add_argument("--story-ids", required=True, help="Comma-separated story IDs to move")
ms.add_argument("--dry-run", action="store_true", help="Show what would be moved without writing")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
commands = {
"validate-split": cmd_validate_split,
"create-split-feature": cmd_create_split_feature,
"move-stories": cmd_move_stories,
}
result = commands[args.command](args)
json.dump(result, sys.stdout, indent=2, default=str)
print()
status = result.get("status", "fail")
sys.exit(0 if status == "pass" else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Tests for split-feature-ops.py."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
SCRIPT = str(Path(__file__).parent.parent / "split-feature-ops.py")
PASS = 0
FAIL = 0
def run(args: list[str]) -> tuple[dict, int]:
"""Run the script and return parsed JSON output and exit code."""
result = subprocess.run(
[sys.executable, SCRIPT] + args,
capture_output=True,
text=True,
)
try:
return json.loads(result.stdout), result.returncode
except json.JSONDecodeError:
return {"error": result.stderr, "stdout": result.stdout}, result.returncode
def assert_eq(name: str, actual, expected):
global PASS, FAIL
if actual == expected:
PASS += 1
print(f" ✓ {name}", file=sys.stderr)
else:
FAIL += 1
print(f" ✗ {name}: expected {expected!r}, got {actual!r}", file=sys.stderr)
def assert_true(name: str, actual):
assert_eq(name, bool(actual), True)
def assert_false(name: str, actual):
assert_eq(name, bool(actual), False)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_sprint_plan(tmp: str, statuses: dict) -> str:
"""Write a sprint-plan.md with the given story-id -> status mapping."""
plan_path = Path(tmp) / "sprint-plan.md"
content = "# Sprint Plan\n\n```yaml\ndevelopment_status:\n"
for story_id, status in statuses.items():
content += f" {story_id}: {status}\n"
content += "```\n"
plan_path.write_text(content, encoding="utf-8")
return str(plan_path)
def make_sprint_plan_pure_yaml(tmp: str, statuses: dict) -> str:
"""Write a sprint-plan.md as pure YAML (development_status section)."""
plan_path = Path(tmp) / "sprint-plan.yaml"
data = {"development_status": statuses}
plan_path.write_text(yaml.dump(data, default_flow_style=False), encoding="utf-8")
return str(plan_path)
def make_story_file(stories_dir: Path, story_id: str, status: str) -> Path:
"""Create a story file with the given status in YAML front matter."""
stories_dir.mkdir(parents=True, exist_ok=True)
story_path = stories_dir / f"{story_id}.md"
story_path.write_text(
f"---\nstatus: {status}\ntitle: Story {story_id}\n---\n\n# {story_id}\n",
encoding="utf-8",
)
return story_path
def create_source_feature(tmp: str, feature_id: str = "auth-login",
domain: str = "platform", service: str = "identity") -> Path:
"""Create a minimal source feature directory with a stories/ sub-dir."""
feature_dir = Path(tmp) / "features" / domain / service / feature_id
stories_dir = feature_dir / "stories"
stories_dir.mkdir(parents=True, exist_ok=True)
feature_yaml = {
"featureId": feature_id,
"name": "Auth Login",
"domain": domain,
"service": service,
"phase": "dev",
"track": "quickplan",
}
(feature_dir / "feature.yaml").write_text(yaml.dump(feature_yaml), encoding="utf-8")
return feature_dir
# ---------------------------------------------------------------------------
# Tests: validate-split
# ---------------------------------------------------------------------------
def test_validate_split_all_pending():
"""All stories pending → pass, all eligible."""
print("test_validate_split_all_pending", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan(tmp, {"story-1": "pending", "story-2": "pending", "story-3": "done"})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", "story-1,story-2,story-3",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("exit code 0", code, 0)
assert_eq("eligible count", len(result["eligible"]), 3)
assert_eq("blocked empty", result["blocked"], [])
assert_eq("blockers empty", result["blockers"], [])
def test_validate_split_with_in_progress():
"""One in-progress story → fail, lists blocked ID."""
print("test_validate_split_with_in_progress", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan(tmp, {"story-1": "pending", "story-2": "in-progress"})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", "story-1,story-2",
])
assert_eq("status fail", result["status"], "fail")
assert_eq("exit code 1", code, 1)
assert_eq("eligible has story-1", result["eligible"], ["story-1"])
assert_eq("blocked count", len(result["blocked"]), 1)
assert_eq("blocked id", result["blocked"][0]["id"], "story-2")
assert_eq("blocked reason", result["blocked"][0]["reason"], "in-progress")
assert_eq("blockers list", result["blockers"], ["story-2"])
def test_validate_split_mixed():
"""Mix of pending, in-progress, done → blocked lists only in-progress."""
print("test_validate_split_mixed", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan(tmp, {
"story-1": "pending",
"story-2": "in-progress",
"story-3": "done",
"story-4": "in-progress",
})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", "story-1,story-2,story-3,story-4",
])
assert_eq("status fail", result["status"], "fail")
assert_eq("eligible count", len(result["eligible"]), 2)
assert_true("story-1 eligible", "story-1" in result["eligible"])
assert_true("story-3 eligible", "story-3" in result["eligible"])
assert_eq("blocked count", len(result["blocked"]), 2)
blocked_ids = {b["id"] for b in result["blocked"]}
assert_true("story-2 blocked", "story-2" in blocked_ids)
assert_true("story-4 blocked", "story-4" in blocked_ids)
def test_validate_split_unknown_story_is_eligible():
"""Story not in sprint plan is treated as eligible (status unknown)."""
print("test_validate_split_unknown_story_is_eligible", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan(tmp, {"story-1": "pending"})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", "story-1,story-unknown",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("eligible count", len(result["eligible"]), 2)
assert_true("unknown story eligible", "story-unknown" in result["eligible"])
def test_validate_split_pure_yaml_sprint_plan():
"""Sprint plan as pure YAML file is parsed correctly."""
print("test_validate_split_pure_yaml_sprint_plan", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan_pure_yaml(tmp, {"story-a": "pending", "story-b": "in-progress"})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", "story-a,story-b",
])
assert_eq("status fail", result["status"], "fail")
assert_eq("story-b blocked", result["blockers"], ["story-b"])
def test_validate_split_json_array_story_ids():
"""Story IDs can be passed as JSON array."""
print("test_validate_split_json_array_story_ids", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
plan = make_sprint_plan(tmp, {"s1": "done", "s2": "pending"})
result, code = run([
"validate-split",
"--sprint-plan-file", plan,
"--story-ids", '["s1","s2"]',
])
assert_eq("json array pass", result["status"], "pass")
assert_eq("eligible count", len(result["eligible"]), 2)
# ---------------------------------------------------------------------------
# Tests: create-split-feature
# ---------------------------------------------------------------------------
def test_create_split_feature_creates_feature_yaml():
"""create-split-feature writes a valid feature.yaml."""
print("test_create_split_feature_creates_feature_yaml", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "auth-mfa",
"--new-name", "MFA Authentication",
"--track", "quickplan",
"--username", "testuser",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("exit code 0", code, 0)
assert_eq("new_feature_id", result["new_feature_id"], "auth-mfa")
feature_yaml_path = Path(tmp) / "features" / "platform" / "identity" / "auth-mfa" / "feature.yaml"
assert_true("feature.yaml exists", feature_yaml_path.exists())
with open(feature_yaml_path) as f:
data = yaml.safe_load(f)
assert_eq("featureId", data["featureId"], "auth-mfa")
assert_eq("name", data["name"], "MFA Authentication")
assert_eq("phase", data["phase"], "preplan")
assert_eq("track", data["track"], "quickplan")
assert_eq("split_from", data["split_from"], "auth-login")
assert_eq("team lead", data["team"][0]["username"], "testuser")
assert_eq("team role", data["team"][0]["role"], "lead")
def test_create_split_feature_updates_feature_index():
"""create-split-feature updates feature-index.yaml."""
print("test_create_split_feature_updates_feature_index", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "auth-reset",
"--new-name", "Password Reset",
"--track", "full",
"--username", "testuser",
])
assert_eq("status pass", result["status"], "pass")
assert_true("index_updated", result["index_updated"])
index_path = Path(tmp) / "feature-index.yaml"
assert_true("index file exists", index_path.exists())
with open(index_path) as f:
index = yaml.safe_load(f)
features = index.get("features", [])
assert_eq("features count", len(features), 1)
entry = features[0]
assert_eq("index featureId", entry["featureId"], "auth-reset")
assert_eq("index status", entry["status"], "preplan")
assert_eq("index split_from", entry["split_from"], "auth-login")
def test_create_split_feature_appends_to_existing_index():
"""create-split-feature appends to an existing feature-index.yaml."""
print("test_create_split_feature_appends_to_existing_index", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
# Pre-populate index with one entry
index_path = Path(tmp) / "feature-index.yaml"
existing_entry = {
"featureId": "existing-feature",
"name": "Existing",
"domain": "core",
"service": "api",
"status": "dev",
}
index_path.write_text(yaml.dump({"features": [existing_entry]}), encoding="utf-8")
run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "existing-feature",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "split-child",
"--new-name", "Split Child",
"--track", "quickplan",
"--username", "testuser",
])
with open(index_path) as f:
index = yaml.safe_load(f)
features = index.get("features", [])
assert_eq("both entries present", len(features), 2)
ids = {f["featureId"] for f in features}
assert_true("existing preserved", "existing-feature" in ids)
assert_true("new added", "split-child" in ids)
def test_create_split_feature_writes_summary_stub():
"""create-split-feature writes a summary.md stub."""
print("test_create_split_feature_writes_summary_stub", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "auth-oauth",
"--new-name", "OAuth Integration",
"--track", "quickplan",
"--username", "testuser",
])
summary_path = Path(tmp) / "features" / "platform" / "identity" / "auth-oauth" / "summary.md"
assert_true("summary.md exists", summary_path.exists())
content = summary_path.read_text()
assert_true("has feature id", "auth-oauth" in content)
assert_true("has split_from", "auth-login" in content)
def test_create_split_feature_dry_run():
"""Dry-run returns plan without creating files."""
print("test_create_split_feature_dry_run", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "auth-mfa",
"--new-name", "MFA Authentication",
"--track", "quickplan",
"--username", "testuser",
"--dry-run",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("exit code 0", code, 0)
assert_eq("dry_run flag", result.get("dry_run"), True)
assert_eq("new_feature_id", result["new_feature_id"], "auth-mfa")
# No files should be created
feature_yaml_path = Path(tmp) / "features" / "platform" / "identity" / "auth-mfa" / "feature.yaml"
assert_false("no file created", feature_yaml_path.exists())
def test_create_split_feature_duplicate_fails():
"""create-split-feature fails if new feature already exists."""
print("test_create_split_feature_duplicate_fails", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
args = [
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "auth-dup",
"--new-name", "Dup",
"--track", "quickplan",
"--username", "testuser",
]
run(args) # first create
result, code = run(args) # second create
assert_eq("duplicate fail", result["status"], "fail")
assert_eq("duplicate exit code", code, 1)
def test_create_split_feature_invalid_id():
"""create-split-feature rejects invalid new feature ID."""
print("test_create_split_feature_invalid_id", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "INVALID ID with spaces!",
"--new-name", "Bad Name",
"--track", "quickplan",
"--username", "testuser",
])
assert_eq("invalid id fails", result["status"], "fail")
assert_eq("invalid exit code", code, 1)
assert_true("error mentions id", "new-feature-id" in result.get("error", ""))
# ---------------------------------------------------------------------------
# Tests: move-stories
# ---------------------------------------------------------------------------
def test_move_stories_moves_files():
"""move-stories moves story files to the target feature."""
print("test_move_stories_moves_files", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
src_dir = create_source_feature(tmp, "auth-login", "platform", "identity")
stories_dir = src_dir / "stories"
make_story_file(stories_dir, "story-1", "pending")
make_story_file(stories_dir, "story-2", "done")
# Create target feature
tgt_dir = Path(tmp) / "features" / "platform" / "identity" / "auth-mfa"
(tgt_dir / "stories").mkdir(parents=True, exist_ok=True)
result, code = run([
"move-stories",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--target-feature-id", "auth-mfa",
"--target-domain", "platform",
"--target-service", "identity",
"--story-ids", "story-1,story-2",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("exit code 0", code, 0)
assert_eq("total_moved", result["total_moved"], 2)
# Source files gone
assert_false("story-1 gone from source", (stories_dir / "story-1.md").exists())
assert_false("story-2 gone from source", (stories_dir / "story-2.md").exists())
# Target files exist
tgt_stories = tgt_dir / "stories"
assert_true("story-1 at target", (tgt_stories / "story-1.md").exists())
assert_true("story-2 at target", (tgt_stories / "story-2.md").exists())
def test_move_stories_dry_run():
"""Dry-run shows plan without moving files."""
print("test_move_stories_dry_run", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
src_dir = create_source_feature(tmp, "auth-login", "platform", "identity")
stories_dir = src_dir / "stories"
make_story_file(stories_dir, "story-3", "pending")
result, code = run([
"move-stories",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--target-feature-id", "auth-mfa",
"--target-domain", "platform",
"--target-service", "identity",
"--story-ids", "story-3",
"--dry-run",
])
assert_eq("status pass", result["status"], "pass")
assert_eq("exit code 0", code, 0)
assert_eq("dry_run flag", result.get("dry_run"), True)
assert_eq("total_moved", result["total_moved"], 1)
# File must still be in source
assert_true("story-3 still in source", (stories_dir / "story-3.md").exists())
def test_move_stories_blocks_in_progress():
"""move-stories fails if a story is in-progress."""
print("test_move_stories_blocks_in_progress", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
src_dir = create_source_feature(tmp, "auth-login", "platform", "identity")
stories_dir = src_dir / "stories"
make_story_file(stories_dir, "story-good", "pending")
make_story_file(stories_dir, "story-wip", "in-progress")
result, code = run([
"move-stories",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--target-feature-id", "auth-mfa",
"--target-domain", "platform",
"--target-service", "identity",
"--story-ids", "story-good,story-wip",
])
assert_eq("status fail", result["status"], "fail")
assert_eq("exit code 1", code, 1)
assert_true("blocked present", len(result.get("blocked", [])) > 0)
blocked_ids = {b["id"] for b in result.get("blocked", [])}
assert_true("story-wip in blocked", "story-wip" in blocked_ids)
assert_eq("total_moved zero", result["total_moved"], 0)
# Neither file should have moved
assert_true("story-good still in source", (stories_dir / "story-good.md").exists())
assert_true("story-wip still in source", (stories_dir / "story-wip.md").exists())
def test_move_stories_missing_story_fails():
"""move-stories fails if a specified story file doesn't exist."""
print("test_move_stories_missing_story_fails", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
create_source_feature(tmp, "auth-login", "platform", "identity")
result, code = run([
"move-stories",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--target-feature-id", "auth-mfa",
"--target-domain", "platform",
"--target-service", "identity",
"--story-ids", "nonexistent-story",
])
assert_eq("status fail", result["status"], "fail")
assert_eq("exit code 1", code, 1)
def test_move_stories_invalid_source_id():
"""move-stories rejects invalid source feature ID (slug check)."""
print("test_move_stories_invalid_source_id", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
result, code = run([
"move-stories",
"--governance-repo", tmp,
"--source-feature-id", "INVALID ID",
"--source-domain", "platform",
"--source-service", "identity",
"--target-feature-id", "auth-mfa",
"--target-domain", "platform",
"--target-service", "identity",
"--story-ids", "story-1",
])
assert_eq("invalid id fail", result["status"], "fail")
assert_eq("exit code 1", code, 1)
# ---------------------------------------------------------------------------
# Tests: input validation (slug check)
# ---------------------------------------------------------------------------
def test_invalid_feature_id_rejected():
"""Invalid featureId (slug check) is rejected by create-split-feature."""
print("test_invalid_feature_id_rejected", file=sys.stderr)
with tempfile.TemporaryDirectory() as tmp:
# new-feature-id with uppercase
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "AuthMFA",
"--new-name", "Auth MFA",
"--track", "quickplan",
"--username", "testuser",
])
assert_eq("uppercase id rejected", result["status"], "fail")
assert_eq("exit code 1", code, 1)
with tempfile.TemporaryDirectory() as tmp:
# new-feature-id with special characters (spaces → will fail slug check)
result, code = run([
"create-split-feature",
"--governance-repo", tmp,
"--source-feature-id", "auth-login",
"--source-domain", "platform",
"--source-service", "identity",
"--new-feature-id", "bad..start!!",
"--new-name", "Bad Start",
"--track", "quickplan",
"--username", "testuser",
])
assert_eq("special-chars id rejected", result["status"], "fail")
assert_eq("exit code 1", code, 1)
if __name__ == "__main__":
test_validate_split_all_pending()
test_validate_split_with_in_progress()
test_validate_split_mixed()
test_validate_split_unknown_story_is_eligible()
test_validate_split_pure_yaml_sprint_plan()
test_validate_split_json_array_story_ids()
test_create_split_feature_creates_feature_yaml()
test_create_split_feature_updates_feature_index()
test_create_split_feature_appends_to_existing_index()
test_create_split_feature_writes_summary_stub()
test_create_split_feature_dry_run()
test_create_split_feature_duplicate_fails()
test_create_split_feature_invalid_id()
test_move_stories_moves_files()
test_move_stories_dry_run()
test_move_stories_blocks_in_progress()
test_move_stories_missing_story_fails()
test_move_stories_invalid_source_id()
test_invalid_feature_id_rejected()
print(f"\n{'='*40}", file=sys.stderr)
print(f"Results: {PASS} passed, {FAIL} failed", file=sys.stderr)
print(f"{'='*40}", file=sys.stderr)
sys.exit(1 if FAIL > 0 else 0)