diff --git a/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json b/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json new file mode 100644 index 00000000..3b9ed5a9 --- /dev/null +++ b/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json @@ -0,0 +1,8 @@ +{ + "requiredKeys": ["status", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "summary": "brief description", + "next_action": "proceed|retry|escalate" + } +} diff --git a/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md b/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md new file mode 100644 index 00000000..b66acda1 --- /dev/null +++ b/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md @@ -0,0 +1,7 @@ +Run the `{{label}}` TEA workflow for story `{{story_id}}`. + +{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}{{template_line}}Use the story context already prepared by story automator. + +Return a concise structured result that matches the configured parse schema. + +{{extra_instruction}} diff --git a/skills/bmad-story-automator/src/story_automator/cli.py b/skills/bmad-story-automator/src/story_automator/cli.py index 5ef5a801..cfb9e85e 100644 --- a/skills/bmad-story-automator/src/story_automator/cli.py +++ b/skills/bmad-story-automator/src/story_automator/cli.py @@ -13,7 +13,7 @@ cmd_stop_hook, ) from .commands.orchestrator import cmd_orchestrator_helper -from .commands.state import cmd_build_state_doc, cmd_sprint_compare, cmd_state_metrics, cmd_validate_state +from .commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_detect_workflow_track, cmd_sprint_compare, cmd_state_metrics, cmd_validate_state from .commands.tmux import cmd_codex_status_check, cmd_heartbeat_check, cmd_monitor_session, cmd_tmux_status_check, cmd_tmux_wrapper from .commands.validate_story_creation import cmd_validate_story_creation from .core.common import help_flag, print_json @@ -39,6 +39,8 @@ def main(argv: list[str] | None = None) -> int: "ensure-stop-hook": cmd_ensure_stop_hook, "stop-hook": cmd_stop_hook, "build-state-doc": cmd_build_state_doc, + "build-run-policy": cmd_build_run_policy, + "detect-workflow-track": cmd_detect_workflow_track, "commit-story": cmd_commit_story, "parse-epic": _cmd_parse_epic, "parse-story": _cmd_parse_story, @@ -75,6 +77,8 @@ def _usage(stream: object) -> None: "ensure-stop-hook", "stop-hook", "build-state-doc", + "build-run-policy", + "detect-workflow-track", "commit-story", "parse-epic", "parse-story", diff --git a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py index 740335d7..07420321 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -62,6 +62,7 @@ def cmd_orchestrator_helper(args: list[str]) -> int: "state-latest-incomplete": _state_latest_incomplete, "state-summary": _state_summary, "state-update": _state_update, + "state-progress": _state_progress, "escalate": _escalate, "commit-ready": _commit_ready, "normalize-key": _normalize_key, @@ -100,6 +101,7 @@ def _usage(code: int) -> int: print(" state-latest-incomplete ", file=target) print(" state-summary ", file=target) print(" state-update --set k=v", file=target) + print(" state-progress --story ID --set step=value", file=target) print(" escalate ", file=target) print(" commit-ready ", file=target) print(" normalize-key [--to id|key|prefix|json]", file=target) @@ -110,7 +112,7 @@ def _usage(code: int) -> int: print(" get-epic-stories [--state-file path]", file=target) print(" check-blocking ", file=target) print(" agents-build --state-file path --complexity-file path --output path --config-json '{}'", file=target) - print(" agents-resolve (--state-file path | --agents-file path) --story ID --task create|dev|auto|review", file=target) + print(" agents-resolve (--state-file path | --agents-file path) --story ID --task STEP_NAME", file=target) print(" retro-agent --state-file path", file=target) return code @@ -475,6 +477,125 @@ def _verify_step(args: list[str]) -> int: return exit_code +def _normalize_progress_key(value: str) -> str: + key = str(value or "").strip().lower().replace("_", "-") + aliases = { + "create": "create-story", + "dev": "dev-story", + "auto": "automate", + "review": "code-review", + "test-automate": "test-automate", + "test-review": "test-review", + "git_commit": "git-commit", + "git-commit": "git-commit", + "status": "status", + "story": "story", + "create-story": "create-story", + "dev-story": "dev-story", + "automate": "automate", + "code-review": "code-review", + "atdd": "atdd", + "nfr": "nfr", + "trace": "trace", + } + return aliases.get(key, key) + + +def _parse_markdown_cells(line: str) -> list[str]: + parts = [part.strip() for part in line.split("|")] + return [part for part in parts[1:-1]] + + +def _render_markdown_row(cells: list[str]) -> str: + return "| " + " | ".join(cells) + " |" + + +def _state_progress(args: list[str]) -> int: + if not args: + print_json({"ok": False, "error": "file_not_found"}) + return 1 + state_file = args[0] + try: + if not file_exists(state_file): + print_json({"ok": False, "error": "file_not_found"}) + return 1 + except OSError: + print_json({"ok": False, "error": "state_file_unreadable"}) + return 1 + story_id = "" + updates: dict[str, str] = {} + idx = 1 + while idx < len(args): + if args[idx] == "--story" and idx + 1 < len(args): + story_id = args[idx + 1] + idx += 2 + continue + if args[idx] == "--set" and idx + 1 < len(args): + raw_update = args[idx + 1] + if "=" not in raw_update: + print_json({"ok": False, "error": "invalid_set_argument", "argument": raw_update}) + return 1 + key, value = raw_update.split("=", 1) + updates[_normalize_progress_key(key)] = value + idx += 2 + continue + idx += 1 + if not story_id or not updates: + print_json({"ok": False, "error": "missing_story_or_updates"}) + return 1 + + try: + lines = read_text(state_file).splitlines() + except OSError: + print_json({"ok": False, "error": "state_file_unreadable"}) + return 1 + header_idx = -1 + story_idx = -1 + headers: list[str] = [] + story_cells: list[str] = [] + for i, line in enumerate(lines): + if line.startswith("| Story "): + header_idx = i + headers = [_normalize_progress_key(cell) for cell in _parse_markdown_cells(line)] + continue + if header_idx >= 0 and line.startswith(f"| {story_id} |"): + story_idx = i + story_cells = _parse_markdown_cells(line) + break + if header_idx < 0 or not headers: + print_json({"ok": False, "error": "progress_table_not_found"}) + return 1 + if story_idx < 0 or not story_cells: + print_json({"ok": False, "error": "story_row_not_found"}) + return 1 + if len(story_cells) != len(headers): + print_json({"ok": False, "error": "progress_row_misaligned"}) + return 1 + + header_map = {name: pos for pos, name in enumerate(headers)} + applied: list[str] = [] + for key, value in updates.items(): + if key == "story": + print_json({"ok": False, "error": "story_column_immutable"}) + return 1 + pos = header_map.get(key) + if pos is None: + continue + story_cells[pos] = value + applied.append(key) + if not applied: + print_json({"ok": False, "error": "progress_columns_not_found"}) + return 1 + lines[story_idx] = _render_markdown_row(story_cells) + try: + Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") + except OSError: + print_json({"ok": False, "error": "state_file_unwritable"}) + return 1 + print_json({"ok": True, "story": story_id, "updated": applied}) + return 0 + + def _parse_context_int(context: str, key: str) -> int: match = re.search(rf"{re.escape(key)}=(\d+)", context) return int(match.group(1)) if match else 0 diff --git a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py index b6301556..69e1cc53 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py @@ -5,6 +5,7 @@ from pathlib import Path from story_automator.core.frontmatter import extract_frontmatter, find_frontmatter_value, parse_frontmatter +from story_automator.core.runtime_policy import PolicyError, load_policy_shape_for_state, story_task_sequence from story_automator.core.runtime_layout import runtime_provider from story_automator.core.sprint import sprint_status_epic from story_automator.core.story_keys import normalize_story_key @@ -116,11 +117,17 @@ def agents_build_action(args: list[str]) -> int: config = parse_agent_config(options["config-json"]) complexity = json.loads(read_text(options["complexity-file"])) state_fields = parse_frontmatter(read_text(options["state-file"])) + try: + policy = load_policy_shape_for_state(options["state-file"]) + tasks_in_scope = story_task_sequence(policy) + except PolicyError as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 stories = [] for story in complexity.get("stories", []): level = str(story.get("complexity", {}).get("level", "medium")).lower() or "medium" tasks = {} - for task in ("create", "dev", "auto", "review"): + for task in tasks_in_scope: primary, fallback, model = resolve_agent(config, level, task) entry = { "primary": primary, diff --git a/skills/bmad-story-automator/src/story_automator/commands/state.py b/skills/bmad-story-automator/src/story_automator/commands/state.py index 38990141..afb08711 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,11 +6,63 @@ from typing import Any from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter -from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy +from ..core.runtime_layout import bundled_story_skill_root, resolve_skill_dir +from ..core.runtime_policy import PolicyError, load_effective_policy, load_policy_for_state, snapshot_effective_policy from ..core.agent_config import normalize_model as _model_or_none from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json +STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] +TEA_SKILL_ALIASES = { + "atdd": ("bmad-testarch-atdd", "bmad-tea-testarch-atdd"), + "test_automate": ("bmad-testarch-automate", "bmad-tea-testarch-automate"), + "test_review": ("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), + "trace": ("bmad-testarch-trace", "bmad-tea-testarch-trace"), + "nfr": ("bmad-testarch-nfr", "bmad-tea-testarch-nfr"), +} + +STEP_DISPLAY_NAMES = { + "create": "create-story", + "dev": "dev-story", + "auto": "automate", + "review": "code-review", + "atdd": "atdd", + "test_automate": "test-automate", + "test_review": "test-review", + "nfr": "nfr", + "trace": "trace", +} + + +def _story_progress_steps(policy: dict[str, Any]) -> list[str]: + sequence = ((policy.get("workflow") or {}).get("sequence")) or [] + return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] + + +def _progress_headers(steps: list[str]) -> list[str]: + headers = ["Story"] + headers.extend(STEP_DISPLAY_NAMES.get(step, step.replace("_", "-")) for step in steps) + headers.extend(["git-commit", "Status"]) + return headers + + +def _markdown_divider(width: int) -> list[str]: + return ["-------" if idx == 0 else "----------" for idx in range(width)] + + +def _progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: + steps = _story_progress_steps(policy) + headers = _progress_headers(steps) + divider = _markdown_divider(len(headers)) + pending_cells = ["⏳"] * len(steps) + ["⏳", "pending"] + rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) + return ( + "| " + " | ".join(headers) + " |", + "| " + " | ".join(divider) + " |", + rows, + ) + + def cmd_build_state_doc(args: list[str]) -> int: template = "" output_folder = "" @@ -36,7 +88,7 @@ def cmd_build_state_doc(args: list[str]) -> int: try: config = json.loads(config_json) except json.JSONDecodeError: - write_json({"ok": False, "error": "missing_config"}) + write_json({"ok": False, "error": "invalid_config_json"}) return 1 ensure_dir(output_folder) now = now_utc_z() @@ -44,11 +96,13 @@ def cmd_build_state_doc(args: list[str]) -> int: epic = str(config.get("epic") or "epic") safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" + policy_selection = _build_run_policy(Path(get_project_root()), config) try: - snapshot = snapshot_effective_policy(get_project_root()) + snapshot = snapshot_effective_policy(get_project_root(), inline_override=policy_selection["policyOverride"]) except (FileNotFoundError, PolicyError, ValueError) as exc: write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)}) return 1 + progress_header, progress_divider, progress_rows = _progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) text = read_text(template) replacements: dict[str, Any] = { "epic": config.get("epic", ""), @@ -78,6 +132,14 @@ def cmd_build_state_doc(args: list[str]) -> int: ) custom_instructions = json.dumps(config.get("customInstructions", "")) text = re.sub(r"(?m)^customInstructions:.*$", lambda m: f"customInstructions: {custom_instructions}", text) + if policy_selection["workflowTrack"] == "tea": + tea_frontmatter = ( + f'workflowTrack: {json.dumps(policy_selection["workflowTrack"])}\n' + f"selectedOptionalSteps: {json.dumps(policy_selection['selectedOptionalSteps'])}\n" + f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}\n" + f"policyNotes: {json.dumps(policy_selection['notes'])}\n" + ) + text = text.replace("customInstructions: " + custom_instructions + "\n", "customInstructions: " + custom_instructions + "\n" + tea_frontmatter) agent_config = config.get("agentConfig") if isinstance(agent_config, dict): per_task = agent_config.get("perTask", {}) @@ -153,7 +215,6 @@ def cmd_build_state_doc(args: list[str]) -> int: for key, value in replacements.items(): text = re.sub(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {json.dumps(v)}", text) story_range = [item for item in config.get("storyRange", []) if isinstance(item, str)] - progress_rows = "\n".join(f"| {story_id} | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |" for story_id in story_range) body = { "{{epicName}}": str(config.get("epicName", "")), "{{epic}}": str(config.get("epic", "")), @@ -163,14 +224,549 @@ def cmd_build_state_doc(args: list[str]) -> int: "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), "{{customInstructions}}": str(config.get("customInstructions", "")), } + tea_block = "" + if policy_selection["workflowTrack"] == "tea": + tea_block_lines = [ + "**TEA Configuration:**", + "- Mandatory TEA Core: atdd, test_automate, test_review, trace", + f"- Optional Automated Steps: {', '.join(policy_selection['selectedOptionalSteps']) or 'none'}", + f"- Policy Notes: {'; '.join(policy_selection['notes']) or 'none'}", + "", + ] + tea_block = "\n".join(tea_block_lines) + body["{{teaConfigurationBlock}}"] = tea_block for key, value in body.items(): text = text.replace(key, value) + text = text.replace("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", progress_header) + text = text.replace("|-------|--------------|-----------|----------|-------------|------------|--------|", progress_divider) text = text.replace("", progress_rows) output_path.write_text(text) write_json({"ok": True, "path": str(output_path), "createdAt": now}) return 0 +def _normalize_string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if item is not None and str(item).strip()] + if isinstance(value, str) and value.strip(): + return [part.strip() for part in value.split(",") if part.strip()] + return [] + + +def _normalize_option_list(value: Any) -> list[str]: + return [item.lower() for item in _normalize_string_list(value)] + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "y", "on"}: + return True + if lowered in {"0", "false", "no", "n", "off"}: + return False + return default + + +def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: + configured = str(config.get("teaAssetsRoot") or "").strip() + if configured: + return configured.rstrip("/") + return _tea_detected_assets_root(project_root) + + +def _tea_asset_root_candidates(project_root: Path) -> list[str]: + candidates = [ + "_bmad/tea/story-automator", + "docs/plans/tea-story-automator/assets", + "data/tea-story-automator", + ] + unique: list[str] = [] + for candidate in candidates: + if candidate not in unique: + unique.append(candidate) + return unique + + +def _tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: + raw = Path(assets_root) + candidates: list[Path] = [] + if raw.is_absolute(): + candidates.append(raw.resolve()) + else: + candidates.append((project_root / raw).resolve()) + try: + bundle_root = bundled_story_skill_root(project_root) + candidates.append((bundle_root / raw).resolve()) + except FileNotFoundError: + pass + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] if candidates else None + + +def _tea_assets_complete_for_base(base: Path | None) -> bool: + if base is None or not base.exists(): + return False + if (base / "prompts" / "tea_step.md").is_file() and (base / "parse" / "tea_step.json").is_file(): + return True + required = [ + base / "prompts" / "atdd.md", + base / "prompts" / "test_automate.md", + base / "prompts" / "test_review.md", + base / "prompts" / "trace.md", + base / "parse" / "atdd.json", + base / "parse" / "test_automate.json", + base / "parse" / "test_review.json", + base / "parse" / "trace.json", + ] + return all(path.is_file() for path in required) + + +def _tea_detected_assets_root(project_root: Path) -> str: + for assets_root in _tea_asset_root_candidates(project_root): + if _tea_assets_complete_for_base(_tea_assets_base_path(project_root, assets_root)): + return assets_root + return "data/tea-story-automator" + + +def _tea_contract_files(project_root: Path, assets_root: str, step: str) -> tuple[str, str]: + base = _tea_assets_base_path(project_root, assets_root) + root = assets_root.rstrip("/") + generic_prompt = f"{root}/prompts/tea_step.md" + generic_schema = f"{root}/parse/tea_step.json" + if base is None: + return generic_prompt, generic_schema + if (base / "prompts" / f"{step}.md").is_file() and (base / "parse" / f"{step}.json").is_file(): + return f"{root}/prompts/{step}.md", f"{root}/parse/{step}.json" + return generic_prompt, generic_schema + + +def _resolve_tea_skill_name(project_root: Path, step: str) -> str: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return skill_name + return candidates[0] if candidates else "" + + +def _tea_skill_installed(project_root: Path, step: str) -> bool: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return True + return False + + +def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: + root = assets_root.rstrip("/") + atdd_prompt, atdd_schema = _tea_contract_files(project_root, assets_root, "atdd") + automate_prompt, automate_schema = _tea_contract_files(project_root, assets_root, "test_automate") + review_prompt, review_schema = _tea_contract_files(project_root, assets_root, "test_review") + trace_prompt, trace_schema = _tea_contract_files(project_root, assets_root, "trace") + steps: dict[str, Any] = { + "atdd": { + "label": "atdd", + "assets": { + "skillName": _resolve_tea_skill_name(project_root, "atdd"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": atdd_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": atdd_schema}, + "success": {"verifier": "session_exit"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": _resolve_tea_skill_name(project_root, "test_automate"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": automate_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": automate_schema}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": _resolve_tea_skill_name(project_root, "test_review"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": review_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": review_schema}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": _resolve_tea_skill_name(project_root, "trace"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": trace_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": trace_schema}, + "success": {"verifier": "session_exit"}, + }, + } + if include_nfr: + nfr_prompt, nfr_schema = _tea_contract_files(project_root, assets_root, "nfr") + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": _resolve_tea_skill_name(project_root, "nfr"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": nfr_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": nfr_schema}, + "success": {"verifier": "session_exit"}, + } + return steps + + +def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: + explicit_override = config.get("policyOverride") + if isinstance(explicit_override, dict): + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" + notes = _normalize_string_list(config.get("policyNotes")) + if _normalize_option_list(config.get("manualCheckpoints")): + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") + return { + "policyOverride": explicit_override, + "workflowTrack": track, + "selectedOptionalSteps": _normalize_option_list(config.get("selectedOptionalSteps")), + "manualCheckpoints": [], + "notes": notes, + } + + has_run_selection = any( + key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") + ) + if not has_run_selection: + return { + "policyOverride": {"workflow": {"sequence": list(STANDARD_SEQUENCE)}}, + "workflowTrack": "standard", + "selectedOptionalSteps": [], + "manualCheckpoints": [], + "notes": [], + } + + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" + selected = set(_normalize_option_list(config.get("selectedOptionalSteps"))) + manual = set(_normalize_option_list(config.get("manualCheckpoints"))) + notes: list[str] = [] + policy_override: dict[str, Any] = {} + + if track == "tea": + assets_root = _tea_assets_root(project_root, config) + include_nfr = "nfr" in selected + if include_nfr and not _tea_skill_installed(project_root, "nfr"): + notes.append("nfr was requested on the TEA track, but the TEA NFR skill is not installed, so it was ignored.") + include_nfr = False + selected.discard("nfr") + include_retro = "retro" in selected + if "validate-create-story" in selected: + notes.append("validate-create-story remains an advisory pre-dev quality check and is not yet automated by story-automator.") + if "qa-generate-e2e-tests" in selected: + notes.append("qa-generate-e2e-tests is superseded by TEA test_automate on the TEA track and was ignored.") + sequence = ["create", "atdd", "dev", "test_automate", "test_review"] + if include_nfr: + sequence.append("nfr") + sequence.extend(["trace", "review"]) + if include_retro: + sequence.append("retro") + policy_override = { + "workflow": {"sequence": sequence}, + "steps": _tea_step_contracts(project_root, assets_root, include_nfr=include_nfr), + } + selected = {"nfr" if include_nfr else "", "retro" if include_retro else ""} + selected.discard("") + else: + include_retro = "retro" in selected if "retro" in selected else _as_bool(config.get("includeRetro"), True) + sequence = ["create", "dev", "auto", "review"] + if include_retro: + sequence.append("retro") + selected.add("retro") + else: + selected.discard("retro") + for unsupported in sorted(selected & {"nfr"}): + notes.append("nfr is only available on the TEA track and was ignored for the standard workflow.") + selected.discard(unsupported) + if "validate-create-story" in selected: + notes.append("validate-create-story is not yet an automated story-automator step and was recorded as advisory only.") + selected.discard("validate-create-story") + if "qa-generate-e2e-tests" in selected: + notes.append("qa-generate-e2e-tests is already represented by the standard auto step; use skipAutomate to disable it.") + selected.discard("qa-generate-e2e-tests") + policy_override = {"workflow": {"sequence": sequence}} + + if manual: + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") + return { + "policyOverride": policy_override, + "workflowTrack": track, + "selectedOptionalSteps": sorted(selected), + "manualCheckpoints": [], + "notes": notes, + } + + +def cmd_build_run_policy(args: list[str]) -> int: + config_file = "" + config_json = "" + for idx, arg in enumerate(args): + if arg == "--config-file" and idx + 1 < len(args): + config_file = args[idx + 1] + elif arg == "--config-json" and idx + 1 < len(args): + config_json = args[idx + 1] + if config_file and file_exists(config_file): + config_json = read_text(config_file) + if not config_json.strip(): + write_json({"ok": False, "error": "missing_config"}) + return 1 + try: + config = json.loads(config_json) + except json.JSONDecodeError: + write_json({"ok": False, "error": "invalid_config_json"}) + return 1 + if not isinstance(config, dict): + write_json({"ok": False, "error": "config_must_be_object"}) + return 1 + selection = _build_run_policy(Path(get_project_root()), config) + write_json({"ok": True, **selection}) + return 0 + + +def _explicit_policy_path(project_root: Path) -> Path: + return project_root / "_bmad" / "bmm" / "story-automator.policy.json" + + +def _explicit_policy_payload(project_root: Path) -> tuple[dict[str, Any], str]: + override_path = _explicit_policy_path(project_root) + if not override_path.is_file(): + return {}, "" + try: + payload = json.loads(read_text(override_path)) + except OSError as exc: + return {}, f"explicit story-automator policy unreadable: {exc}" + except json.JSONDecodeError as exc: + return {}, f"explicit story-automator policy invalid JSON: {exc}" + if not isinstance(payload, dict): + return {}, "explicit story-automator policy must be a JSON object" + return payload, "" + + +def _explicit_tea_steps(project_root: Path) -> list[str]: + payload, _ = _explicit_policy_payload(project_root) + sequence = ((payload.get("workflow") or {}).get("sequence")) or [] + tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} + return [step for step in sequence if isinstance(step, str) and step in tea_steps] + + +def _has_explicit_tea_policy(project_root: Path) -> bool: + return bool(_explicit_tea_steps(project_root)) + + +def _explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | None, str]: + if not _has_explicit_tea_policy(project_root): + return None, "" + try: + return load_effective_policy(str(project_root), resolve_assets=True), "" + except (FileNotFoundError, PolicyError, ValueError) as exc: + return None, str(exc) + + +def _explicit_tea_policy_valid(project_root: Path) -> tuple[bool, str]: + policy, error = _explicit_tea_policy_details(project_root) + return policy is not None, error + + +def _tea_detection_assets_root(project_root: Path) -> str: + return _tea_detected_assets_root(project_root) + + +def _tea_assets_complete(project_root: Path, assets_root: str) -> tuple[bool, list[str]]: + if not assets_root: + return False, ["missing TEA story-automator assets root"] + base = _tea_assets_base_path(project_root, assets_root) + if base is None or not base.exists(): + return False, ["missing TEA story-automator assets root"] + if _tea_assets_complete_for_base(base): + return True, [] + required = [ + base / "prompts" / "atdd.md", + base / "prompts" / "test_automate.md", + base / "prompts" / "test_review.md", + base / "prompts" / "trace.md", + base / "parse" / "atdd.json", + base / "parse" / "test_automate.json", + base / "parse" / "test_review.json", + base / "parse" / "trace.json", + ] + missing = [str(path) for path in required if not path.is_file()] + return False, missing + + +def _tea_project_signals(project_root: Path) -> list[str]: + signals: list[str] = [] + checks = { + "_bmad/tea/config.yaml": project_root / "_bmad" / "tea" / "config.yaml", + "_bmad/tea/module-help.csv": project_root / "_bmad" / "tea" / "module-help.csv", + "_bmad/tea/workflows/testarch": project_root / "_bmad" / "tea" / "workflows" / "testarch", + "_bmad/tea/story-automator": project_root / "_bmad" / "tea" / "story-automator", + } + for label, path in checks.items(): + if path.exists(): + signals.append(label) + return signals + + +def _tea_skill_availability(project_root: Path, required_steps: list[str] | None = None) -> tuple[list[str], list[str]]: + available: list[str] = [] + missing: list[str] = [] + for step in (required_steps or ["atdd", "test_automate", "test_review", "trace"]): + skill_name = _resolve_tea_skill_name(project_root, step) + if not skill_name: + missing.append(TEA_SKILL_ALIASES[step][0]) + continue + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + missing.append(TEA_SKILL_ALIASES[step][0]) + continue + if file_exists(str(skill_dir / "SKILL.md")): + available.append(skill_name) + else: + missing.append(TEA_SKILL_ALIASES[step][0]) + return available, missing + + +def _resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[str]) -> tuple[list[str], str]: + available: list[str] = [] + asset_roots: list[str] = [] + steps = policy.get("steps") or {} + for step in required_steps: + if not isinstance(steps.get(step), dict): + continue + contract = steps[step] + assets = contract.get("assets") or {} + skill_name = str(assets.get("skillName") or "").strip() + if skill_name: + available.append(skill_name) + prompt = contract.get("prompt") or {} + template_file = str(prompt.get("templateFile") or "").strip() + if template_file: + root = str(Path(template_file).parent.parent).replace("\\", "/") + if root and root not in asset_roots: + asset_roots.append(root) + return available, ", ".join(asset_roots) + + +def _detect_workflow_track(project_root: Path) -> dict[str, Any]: + signals = _tea_project_signals(project_root) + explicit_override_present = _explicit_policy_path(project_root).is_file() + _, explicit_override_error = _explicit_policy_payload(project_root) + explicit_steps = _explicit_tea_steps(project_root) + explicit_policy = bool(explicit_steps) + explicit_policy_resolved, explicit_policy_error = _explicit_tea_policy_details(project_root) + explicit_policy_valid = explicit_policy_resolved is not None + if explicit_policy and explicit_policy_valid: + available_skills, assets_root = _resolved_explicit_tea_status(explicit_policy_resolved, explicit_steps) + missing_skills = [] + missing_assets = [] + assets_ok = True + else: + assets_root = _tea_detection_assets_root(project_root) + assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) + available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) + reasons: list[str] = [] + prompt = "" + recommended_track = "standard" + requires_confirmation = False + tea_capable = bool(signals) and assets_ok and not missing_skills + + if explicit_policy and explicit_policy_valid: + recommended_track = "tea" + reasons.append("Project already defines an explicit TEA story-automator policy override.") + elif explicit_policy: + reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") + if explicit_policy_error: + reasons.append(explicit_policy_error) + elif explicit_override_present and explicit_override_error: + reasons.append("Project defines a story-automator policy override, but it is invalid.") + reasons.append(explicit_override_error) + elif tea_capable: + recommended_track = "tea" + requires_confirmation = True + reasons.append("Detected TEA module files in the project.") + reasons.append("Required TEA skills are installed.") + reasons.append("TEA story-automator assets are available.") + prompt = "Detected TEA support for this project. Enable TEA automation for this run? [y/N]" + else: + if signals: + reasons.append("Detected TEA-related project files.") + if missing_skills: + reasons.append("Required TEA skills are missing, so TEA automation is not currently available.") + if missing_assets: + reasons.append("TEA story-automator assets are incomplete or missing.") + + return { + "ok": True, + "recommendedTrack": recommended_track, + "requiresConfirmation": requires_confirmation, + "prompt": prompt, + "teaDetected": explicit_policy or explicit_override_present or bool(signals), + "teaCapable": explicit_policy_valid if explicit_policy else tea_capable, + "explicitTeaPolicy": explicit_policy, + "signals": signals, + "availableSkills": available_skills, + "missingSkills": missing_skills, + "assetsRoot": assets_root, + "missingAssets": missing_assets, + "reasons": reasons, + } + + +def cmd_detect_workflow_track(args: list[str]) -> int: + project_root = Path(get_project_root()) + for idx, arg in enumerate(args): + if arg == "--project-root" and idx + 1 < len(args): + project_root = Path(args[idx + 1]).expanduser().resolve() + write_json(_detect_workflow_track(project_root)) + return 0 + + def cmd_sprint_compare(args: list[str]) -> int: state = "" sprint = "" @@ -220,9 +816,13 @@ def cmd_state_metrics(args: list[str]) -> int: continue if in_table and line.startswith("|"): parts = [part.strip() for part in line.split("|")] - if len(parts) >= 8 and parts[1]: + values = [part for part in parts[1:-1] if part] + if len(values) >= 2: + first_cell = values[0] + if re.fullmatch(r"-+", first_cell): + continue total += 1 - if any(token in parts[7].lower() for token in ("done", "complete", "completed")): + if any(token in values[-1].lower() for token in ("done", "complete", "completed")): completed += 1 continue if in_table and not line.startswith("|"): diff --git a/skills/bmad-story-automator/src/story_automator/core/agent_config.py b/skills/bmad-story-automator/src/story_automator/core/agent_config.py index 19b67cd9..ac934638 100644 --- a/skills/bmad-story-automator/src/story_automator/core/agent_config.py +++ b/skills/bmad-story-automator/src/story_automator/core/agent_config.py @@ -8,6 +8,7 @@ from .common import ensure_dir, file_exists, iso_now, read_text, write_atomic from .frontmatter import find_frontmatter_value +from .runtime_policy import PolicyError, load_policy_shape_for_state, story_task_sequence from .runtime_layout import runtime_provider @@ -193,11 +194,15 @@ def extract_json_block(text: str) -> str: def build_agents_file(state_file: str | Path, complexity_file: str | Path, output_path: str | Path, config_json: str) -> dict[str, Any]: config = parse_agent_config_json(config_json) complexity_payload = json.loads(read_text(complexity_file)) + try: + tasks_in_scope = story_task_sequence(load_policy_shape_for_state(state_file)) + except PolicyError as exc: + return {"ok": False, "error": "policy_invalid", "reason": str(exc)} stories = [] for story in complexity_payload.get("stories", []): level = str(((story.get("complexity") or {}).get("level")) or "medium").strip().lower() or "medium" tasks = {} - for task in ("create", "dev", "auto", "review"): + for task in tasks_in_scope: primary, fallback, model = resolve_agent_for_task(config, level, task) entry: dict[str, Any] = { "primary": primary, diff --git a/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py index a0cd393e..224154e4 100644 --- a/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py @@ -10,7 +10,7 @@ from .utils import ensure_dir, get_project_root, iso_now, md5_hex8, read_text, write_atomic VALID_TOP_LEVEL_KEYS = {"version", "snapshot", "runtime", "workflow", "steps"} -VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro"} +VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro", "atdd", "test_automate", "test_review", "trace", "nfr"} VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} VALID_PARSER_PROVIDERS = {"claude"} @@ -19,8 +19,7 @@ def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]: root = Path(project_root or get_project_root()).resolve() bundle_root = bundled_skill_root(root) - policy = _read_json(bundle_root / "data" / "orchestration-policy.json") - _validate_policy_shape(policy) + policy = _load_bundled_policy_shape(root) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundle_root) else: @@ -32,14 +31,23 @@ class PolicyError(ValueError): pass -def load_effective_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]: +def load_effective_policy( + project_root: str | None = None, + *, + resolve_assets: bool = True, + inline_override: dict[str, Any] | None = None, +) -> dict[str, Any]: root = Path(project_root or get_project_root()).resolve() bundled = load_bundled_policy(str(root), resolve_assets=False) override_path = root / "_bmad" / "bmm" / "story-automator.policy.json" - override = _read_json(override_path) if override_path.is_file() else {} - policy = _deep_merge(bundled, override) + try: + override = _read_json(override_path) if override_path.is_file() else {} + except OSError as exc: + raise PolicyError(f"project override unreadable: {override_path}") from exc + policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) _clear_resolved_fields(policy) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root)) @@ -66,9 +74,9 @@ def load_runtime_policy( return load_effective_policy(str(root), resolve_assets=resolve_assets) -def snapshot_effective_policy(project_root: str | None = None) -> dict[str, Any]: +def snapshot_effective_policy(project_root: str | None = None, *, inline_override: dict[str, Any] | None = None) -> dict[str, Any]: root = Path(project_root or get_project_root()).resolve() - policy = load_effective_policy(str(root)) + policy = load_effective_policy(str(root), inline_override=inline_override) snapshot_dir = _resolve_snapshot_dir(policy, root) ensure_dir(snapshot_dir) stable_json = _stable_policy_json(policy) @@ -96,20 +104,13 @@ def load_policy_snapshot( if not path.is_absolute(): path = root / path path = _ensure_within(path, root, "policy snapshot") - if not path.is_file(): - raise PolicyError(f"policy snapshot missing: {path}") try: - raw = read_text(path) + snapshot_exists = path.is_file() except OSError as exc: raise PolicyError(f"policy snapshot unreadable: {path}") from exc - actual_hash = md5_hex8(raw) - if expected_hash and actual_hash != expected_hash: - raise PolicyError(f"policy snapshot hash mismatch: expected {expected_hash}, got {actual_hash}") - try: - policy = json.loads(raw) - except json.JSONDecodeError as exc: - raise PolicyError(f"policy json invalid: {path}") from exc - _validate_policy_shape(policy) + if not snapshot_exists: + raise PolicyError(f"policy snapshot missing: {path}") + policy = _load_policy_snapshot_shape(path, expected_hash=expected_hash) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root)) else: @@ -139,6 +140,28 @@ def load_policy_for_state( return load_bundled_policy(str(root), resolve_assets=resolve_assets) +def load_policy_shape_for_state(state_file: str | Path, project_root: str | None = None) -> dict[str, Any]: + root = Path(project_root or get_project_root()).resolve() + try: + fields = parse_simple_frontmatter(read_text(state_file)) + except OSError as exc: + raise PolicyError(f"state file unreadable: {state_file}") from exc + snapshot_file, snapshot_hash, legacy_mode = _state_policy_mode(fields) + if not legacy_mode: + path = Path(snapshot_file) + if not path.is_absolute(): + path = root / path + path = _ensure_within(path, root, "policy snapshot") + try: + snapshot_exists = path.is_file() + except OSError as exc: + raise PolicyError(f"policy snapshot unreadable: {path}") from exc + if not snapshot_exists: + raise PolicyError(f"policy snapshot missing: {path}") + return _load_policy_snapshot_shape(path, expected_hash=snapshot_hash) + return _load_bundled_policy_shape(root) + + def summarize_state_policy_fields(fields: dict[str, Any], *, project_root: str | Path | None = None) -> tuple[str, str, str, str, str]: policy_version = str(fields.get("policyVersion") or "").strip() try: @@ -188,6 +211,15 @@ def review_max_cycles(policy: dict[str, Any]) -> int: return int(repeat.get("maxCycles", 5)) +def workflow_sequence(policy: dict[str, Any]) -> list[str]: + sequence = ((policy.get("workflow") or {}).get("sequence")) or [] + return [str(step) for step in sequence if isinstance(step, str) and step] + + +def story_task_sequence(policy: dict[str, Any]) -> list[str]: + return [step for step in workflow_sequence(policy) if step != "retro"] + + def crash_max_retries(policy: dict[str, Any]) -> int: crash = ((policy.get("workflow") or {}).get("crash")) or {} return int(crash.get("maxRetries", 2)) @@ -216,6 +248,36 @@ def bundled_skill_root(project_root: str | Path | None = None) -> Path: raise PolicyError("bundled policy not found") from exc +def _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[str, Any]: + root = Path(project_root or get_project_root()).resolve() + bundle_root = bundled_skill_root(root) + policy_path = bundle_root / "data" / "orchestration-policy.json" + try: + policy = _read_json(policy_path) + except OSError as exc: + raise PolicyError(f"policy unreadable: {policy_path}") from exc + _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) + return policy + + +def _load_policy_snapshot_shape(path: Path, *, expected_hash: str = "") -> dict[str, Any]: + try: + raw = read_text(path) + except OSError as exc: + raise PolicyError(f"policy snapshot unreadable: {path}") from exc + actual_hash = md5_hex8(raw) + if expected_hash and actual_hash != expected_hash: + raise PolicyError(f"policy snapshot hash mismatch: expected {expected_hash}, got {actual_hash}") + try: + policy = json.loads(raw) + except json.JSONDecodeError as exc: + raise PolicyError(f"policy json invalid: {path}") from exc + _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) + return policy + + def _read_json(path: str | Path) -> dict[str, Any]: try: payload = json.loads(read_text(path)) @@ -258,6 +320,20 @@ def _clear_resolved_fields(policy: dict[str, Any]) -> None: success.pop("contractHash", None) +def _prune_unreferenced_steps(policy: dict[str, Any]) -> None: + steps = policy.get("steps") + workflow = policy.get("workflow") + if not isinstance(steps, dict) or not isinstance(workflow, dict): + return + sequence = workflow.get("sequence") or [] + if not isinstance(sequence, list): + return + referenced = {step for step in sequence if isinstance(step, str)} + if not referenced: + return + policy["steps"] = {name: contract for name, contract in steps.items() if name in referenced} + + def _apply_legacy_env(policy: dict[str, Any]) -> None: review_cycles = os.environ.get("MAX_REVIEW_CYCLES") crash_retries = os.environ.get("MAX_CRASH_RETRIES") diff --git a/skills/bmad-story-automator/steps-c/step-01b-continue.md b/skills/bmad-story-automator/steps-c/step-01b-continue.md index 5226d7c7..d9151249 100644 --- a/skills/bmad-story-automator/steps-c/step-01b-continue.md +++ b/skills/bmad-story-automator/steps-c/step-01b-continue.md @@ -136,8 +136,8 @@ Active sessions: {count or 'None'} - READY → `{preflightFinalizeStep}` - INITIALIZING → `{preflightConfigStep}` - IN_PROGRESS / PAUSED → route by `currentStep`: - - `step-03-execute` or `create` or `dev` → `{executeStep}` - - `step-03a-execute-review` or `auto` or `review` → `{executeReviewStep}` + - `step-03-execute` or `create` or `atdd` or `dev` → `{executeStep}` + - `step-03a-execute-review` or `auto` or `test_automate` or `test_review` or `nfr` or `trace` or `review` → `{executeReviewStep}` - `step-03b-execute-finish` or `commit` or `retro` → `{executeFinishStep}` - `step-03c-execute-complete` → `{executeCompleteStep}` - (default) → `{executeStep}` diff --git a/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md b/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md index 96d26595..93757ff2 100644 --- a/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md +++ b/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md @@ -42,6 +42,56 @@ Enter choices (e.g., `N 1` or `Y 3`): Store responses as `skip_automate` (true/false) and `max_parallel` (integer). +### 1b. Detect TEA Support (Optional) + +Run TEA detection before offering any TEA-specific configuration: + +```bash +tea_detect=$("{buildStateDoc}" detect-workflow-track) +tea_recommended=$(echo "$tea_detect" | jq -r '.recommendedTrack') +tea_prompt=$(echo "$tea_detect" | jq -r '.prompt') +tea_capable=$(echo "$tea_detect" | jq -r '.teaCapable') +``` + +If the current runtime is not a POSIX shell, translate this command and JSON parsing flow to the native shell or scripting environment in use (for example PowerShell on Windows) while preserving the same logic. + +If `tea_recommended == "tea"` and `tea_capable == "true"`: + +```text +Detected TEA support for this project. Enable TEA automation for this run? [y/N] +``` + +**Wait.** + +- If `y`: set `workflow_track=tea` +- Otherwise: set `workflow_track=standard` + +If TEA is not recommended or not capable: +- set `workflow_track=standard` +- if `tea_detect.reasons` contains missing-skill or missing-asset warnings, display them once and continue in standard mode + +### 1c. Configure TEA Options (Only When Explicitly Enabling TEA) + +Only if the user explicitly chooses the TEA track for this run, collect TEA-specific choices separately. Do not change the standard-path interaction contract above. + +For the TEA track, state clearly: +- **Mandatory automated TEA core:** `atdd`, `test_automate`, `test_review`, `trace` +- **Optional automated TEA add-on:** `nfr` +- **Optional epic-level add-on:** `retro` +- `validate-create-story` remains advisory only and is not automated in v1 +- `checkpoint-preview` is out of scope for story-automator and must not be modeled as an in-run checkpoint +- legacy `qa-generate-e2e-tests` is not added on the TEA track because `test_automate` supersedes it + +Collect: +- `selected_optional_steps` = zero or more of `retro`, `nfr` +- `workflow_track` = `tea` + +If `validate-create-story` is referenced elsewhere while `workflow_track == tea`, treat it as advisory only: do not add it to `selected_optional_steps`, and do not expect any automated action from story-automator in v1. + +If TEA is not explicitly enabled: +- `workflow_track` = `standard` +- `selected_optional_steps` = `[]` + ### 2. Configure Agent (Complexity-Aware) Using the complexity data from `stories_json`, present agent configuration options that reference the actual complexity breakdown. @@ -103,6 +153,11 @@ Display configuration summary: - Agent configuration - Execution settings +Only for the TEA track, add a separate TEA summary block: +- Mandatory TEA core +- Selected optional automated steps +- Advisory ignored items, if any + Pause for confirmation before starting execution. ### 3b. Confirm Autonomous Start (Optional Checkpoint) @@ -140,9 +195,11 @@ config_json=$(jq -n \ --arg currentStep "preflight" \ --arg aiCommand "$agent_cmd" \ --arg customInstructions "$custom_instructions" \ + --arg workflowTrack "$workflow_track" \ + --argjson selectedOptionalSteps "$selected_optional_steps" \ --argjson overrides "{\"skipAutomate\":$skip_automate,\"maxParallel\":$max_parallel}" \ --argjson agentConfig "$agent_config_json" \ - '{epic:$epic,epicName:$epicName,storyRange:$storyRange,status:$status,currentStory:null,currentStep:$currentStep,aiCommand:$aiCommand,customInstructions:$customInstructions,overrides:$overrides,agentConfig:$agentConfig}' + '{epic:$epic,epicName:$epicName,storyRange:$storyRange,status:$status,currentStory:null,currentStep:$currentStep,aiCommand:$aiCommand,customInstructions:$customInstructions,workflowTrack:$workflowTrack,selectedOptionalSteps:$selectedOptionalSteps,overrides:$overrides,agentConfig:$agentConfig}' ) state_result=$("{buildStateDoc}" build-state-doc --template "{stateTemplate}" --output-folder "{outputFolder}" --config-json "$config_json") diff --git a/skills/bmad-story-automator/steps-c/step-03-execute.md b/skills/bmad-story-automator/steps-c/step-03-execute.md index b7df134b..a7d6eeca 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -54,6 +54,7 @@ Load from state document (located via `{stateFilePattern}`; output folder `{outp - `storyRange`, `currentStory`, `currentStep` - `overrides` (skipAutomate, maxParallel) - `customInstructions` +- pinned workflow policy snapshot Resolve agent configuration using deterministic agents file (see `{retryStrategy}` for full function): ```bash @@ -64,6 +65,15 @@ state_file="{outputFile}" **IF resuming** (currentStory set): Skip to that point in loop. **IF fresh**: Display "**Starting build cycle for {count} stories...**" +### Workflow Sequence Rule + +The pinned workflow policy snapshot is authoritative for per-story task order. + +- Standard default path: `create -> dev -> auto -> review` +- TEA v1 opt-in path: `create -> atdd -> dev -> test_automate -> test_review -> trace -> review` + +Do not silently switch to TEA because TEA skills are installed. Only follow TEA steps when the pinned policy sequence explicitly includes them. + ## 🚨 CRITICAL: Execution Patterns **BEFORE executing any steps, read `{executionPatterns}` for:** @@ -92,12 +102,10 @@ state_file="{outputFile}" --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$state_file" -# Initialize Story Progress row -tmp_state=$(mktemp) -awk -v row="| {story_id} | - | - | - | - | - | in-progress |" ' - /^$/ { print row } - { print } -' "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file" +# Mark the current story row in progress using the rendered table headers +"$scripts" orchestrator-helper state-progress "$state_file" \ + --story "{story_id}" \ + --set status=in-progress ``` Display: "**Story {N}/{total}: {title}**" @@ -148,13 +156,35 @@ validation=$("$scripts" orchestrator-helper verify-step create {story_id} --stat - If `validation.verified == true`: ```bash # Update Story Progress: mark create-story done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | - | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file" + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set create=done \ + --set status=in-progress ``` → proceed to B - If `validation.verified == false` AND attempts < 5 → retry with next agent (see `{retryStrategy}`) - If `validation.verified == false` AND attempts == 5 → escalate (all retries exhausted) +### A.1 ATDD +*Run only if the pinned policy sequence includes `atdd`* + +Use the same spawn/monitor/parse pattern as other session-exit steps: + +```bash +session=$("$scripts" tmux-wrapper spawn atdd {epic} {story_id} \ + --agent "$current_agent" \ + --command "$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --state-file "$state_file")") +result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") +"$scripts" tmux-wrapper kill "$session" +parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" atdd --state-file "$state_file") +``` + +- If `next_action == "proceed"` → continue to dev +- If `next_action == "retry"` or session crashed → retry with fallback pattern +- Treat successful completion as execution completion only; TEA artifact verification is not part of v1 + +When updating progress, do not assume the standard fixed column order if TEA mode is active. + ### B. Dev Story **Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents. @@ -190,8 +220,10 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') - If `next_action == "proceed"`: ```bash # Update Story Progress: mark dev-story done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file" + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set dev=done \ + --set status=in-progress ``` → proceed to C (next step) - If `next_action == "retry"` OR `result.final_state == "crashed"`: @@ -201,7 +233,7 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') ## Auto-Proceed to Review Phase -Display: "**Dev story complete. Proceeding to automate and code review...**" +Display: "**Dev story complete. Proceeding to the next policy-defined quality phase...**" ```bash "$scripts" orchestrator-helper state-update "$state_file" \ diff --git a/skills/bmad-story-automator/steps-c/step-03a-execute-review.md b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md index e38bb421..89c568f1 100644 --- a/skills/bmad-story-automator/steps-c/step-03a-execute-review.md +++ b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md @@ -10,7 +10,7 @@ reviewLoop: '../data/code-review-loop.md' # Step 3a: Execute Review Phase -**Goal:** Run automate (guardrails) and code review loop for the current story. +**Goal:** Run the policy-defined quality phase and final code review loop for the current story. **Interaction mode:** Deterministic autonomous execution. --- @@ -26,8 +26,22 @@ Set: `scripts="{scriptsDir}"` ## Story Loop (Continue from Step 3) +### C. Pre-Review Quality Steps + +The pinned workflow policy snapshot decides which pre-review quality steps apply. + +- Standard default path: optional `auto`, then `review` +- TEA v1 opt-in path: `test_automate`, `test_review`, optional `nfr`, `trace`, then `review` + +For TEA v1: + +- `test_automate`, `test_review`, optional `nfr`, and `trace` use the same spawn/monitor/parse pattern as other session-exit steps +- successful completion means execution completed, not artifact verification +- use the current per-task agent selection from the agents file +- when updating progress, do not assume the standard fixed column order if TEA mode is active + ### C. Automate (Guardrails) -*Skip if `overrides.skipAutomate`* +*Run only if the pinned policy sequence includes `auto` and `overrides.skipAutomate` is false* **Apply retry/fallback pattern from `{retryStrategy}`:** Non-blocking, but still retry on failure. @@ -49,20 +63,44 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") - SUCCESS: ```bash # Update Story Progress: mark automate done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "$scripts" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set auto=done \ + --set status=in-progress ``` Display: `[story {N}/{total}] automate -> done` → proceed to D - FAILURE → retry up to 3 attempts (non-blocking, so fewer retries), then log warning: ```bash # Update Story Progress: mark automate skipped - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | skip | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "$scripts" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set auto=skip \ + --set status=in-progress ``` Display: `[story {N}/{total}] automate -> skip (non-blocking)` → proceed to D +### C.1 TEA Quality Steps + +*Run only if the pinned policy sequence includes any of: `test_automate`, `test_review`, `nfr`, `trace`* + +For each enabled TEA step: + +```bash +session=$("$scripts" tmux-wrapper spawn {step} {epic} {story_id} \ + --agent "$current_agent" \ + --command "$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --state-file "$state_file")") +result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") +"$scripts" tmux-wrapper kill "$session" +parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" {step} --state-file "$state_file") +``` + +- If `next_action == "proceed"` → continue to the next policy-defined step +- If `next_action == "retry"` or the session crashes → apply the retry/fallback pattern +- TEA v1 success for these steps means session execution completed successfully +- When a TEA quality step completes, update only that named progress column via `state-progress` rather than rewriting the whole row + ### D. Code Review Loop **See `{reviewLoop}` for complete script-based review cycle with v2.3 per-task agent configuration.** @@ -98,8 +136,10 @@ Key points: - **States:** `completed` (verified): ```bash # Update Story Progress: mark code-review done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "$scripts" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set review=done \ + --set status=in-progress ``` Display: `[story {N}/{total}] review -> done` → E | `incomplete` → count as failed attempt, retry until maxCycles, then CRITICAL escalate (Trigger #8) diff --git a/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md b/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md index 8da7759f..5b5c8f1a 100644 --- a/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md +++ b/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md @@ -27,8 +27,10 @@ ok=$(echo "$commit" | jq -r '.ok') - If `ok == true`: ```bash # Update Story Progress: mark git-commit done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "{scriptsDir}" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set git-commit=done \ + --set status=in-progress ``` → proceed to F - If `ok == false` → log warning and escalate @@ -60,8 +62,9 @@ Display: "**✅ Story {N} complete.**" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Story {story_id}: ✅ complete (commit + sprint-status verified)" >> "{outputFile}" # Update Story Progress: mark story done -tmp_state=$(mktemp) -sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | done |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" +"{scriptsDir}" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set status=done ``` Display: `[story {N}/{total}] finalize -> done` diff --git a/skills/bmad-story-automator/templates/state-document.md b/skills/bmad-story-automator/templates/state-document.md index de50b019..df174707 100644 --- a/skills/bmad-story-automator/templates/state-document.md +++ b/skills/bmad-story-automator/templates/state-document.md @@ -80,6 +80,8 @@ completedSessions: [] **Custom Instructions:** {{customInstructions}} +{{teaConfigurationBlock}} + --- ## Story Progress diff --git a/skills/bmad-story-automator/workflow.md b/skills/bmad-story-automator/workflow.md index 9dacea3b..d1053f45 100644 --- a/skills/bmad-story-automator/workflow.md +++ b/skills/bmad-story-automator/workflow.md @@ -10,7 +10,7 @@ outputFolder: '{output_folder}/story-automator' # story-automator -**Goal:** Automate the entire development build cycle (create-story → dev-story → automate → code-review → retrospective) for multiple stories in one or more epics, using T-Mux to spawn isolated AI agent sessions while providing visibility, resumability, and graceful decision escalation. +**Goal:** Automate the entire development build cycle for multiple stories in one or more epics, using T-Mux to spawn isolated AI agent sessions while providing visibility, resumability, and graceful decision escalation. The default path remains `create-story → dev-story → automate → code-review → retrospective`. Projects may explicitly opt into a TEA-assisted path through the pinned runtime policy snapshot. **Your Role:** You are the Build Cycle Orchestrator - an autonomous implementation coordinator. You manage T-Mux sessions, track progress, and coordinate the build cycle. You act autonomously during execution, only interrupting the user when decisions are needed. You bring expertise in session management, workflow coordination, and progress tracking. The user brings their epic(s), stories, and domain context. Work efficiently with minimal interruption. @@ -18,10 +18,12 @@ outputFolder: '{output_folder}/story-automator' - Preflight/continue/user-choice phases: collaborative, ask one clarifying question when input is ambiguous. - Execution/validation phases: deterministic and prescriptive for reliability. -**Meta-Context:** This orchestrator spawns and monitors other workflows (create-story, dev-story, automate, code-review, retrospective) in isolated T-Mux sessions. It tracks state for full resumability and escalates to the user only when autonomous decisions cannot be made. +**Meta-Context:** This orchestrator spawns and monitors other workflows (create-story, dev-story, automate, code-review, retrospective, and TEA-specific steps when explicitly configured) in isolated T-Mux sessions. It tracks state for full resumability and escalates to the user only when autonomous decisions cannot be made. **Runtime Policy:** Machine settings live in `data/orchestration-policy.json`. Prompt contracts, parse contracts, retry budgets, and verifier selection should follow the pinned policy snapshot written at orchestration start. +**TEA v1 Scope:** If the pinned policy explicitly includes TEA steps, treat them as an opt-in per-story path. TEA step completion in v1 means successful session execution only. Artifact-level verification for TEA steps is deferred. Final story completion remains gated by the `review` verifier. + --- ## MULTI-EPIC SUPPORT diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 32f27469..c728c895 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -240,6 +240,23 @@ def test_resolve_agents_returns_model(self) -> None: result = resolve_agents(agents_file, "9.1", "dev") self.assertEqual(result["model"], "") + def test_build_agents_file_returns_structured_policy_error(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + state_file = tmp_path / "state.md" + state_file.write_text( + "---\npolicySnapshotFile: missing.json\npolicySnapshotHash: deadbeef\n---\n", + encoding="utf-8", + ) + complexity_file = tmp_path / "complexity.json" + complexity_file.write_text(json.dumps({"stories": []}), encoding="utf-8") + output = tmp_path / "agents.md" + result = build_agents_file(state_file, complexity_file, output, json.dumps({"defaultPrimary": "claude"})) + self.assertFalse(result["ok"]) + self.assertEqual(result["error"], "policy_invalid") + self.assertIn("policy snapshot missing:", result["reason"]) + self.assertTrue(result["reason"].endswith("missing.json")) + class OrchestratorEpicAgentsModelTests(unittest.TestCase): def test_parse_agent_config_extracts_default_model(self) -> None: @@ -263,6 +280,39 @@ def test_resolve_agent_picks_model_per_task(self) -> None: _primary, _fallback, model = resolve_agent(config, "medium", "dev") self.assertEqual(model, "claude-opus-4-7") + def test_agents_build_returns_json_for_invalid_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + state_file = tmp_path / "state.md" + state_file.write_text( + "---\npolicySnapshotFile: missing.json\npolicySnapshotHash: deadbeef\n---\n", + encoding="utf-8", + ) + complexity_file = tmp_path / "complexity.json" + complexity_file.write_text(json.dumps({"stories": []}), encoding="utf-8") + output = tmp_path / "agents.md" + stdout = io.StringIO() + with redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "agents-build", + "--state-file", + str(state_file), + "--complexity-file", + str(complexity_file), + "--output", + str(output), + "--config-json", + json.dumps({"defaultPrimary": "claude"}), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["error"], "policy_invalid") + self.assertIn("policy snapshot missing:", payload["reason"]) + self.assertTrue(payload["reason"].endswith("missing.json")) + class StateDocModelSerializationTests(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/test_orchestrator_parse.py b/tests/test_orchestrator_parse.py index a82454c2..75e04520 100644 --- a/tests/test_orchestrator_parse.py +++ b/tests/test_orchestrator_parse.py @@ -145,6 +145,29 @@ def test_parser_runtime_uses_policy_settings(self) -> None: self.assertEqual(mock_run.call_args.args[:4], ("claude", "-p", "--model", "sonnet")) self.assertEqual(mock_run.call_args.kwargs["timeout"], 33) + def test_parse_schema_supports_tea_step_from_override(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {"atdd": _tea_steps_override(self.project_root)["atdd"]}, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": str(self.project_root)}), patch( + "story_automator.commands.orchestrator_parse.run_cmd", + return_value=CommandResult('{"status":"SUCCESS","failing_tests_created":true,"summary":"ok","next_action":"proceed"}', 0), + ), redirect_stdout(stdout): + code = parse_output_action([str(self.output_file), "atdd"]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["failing_tests_created"]) + def _install_bundle(self) -> None: source_skill = REPO_ROOT / "skills" / "bmad-story-automator" source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" @@ -165,6 +188,19 @@ def _install_required_skills(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") (self.project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text("# checklist\n", encoding="utf-8") + def _install_tea_skills(self) -> None: + _write_tea_assets(self.project_root) + for name in ( + "bmad-tea-testarch-atdd", + "bmad-tea-testarch-automate", + "bmad-tea-testarch-test-review", + "bmad-tea-testarch-trace", + ): + skill_dir = self.project_root / ".claude" / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") + def _build_state(self) -> Path: output_dir = self.project_root / "_bmad-output" / "story-automator" output_dir.mkdir(parents=True, exist_ok=True) @@ -192,5 +228,33 @@ def _build_state(self) -> Path: return Path(json.loads(stdout.getvalue())["path"]) +def _write_tea_assets(project_root: Path) -> None: + prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" + parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override(project_root: Path) -> dict[str, object]: + return { + "atdd": { + "label": "atdd", + "assets": { + "skillName": "bmad-tea-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/atdd.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/atdd.json"}, + "success": {"verifier": "session_exit"}, + } + } + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index b3d9b475..33f66697 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -10,6 +10,7 @@ from story_automator.core.runtime_policy import ( PolicyError, load_effective_policy, + load_policy_shape_for_state, load_policy_snapshot, load_runtime_policy, snapshot_effective_policy, @@ -45,11 +46,36 @@ def test_project_override_deep_merges_and_arrays_replace(self) -> None: self.assertEqual(policy["workflow"]["sequence"], ["create", "review"]) self.assertEqual(policy["steps"]["review"]["prompt"]["defaultExtraInstruction"], "fix critical issues only") + def test_inline_override_deep_merges_after_project_override(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev", "review"]}}) + policy = load_effective_policy( + str(self.project_root), + inline_override={"workflow": {"repeat": {"review": {"maxCycles": 3}}}}, + ) + self.assertEqual(policy["workflow"]["sequence"], ["create", "dev", "review"]) + self.assertEqual(policy["workflow"]["repeat"]["review"]["maxCycles"], 3) + def test_invalid_step_name_rejected(self) -> None: self._write_override({"steps": {"ship": {"success": {"verifier": "session_exit"}}}}) with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_tea_steps_allowed_when_explicitly_configured_and_installed(self) -> None: + self._install_tea_skills() + steps = _tea_steps_override(self.project_root) + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": steps, + } + ) + policy = load_effective_policy(str(self.project_root)) + self.assertEqual( + policy["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], + ) + self.assertEqual(policy["steps"]["trace"]["assets"]["skillName"], "bmad-tea-testarch-trace") + def test_invalid_verifier_name_rejected(self) -> None: self._write_override({"steps": {"review": {"success": {"verifier": "nope"}}}}) with self.assertRaises(PolicyError): @@ -60,6 +86,19 @@ def test_required_asset_missing_fails(self) -> None: with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_tea_policy_fails_when_required_tea_skill_missing(self) -> None: + steps = _tea_steps_override(self.project_root) + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": { + "atdd": steps["atdd"], + }, + } + ) + with self.assertRaises(PolicyError): + load_effective_policy(str(self.project_root)) + def test_dependency_workflow_file_optional(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-create-story" / "workflow.md").unlink() policy = load_effective_policy(str(self.project_root)) @@ -103,6 +142,34 @@ def test_malformed_override_json_raises_policy_error(self) -> None: with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_unreadable_override_file_is_wrapped_as_policy_error(self) -> None: + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override_path = (override_dir / "story-automator.policy.json").resolve() + override_path.write_text("{}", encoding="utf-8") + + original_read_json = __import__("story_automator.core.runtime_policy", fromlist=["_read_json"])._read_json + + def raising_read_json(path): + if Path(path).resolve() == override_path: + raise OSError("permission denied") + return original_read_json(path) + + with patch("story_automator.core.runtime_policy._read_json", side_effect=raising_read_json): + with self.assertRaisesRegex(PolicyError, r"project override unreadable: .*story-automator\.policy\.json"): + load_effective_policy(str(self.project_root)) + + def test_bundled_policy_read_failure_is_wrapped_as_policy_error(self) -> None: + policy_path = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "data" / "orchestration-policy.json" + policy_path.unlink() + policy_path.mkdir() + with patch( + "story_automator.core.runtime_policy.bundled_skill_root", + return_value=self.project_root / ".claude" / "skills" / "bmad-story-automator", + ): + with self.assertRaisesRegex(PolicyError, r"policy unreadable: .*orchestration-policy\.json"): + load_effective_policy(str(self.project_root)) + def test_invalid_assets_type_rejected(self) -> None: self._write_override({"steps": {"review": {"assets": []}}}) with self.assertRaises(PolicyError): @@ -263,6 +330,46 @@ def test_explicit_directory_state_file_raises_policy_error(self) -> None: with self.assertRaisesRegex(PolicyError, "state file unreadable"): load_runtime_policy(str(self.project_root), state_file=str(self.project_root)) + def test_load_policy_shape_for_state_reports_missing_snapshot_precisely(self) -> None: + state_file = self.project_root / "orchestration-missing-snapshot.md" + state_file.write_text( + "---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(PolicyError, r"policy snapshot missing: .*missing\.json"): + load_policy_shape_for_state(str(state_file), project_root=str(self.project_root)) + + def test_load_policy_shape_for_state_wraps_snapshot_stat_errors(self) -> None: + state_file = self.project_root / "orchestration-unreadable-snapshot.md" + state_file.write_text( + "---\npolicySnapshotFile: \"blocked.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", + encoding="utf-8", + ) + blocked_path = (self.project_root / "blocked.json").resolve() + original_is_file = Path.is_file + + def raising_is_file(path: Path) -> bool: + if path.resolve() == blocked_path: + raise PermissionError("permission denied") + return original_is_file(path) + + with patch("pathlib.Path.is_file", autospec=True, side_effect=raising_is_file): + with self.assertRaisesRegex(PolicyError, r"policy snapshot unreadable: .*blocked\.json"): + load_policy_shape_for_state(str(state_file), project_root=str(self.project_root)) + + def test_load_policy_snapshot_wraps_snapshot_stat_errors(self) -> None: + blocked_path = (self.project_root / "blocked.json").resolve() + original_is_file = Path.is_file + + def raising_is_file(path: Path) -> bool: + if path.resolve() == blocked_path: + raise PermissionError("permission denied") + return original_is_file(path) + + with patch("pathlib.Path.is_file", autospec=True, side_effect=raising_is_file): + with self.assertRaisesRegex(PolicyError, r"policy snapshot unreadable: .*blocked\.json"): + load_policy_snapshot("blocked.json", project_root=str(self.project_root), expected_hash="deadbeef") + def _install_bundle(self) -> None: source_skill = REPO_ROOT / "skills" / "bmad-story-automator" source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" @@ -293,6 +400,113 @@ def _write_override(self, payload: dict[str, object]) -> None: override_dir.mkdir(parents=True, exist_ok=True) (override_dir / "story-automator.policy.json").write_text(json.dumps(payload), encoding="utf-8") + def _install_tea_skills(self) -> None: + _write_tea_assets(self.project_root) + for name in ( + "bmad-tea-testarch-atdd", + "bmad-tea-testarch-automate", + "bmad-tea-testarch-test-review", + "bmad-tea-testarch-trace", + ): + skill_dir = self.project_root / ".claude" / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") + + +def _write_tea_assets(project_root: Path) -> None: + prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" + parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") + (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") + (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dict[str, object]: + steps: dict[str, object] = { + "atdd": { + "label": "atdd", + "assets": { + "skillName": "bmad-tea-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/atdd.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/atdd.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": "bmad-tea-testarch-automate", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_automate.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_automate.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": "bmad-tea-testarch-test-review", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_review.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-tea-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/trace.json"}, + "success": {"verifier": "session_exit"}, + }, + } + if include_nfr: + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": "bmad-tea-testarch-nfr", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/nfr.json"}, + "success": {"verifier": "session_exit"}, + } + return steps + if __name__ == "__main__": unittest.main() diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 531883f3..d0ae9722 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -7,10 +7,17 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path +from unittest.mock import patch from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper -from story_automator.commands.state import cmd_build_state_doc, cmd_validate_state +from story_automator.commands.state import ( + cmd_build_run_policy, + cmd_build_state_doc, + cmd_detect_workflow_track, + cmd_state_metrics, + cmd_validate_state, +) from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper @@ -461,6 +468,692 @@ def test_build_state_doc_returns_json_on_policy_snapshot_failure(self) -> None: self.assertFalse(payload["ok"]) self.assertEqual(payload["error"], "policy_snapshot_failed") + def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state({"workflowTrack": "tea"}) + text = state_file.read_text(encoding="utf-8") + self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | trace | code-review | git-commit | Status |", text) + self.assertIn("| 1.1 | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |", text) + + def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_checkpoint(self) -> None: + self._install_tea_skills(include_nfr=True) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr", "retro", "qa-generate-e2e-tests", "validate-create-story"], + "manualCheckpoints": ["checkpoint-preview"], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], + ) + self.assertEqual(payload["manualCheckpoints"], []) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + self.assertTrue(any("superseded by TEA test_automate" in note for note in payload["notes"])) + self.assertTrue(any("not yet automated by story-automator" in note for note in payload["notes"])) + self.assertTrue(any("out of scope for story-automator" in note for note in payload["notes"])) + + def test_detect_workflow_track_recommends_tea_when_project_is_capable(self) -> None: + self._install_tea_skills() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["requiresConfirmation"]) + self.assertTrue(payload["teaCapable"]) + self.assertIn("Detected TEA support for this project", payload["prompt"]) + + def test_detect_workflow_track_accepts_canonical_tea_skill_names(self) -> None: + self._install_tea_skills(canonical=True) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual( + payload["availableSkills"], + [ + "bmad-testarch-atdd", + "bmad-testarch-automate", + "bmad-testarch-test-review", + "bmad-testarch-trace", + ], + ) + + def test_detect_workflow_track_uses_bundled_tea_adapter_assets(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") + self.assertEqual(payload["missingAssets"], []) + + def test_detect_workflow_track_falls_back_when_project_tea_assets_are_incomplete(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + incomplete_dir = self.project_root / "_bmad" / "tea" / "story-automator" / "prompts" + incomplete_dir.mkdir(parents=True, exist_ok=True) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") + self.assertEqual(payload["missingAssets"], []) + + def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: + _write_tea_assets(self.project_root) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["missingSkills"]) + + def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertFalse(payload["requiresConfirmation"]) + self.assertTrue(payload["explicitTeaPolicy"]) + + def test_detect_workflow_track_trusts_valid_explicit_tea_policy_with_custom_asset_root(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + custom_root = self.project_root / "custom-tea-assets" + _write_tea_assets(self.project_root, root=custom_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override_steps = _tea_steps_override(self.project_root, canonical=True, assets_root="custom-tea-assets") + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": override_steps, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "custom-tea-assets") + self.assertEqual(payload["missingAssets"], []) + self.assertEqual( + payload["availableSkills"], + [ + "bmad-testarch-atdd", + "bmad-testarch-automate", + "bmad-testarch-test-review", + "bmad-testarch-trace", + ], + ) + + def test_detect_workflow_track_reports_multiple_asset_roots_for_valid_explicit_policy(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + _write_tea_assets(self.project_root) + custom_root = self.project_root / "custom-tea-assets" + _write_tea_assets(self.project_root, root=custom_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override_steps = _tea_steps_override(self.project_root, canonical=True) + override_steps["trace"] = _tea_steps_override( + self.project_root, + canonical=True, + assets_root="custom-tea-assets", + )["trace"] + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": override_steps, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["missingAssets"], []) + self.assertEqual(payload["assetsRoot"], "_bmad/tea/story-automator, custom-tea-assets") + + def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {}, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(any("workflow.sequence references missing step: atdd" in note for note in payload["reasons"])) + + def test_detect_workflow_track_reports_invalid_explicit_override_file(self) -> None: + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text("{bad json", encoding="utf-8") + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["teaDetected"]) + self.assertTrue(any("story-automator policy override, but it is invalid" in note for note in payload["reasons"])) + self.assertTrue(any("invalid JSON" in note for note in payload["reasons"])) + + def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(self) -> None: + _write_tea_assets(self.project_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["explicitTeaPolicy"]) + self.assertTrue(any("required TEA skills or assets are missing" in note for note in payload["reasons"])) + + def test_detect_workflow_track_rejects_explicit_tea_policy_when_nfr_skill_is_missing(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root, include_nfr=True), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["explicitTeaPolicy"]) + self.assertIn("bmad-testarch-nfr", payload["missingSkills"]) + + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: + self._install_tea_skills(include_nfr=True) + state_file = self._build_state( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + "manualCheckpoints": ["checkpoint-preview"], + } + ) + text = state_file.read_text(encoding="utf-8") + self.assertIn('workflowTrack: "tea"', text) + self.assertIn('selectedOptionalSteps: ["nfr"]', text) + self.assertIn('manualCheckpoints: []', text) + self.assertIn("**TEA Configuration:**", text) + self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) + self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + + def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root, include_nfr=True), + } + ), + encoding="utf-8", + ) + state_file = self._build_state({"workflowTrack": "standard"}) + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + + def test_build_state_doc_legacy_config_stays_standard_despite_explicit_tea_override(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: + self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["policyOverride"]["steps"]["atdd"]["assets"]["skillName"], "bmad-testarch-atdd") + self.assertEqual(payload["policyOverride"]["steps"]["test_automate"]["assets"]["skillName"], "bmad-testarch-automate") + self.assertEqual(payload["policyOverride"]["steps"]["test_review"]["assets"]["skillName"], "bmad-testarch-test-review") + self.assertEqual(payload["policyOverride"]["steps"]["trace"]["assets"]["skillName"], "bmad-testarch-trace") + self.assertEqual(payload["policyOverride"]["steps"]["nfr"]["assets"]["skillName"], "bmad-testarch-nfr") + self.assertEqual(payload["policyOverride"]["steps"]["atdd"]["prompt"]["templateFile"], "data/tea-story-automator/prompts/tea_step.md") + self.assertEqual(payload["policyOverride"]["steps"]["nfr"]["parse"]["schemaFile"], "data/tea-story-automator/parse/tea_step.json") + + def test_build_run_policy_normalizes_workflow_track_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "policyOverride": {"workflow": {"sequence": ["create", "dev", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["workflowTrack"], "tea") + + def test_build_run_policy_normalizes_selected_optional_steps_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "selectedOptionalSteps": ["NFR", "Retro", None], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + + def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "manualCheckpoints": ["checkpoint-preview"], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["manualCheckpoints"], []) + self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) + + def test_build_run_policy_distinguishes_invalid_json_from_missing_config(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "{"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "invalid_config_json") + + def test_build_run_policy_rejects_non_object_json(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "[]"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "config_must_be_object") + + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], + ) + self.assertNotIn("nfr", payload["policyOverride"]["steps"]) + self.assertEqual(payload["selectedOptionalSteps"], []) + self.assertTrue(any("TEA NFR skill is not installed" in note for note in payload["notes"])) + + def test_build_run_policy_normalizes_selected_optional_steps_on_tea_track(self) -> None: + self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "selectedOptionalSteps": ["NFR", "Retro", None], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], + ) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + + def test_state_progress_updates_named_columns_in_tea_table(self) -> None: + self._install_tea_skills(include_nfr=True) + state_file = self._build_state( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + } + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "atdd=done", + "--set", + "nfr=done", + "--set", + "status=in-progress", + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + text = state_file.read_text(encoding="utf-8") + self.assertIn("| 1.1 | ⏳ | done | ⏳ | ⏳ | ⏳ | done | ⏳ | ⏳ | ⏳ | in-progress |", text) + + def test_state_progress_rejects_invalid_set_argument(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "invalid_set_argument") + self.assertEqual(payload["argument"], "status") + + def test_state_progress_rejects_story_column_updates(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "story=1.2", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "story_column_immutable") + + def test_state_progress_returns_structured_error_when_state_file_is_unreadable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("story_automator.commands.orchestrator.read_text", side_effect=OSError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unreadable") + + def test_state_progress_returns_structured_error_when_state_file_stat_is_unreadable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("story_automator.commands.orchestrator.file_exists", side_effect=PermissionError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unreadable") + + def test_state_progress_returns_structured_error_when_state_file_is_unwritable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("pathlib.Path.write_text", side_effect=OSError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unwritable") + + def test_state_metrics_skips_markdown_divider_row(self) -> None: + state_file = self.project_root / "metrics-state.md" + state_file.write_text( + "\n".join( + [ + "---", + "epic: 1", + "---", + "| Story | create-story | Status |", + "|-------\t|--------------|--------|", + "| 1.1 | done | pending |", + "", + ] + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_state_metrics(["--state", str(state_file)]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["total"], 1) + self.assertEqual(payload["storiesCompleted"], 0) + + def test_build_state_doc_keeps_standard_summary_shape_unchanged(self) -> None: + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertNotIn("Workflow Track:", text) + self.assertNotIn("Optional Steps:", text) + self.assertNotIn("Manual Checkpoints:", text) + + def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state({"workflowTrack": "tea"}) + complexity_file = self.project_root / "complexity.json" + complexity_file.write_text( + json.dumps({"stories": [{"storyId": "1.1", "title": "Story 1", "complexity": {"level": "medium"}}]}), + encoding="utf-8", + ) + agents_file = self.project_root / "agents.md" + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "agents-build", + "--state-file", + str(state_file), + "--complexity-file", + str(complexity_file), + "--output", + str(agents_file), + "--config-json", + json.dumps({"defaultPrimary": "claude", "defaultFallback": False}), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + text = agents_file.read_text(encoding="utf-8") + self.assertIn('"atdd"', text) + self.assertIn('"test_automate"', text) + self.assertIn('"test_review"', text) + self.assertIn('"trace"', text) + def test_build_cmd_rejects_unknown_step_via_policy(self) -> None: stderr = io.StringIO() with patch_env(self.project_root), redirect_stderr(stderr): @@ -525,6 +1218,26 @@ def _install_required_skills(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") (self.project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text("# checklist\n", encoding="utf-8") + def _install_tea_skills(self, *, include_nfr: bool = False, canonical: bool = False, write_assets: bool = True) -> None: + if write_assets: + _write_tea_assets(self.project_root) + else: + (self.project_root / "_bmad" / "tea" / "workflows" / "testarch").mkdir(parents=True, exist_ok=True) + prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" + names = [ + f"{prefix}-atdd", + f"{prefix}-automate", + f"{prefix}-test-review", + f"{prefix}-trace", + ] + if include_nfr: + names.append(f"{prefix}-nfr") + for name in names: + skill_dir = self.project_root / ".claude" / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") + class patch_env: def __init__(self, project_root: Path, extra: dict[str, str] | None = None) -> None: @@ -551,5 +1264,107 @@ def __exit__(self, exc_type, exc, tb) -> None: os.environ[key] = value +def _write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: + base = root or (project_root / "_bmad" / "tea" / "story-automator") + prompts = base / "prompts" + parse = base / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") + (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") + (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override( + project_root: Path, + *, + include_nfr: bool = False, + canonical: bool = False, + assets_root: str = "_bmad/tea/story-automator", +) -> dict[str, object]: + prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" + steps: dict[str, object] = { + "atdd": { + "label": "atdd", + "assets": { + "skillName": f"{prefix}-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{assets_root}/prompts/atdd.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/atdd.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": f"{prefix}-automate", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{assets_root}/prompts/test_automate.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/test_automate.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": f"{prefix}-test-review", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{assets_root}/prompts/test_review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/test_review.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": f"{prefix}-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{assets_root}/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/trace.json"}, + "success": {"verifier": "session_exit"}, + }, + } + if include_nfr: + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": f"{prefix}-nfr", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{assets_root}/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/nfr.json"}, + "success": {"verifier": "session_exit"}, + } + return steps + + if __name__ == "__main__": unittest.main()