diff --git a/scripts/run-smoke-policy-invariants.py b/scripts/run-smoke-policy-invariants.py new file mode 100644 index 00000000..6d5e205c --- /dev/null +++ b/scripts/run-smoke-policy-invariants.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +MODULES = [ + "tests.test_policy_invariants", + "tests.test_progress_invariants", +] + + +def _to_text(value: object) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, str): + return value + return "" + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + env = dict(os.environ) + pythonpath = str(repo_root / "skills" / "bmad-story-automator" / "src") + existing = env.get("PYTHONPATH", "").strip() + env["PYTHONPATH"] = pythonpath if not existing else f"{pythonpath}{os.pathsep}{existing}" + cmd = [sys.executable, "-m", "unittest", *MODULES] + try: + completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env, timeout=600) + except subprocess.TimeoutExpired as exc: + stdout = _to_text(exc.stdout) + stderr = _to_text(exc.stderr) + payload = { + "ok": False, + "modules": MODULES, + "returncode": 124, + "stdout": stdout, + "stderr": (stderr + "\nTimed out after 600 seconds").strip(), + } + print(json.dumps(payload)) + return 124 + payload = { + "ok": completed.returncode == 0, + "modules": MODULES, + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + print(json.dumps(payload)) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-smoke-resume-matrix.py b/scripts/run-smoke-resume-matrix.py new file mode 100644 index 00000000..b5a5e2f9 --- /dev/null +++ b/scripts/run-smoke-resume-matrix.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +MODULES = [ + "tests.test_resume_matrix", +] + + +def _to_text(value: object) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, str): + return value + return "" + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[1] + env = dict(os.environ) + pythonpath = str(repo_root / "skills" / "bmad-story-automator" / "src") + existing = env.get("PYTHONPATH", "").strip() + env["PYTHONPATH"] = pythonpath if not existing else f"{pythonpath}{os.pathsep}{existing}" + cmd = [sys.executable, "-m", "unittest", *MODULES] + try: + completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env, timeout=600) + except subprocess.TimeoutExpired as exc: + stdout = _to_text(exc.stdout) + stderr = _to_text(exc.stderr) + payload = { + "ok": False, + "modules": MODULES, + "returncode": 124, + "stdout": stdout, + "stderr": (stderr + "\nTimed out after 600 seconds").strip(), + } + print(json.dumps(payload)) + return 124 + payload = { + "ok": completed.returncode == 0, + "modules": MODULES, + "returncode": completed.returncode, + "stdout": completed.stdout, + "stderr": completed.stderr, + } + print(json.dumps(payload)) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/bmad-story-automator/data/orchestration-policy.json b/skills/bmad-story-automator/data/orchestration-policy.json index a2ad41d6..027d3283 100644 --- a/skills/bmad-story-automator/data/orchestration-policy.json +++ b/skills/bmad-story-automator/data/orchestration-policy.json @@ -76,7 +76,7 @@ } }, "auto": { - "label": "qa-generate-e2e-tests", + "label": "automate", "assets": { "skillName": "bmad-qa-generate-e2e-tests", "workflowCandidates": ["workflow.md", "workflow.yaml"], 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..93693ac0 --- /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" + } +} 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 87d048c1..0a0f246d 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -18,7 +18,6 @@ crash_max_retries, load_runtime_policy, review_max_cycles, - summarize_state_policy_fields, ) from story_automator.core.review_verify import verify_code_review_completion from story_automator.core.runtime_layout import active_marker_path, active_marker_project_entry @@ -46,6 +45,16 @@ retro_agent_action, ) from .orchestrator_parse import parse_output_action +from .orchestrator_state import ( + policy_sequence_action, + policy_steps_action, + state_latest_action, + state_latest_incomplete_action, + state_list_action, + state_progress_action, + state_summary_action, + state_update_action, +) def cmd_orchestrator_helper(args: list[str]) -> int: @@ -58,11 +67,12 @@ def cmd_orchestrator_helper(args: list[str]) -> int: "sprint-status": _sprint_status, "parse-output": parse_output_action, "marker": _marker, - "state-list": _state_list, - "state-latest": _state_latest, - "state-latest-incomplete": _state_latest_incomplete, - "state-summary": _state_summary, - "state-update": _state_update, + "state-list": state_list_action, + "state-latest": state_latest_action, + "state-latest-incomplete": state_latest_incomplete_action, + "state-summary": state_summary_action, + "state-update": state_update_action, + "state-progress": _state_progress_action, "escalate": _escalate, "commit-ready": _commit_ready, "normalize-key": _normalize_key, @@ -75,6 +85,8 @@ def cmd_orchestrator_helper(args: list[str]) -> int: "agents-build": agents_build_action, "agents-resolve": agents_resolve_action, "retro-agent": retro_agent_action, + "policy-sequence": policy_sequence_action, + "policy-steps": policy_steps_action, } handler = dispatch.get(action) if handler is None: @@ -82,6 +94,10 @@ def cmd_orchestrator_helper(args: list[str]) -> int: return handler(args[1:]) +def _state_progress_action(args: list[str]) -> int: + return state_progress_action(args, exists_fn=file_exists) + + def _usage(code: int) -> int: target = __import__("sys").stderr if code else __import__("sys").stdout print("Usage: orchestrator-helper [args]", file=target) @@ -101,6 +117,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) @@ -111,8 +128,10 @@ 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) + print(" policy-sequence [--state-file path]", file=target) + print(" policy-steps --group tea-quality [--state-file path]", file=target) return code @@ -220,108 +239,6 @@ def _marker(args: list[str]) -> int: return 1 -def _state_list(args: list[str]) -> int: - if not args or not Path(args[0]).is_dir(): - print_json({"ok": False, "error": "folder_not_found", "files": []}) - return 1 - files = [] - for path in sorted(Path(args[0]).glob("orchestration-*.md")): - files.append({"path": str(path), "status": find_frontmatter_value(path, "status") or "unknown", "lastUpdated": find_frontmatter_value(path, "lastUpdated") or "unknown"}) - print_json({"ok": True, "files": files}) - return 0 - - -def _state_latest(args: list[str]) -> int: - if not args or not Path(args[0]).is_dir(): - print_json({"ok": False, "error": "folder_not_found"}) - return 1 - status_filter = args[1] if len(args) > 1 else "" - matches = [] - for path in Path(args[0]).glob("orchestration-*.md"): - status = find_frontmatter_value(path, "status") - if status_filter and status != status_filter: - continue - matches.append((find_frontmatter_value(path, "lastUpdated"), str(path))) - if not matches: - print_json({"ok": False, "error": "no_match"}) - return 0 - updated, path = max(matches) - print_json({"ok": True, "path": path, "lastUpdated": updated}) - return 0 - - -def _state_latest_incomplete(args: list[str]) -> int: - if not args or not Path(args[0]).is_dir(): - print_json({"ok": False, "error": "folder_not_found"}) - return 1 - matches = [] - for path in Path(args[0]).glob("orchestration-*.md"): - status = find_frontmatter_value(path, "status") - if status == "COMPLETE": - continue - matches.append((find_frontmatter_value(path, "lastUpdated"), status, str(path))) - if not matches: - print_json({"ok": False, "error": "no_incomplete_state"}) - return 0 - updated, status, path = max(matches) - print_json({"ok": True, "path": path, "lastUpdated": updated, "status": status}) - return 0 - - -def _state_summary(args: list[str]) -> int: - if not args or not file_exists(args[0]): - print_json({"ok": False, "error": "file_not_found"}) - return 1 - fields = parse_simple_frontmatter(read_text(args[0])) - snapshot_file, snapshot_hash, policy_version, legacy_policy, policy_error = summarize_state_policy_fields( - fields, - project_root=get_project_root(), - ) - payload = { - "ok": True, - "epic": str(fields.get("epic") or ""), - "epicName": str(fields.get("epicName") or ""), - "currentStory": str(fields.get("currentStory") or ""), - "currentStep": str(fields.get("currentStep") or ""), - "status": str(fields.get("status") or ""), - "lastUpdated": str(fields.get("lastUpdated") or ""), - "policyVersion": policy_version, - "policySnapshotFile": snapshot_file, - "policySnapshotHash": snapshot_hash, - "legacyPolicy": legacy_policy, - "lastAction": extract_last_action(args[0]), - } - if policy_error: - payload["policyError"] = policy_error - print_json(payload) - return 0 - - -def _state_update(args: list[str]) -> int: - if not args or not file_exists(args[0]): - print_json({"ok": False, "error": "file_not_found"}) - return 1 - text = read_text(args[0]) - updated: list[str] = [] - idx = 1 - while idx < len(args): - if args[idx] == "--set" and idx + 1 < len(args): - key, value = args[idx + 1].split("=", 1) - replaced, count = re.subn(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {v}", text) - if count: - text = replaced - updated.append(key) - idx += 2 - continue - idx += 1 - if not updated: - print_json({"ok": False, "error": "keys_not_found", "updated": []}) - return 1 - Path(args[0]).write_text(text, encoding="utf-8") - print_json({"ok": True, "updated": updated}) - return 0 - - def _escalate(args: list[str]) -> int: trigger = args[0] if args else "" context = args[1] if len(args) > 1 else "" 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 edf88a47..e90b2b51 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 @@ -6,6 +6,7 @@ from story_automator.core.artifact_paths import implementation_artifacts_dir 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 StoryKey, normalize_story_key, normalize_story_key_for_epic @@ -138,11 +139,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/orchestrator_state.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py new file mode 100644 index 00000000..2debfd82 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from story_automator.core.frontmatter import extract_last_action, find_frontmatter_value, parse_simple_frontmatter +from story_automator.core.runtime_policy import PolicyError, load_runtime_policy, summarize_state_policy_fields, workflow_sequence +from story_automator.core.state_document import update_story_progress +from story_automator.core.workflow_steps import TEA_QUALITY_STEPS +from story_automator.core.utils import file_exists, get_project_root, print_json, read_text + + +def state_list_action(args: list[str]) -> int: + if not args or not Path(args[0]).is_dir(): + print_json({"ok": False, "error": "folder_not_found", "files": []}) + return 1 + files = [] + for path in sorted(Path(args[0]).glob("orchestration-*.md")): + files.append({"path": str(path), "status": find_frontmatter_value(path, "status") or "unknown", "lastUpdated": find_frontmatter_value(path, "lastUpdated") or "unknown"}) + print_json({"ok": True, "files": files}) + return 0 + + +def state_latest_action(args: list[str]) -> int: + if not args or not Path(args[0]).is_dir(): + print_json({"ok": False, "error": "folder_not_found"}) + return 1 + status_filter = args[1] if len(args) > 1 else "" + matches = [] + for path in Path(args[0]).glob("orchestration-*.md"): + status = find_frontmatter_value(path, "status") + if status_filter and status != status_filter: + continue + matches.append((find_frontmatter_value(path, "lastUpdated"), str(path))) + if not matches: + print_json({"ok": False, "error": "no_match"}) + return 0 + updated, path = max(matches) + print_json({"ok": True, "path": path, "lastUpdated": updated}) + return 0 + + +def state_latest_incomplete_action(args: list[str]) -> int: + if not args or not Path(args[0]).is_dir(): + print_json({"ok": False, "error": "folder_not_found"}) + return 1 + matches = [] + for path in Path(args[0]).glob("orchestration-*.md"): + status = find_frontmatter_value(path, "status") + if status == "COMPLETE": + continue + matches.append((find_frontmatter_value(path, "lastUpdated"), status, str(path))) + if not matches: + print_json({"ok": False, "error": "no_incomplete_state"}) + return 0 + updated, status, path = max(matches) + print_json({"ok": True, "path": path, "lastUpdated": updated, "status": status}) + return 0 + + +def state_summary_action(args: list[str]) -> int: + if not args or not file_exists(args[0]): + print_json({"ok": False, "error": "file_not_found"}) + return 1 + fields = parse_simple_frontmatter(read_text(args[0])) + snapshot_file, snapshot_hash, policy_version, legacy_policy, policy_error = summarize_state_policy_fields( + fields, + project_root=get_project_root(), + ) + payload = { + "ok": True, + "epic": str(fields.get("epic") or ""), + "epicName": str(fields.get("epicName") or ""), + "currentStory": str(fields.get("currentStory") or ""), + "currentStep": str(fields.get("currentStep") or ""), + "status": str(fields.get("status") or ""), + "lastUpdated": str(fields.get("lastUpdated") or ""), + "policyVersion": policy_version, + "policySnapshotFile": snapshot_file, + "policySnapshotHash": snapshot_hash, + "legacyPolicy": legacy_policy, + "lastAction": extract_last_action(args[0]), + } + if policy_error: + payload["policyError"] = policy_error + print_json(payload) + return 0 + + +def state_update_action(args: list[str]) -> int: + if not args or not file_exists(args[0]): + print_json({"ok": False, "error": "file_not_found"}) + return 1 + text = read_text(args[0]) + updated: list[str] = [] + idx = 1 + while idx < len(args): + if args[idx] == "--set" and idx + 1 < len(args): + key, value = args[idx + 1].split("=", 1) + replaced, count = re.subn(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {v}", text) + if count: + text = replaced + updated.append(key) + idx += 2 + continue + idx += 1 + if not updated: + print_json({"ok": False, "error": "keys_not_found", "updated": []}) + return 1 + Path(args[0]).write_text(text, encoding="utf-8") + print_json({"ok": True, "updated": updated}) + return 0 + + + +def policy_sequence_action(args: list[str]) -> int: + state_file = "" + idx = 0 + try: + while idx < len(args): + if args[idx] == "--state-file": + state_file = _flag_value(args, idx, "--state-file") + idx += 2 + continue + idx += 1 + except PolicyError as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + try: + policy = load_runtime_policy(get_project_root(), state_file=state_file, resolve_assets=False) + except (FileNotFoundError, PolicyError) as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + print_json({"ok": True, "sequence": workflow_sequence(policy)}) + return 0 + + +def policy_steps_action(args: list[str]) -> int: + state_file = "" + group = "" + idx = 0 + try: + while idx < len(args): + if args[idx] == "--state-file": + state_file = _flag_value(args, idx, "--state-file") + idx += 2 + continue + if args[idx] == "--group": + group = _flag_value(args, idx, "--group") + idx += 2 + continue + idx += 1 + except PolicyError as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + if group != "tea-quality": + print_json({"ok": False, "error": "unknown_step_group", "group": group}) + return 1 + try: + policy = load_runtime_policy(get_project_root(), state_file=state_file, resolve_assets=False) + except (FileNotFoundError, PolicyError) as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + sequence = workflow_sequence(policy) + steps = [step for step in sequence if step in TEA_QUALITY_STEPS] + print_json({"ok": True, "group": group, "steps": steps}) + return 0 + + + +def state_progress_action(args: list[str], *, exists_fn=None) -> int: + if not args: + print_json({"ok": False, "error": "file_not_found"}) + return 1 + state_file = args[0] + exists = exists_fn or file_exists + try: + if not 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[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: + policy = load_runtime_policy(get_project_root(), state_file=state_file, resolve_assets=False) + except (FileNotFoundError, PolicyError) as exc: + print_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + ok, payload = update_story_progress(state_file, story_id, updates, policy=policy) + if not ok: + print_json(payload) + return 1 + print_json(payload) + return 0 + + + +def _flag_value(args: list[str], idx: int, flag: str) -> str: + if idx + 1 >= len(args) or not args[idx + 1].strip() or args[idx + 1].startswith("--"): + raise PolicyError(f"{flag} requires a value") + return args[idx + 1] 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..d2565cf5 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,9 +6,13 @@ from typing import Any from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter +from ..core.policy_state_rendering import policy_frontmatter_block, policy_summary_block from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy from ..core.agent_config import normalize_model as _model_or_none +from ..core.state_document import progress_metrics, progress_table_lines +from ..core.tea_policy import build_run_policy, detect_workflow_track from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json +from ..core.workflow_steps import selected_optional_steps_from_sequence, workflow_track_for_sequence def cmd_build_state_doc(args: list[str]) -> int: @@ -29,14 +33,37 @@ def cmd_build_state_doc(args: list[str]) -> int: write_json({"ok": False, "error": "missing_template_or_output"}) return 1 if config_file and file_exists(config_file): - config_json = read_text(config_file) + try: + config_json = read_text(config_file) + except OSError: + write_json({"ok": False, "error": "config_file_unreadable"}) + return 1 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": "missing_config"}) + 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 + raw_story_range = config.get("storyRange", []) + if raw_story_range is not None and not isinstance(raw_story_range, list): + write_json({"ok": False, "error": "storyRange_must_be_array"}) + return 1 + if raw_story_range and any(not isinstance(item, str) for item in raw_story_range): + write_json({"ok": False, "error": "storyRange_must_be_array_of_strings"}) + return 1 + story_range = list(raw_story_range or []) + duplicate_story_ids = sorted({item for item in story_range if story_range.count(item) > 1}) + if duplicate_story_ids: + write_json({"ok": False, "error": "storyRange_contains_duplicates", "duplicates": duplicate_story_ids}) + return 1 + invalid_story_ids = sorted({item for item in story_range if any(ch in item for ch in ("|", "\n", "\r"))}) + if invalid_story_ids: + write_json({"ok": False, "error": "storyRange_contains_invalid_ids", "invalid": invalid_story_ids}) return 1 ensure_dir(output_folder) now = now_utc_z() @@ -45,15 +72,20 @@ def cmd_build_state_doc(args: list[str]) -> int: safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" try: - snapshot = snapshot_effective_policy(get_project_root()) + policy_selection = build_run_policy(Path(get_project_root()), config) + 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 + pinned_sequence = [step for step in ((snapshot["policy"].get("workflow") or {}).get("sequence") or []) if isinstance(step, str)] + pinned_track = workflow_track_for_sequence(pinned_sequence) + pinned_optional_steps = selected_optional_steps_from_sequence(pinned_sequence) + progress_header, progress_divider, progress_rows = progress_table_lines(snapshot["policy"], story_range) text = read_text(template) replacements: dict[str, Any] = { "epic": config.get("epic", ""), "epicName": config.get("epicName", ""), - "storyRange": config.get("storyRange", []), + "storyRange": story_range, "status": config.get("status", "READY"), "currentStory": config.get("currentStory"), "currentStep": config.get("currentStep"), @@ -78,6 +110,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) + policy_frontmatter = policy_frontmatter_block( + pinned_track, + pinned_optional_steps, + policy_selection["manualCheckpoints"], + policy_selection["notes"], + ) + if policy_frontmatter: + text = text.replace("customInstructions: " + custom_instructions + "\n", "customInstructions: " + custom_instructions + "\n" + policy_frontmatter) agent_config = config.get("agentConfig") if isinstance(agent_config, dict): per_task = agent_config.get("perTask", {}) @@ -152,8 +192,6 @@ def cmd_build_state_doc(args: list[str]) -> int: text = re.sub(r"(?m)^agentConfig:\n(?:(?:\s{2}.*\n)*)", block, text) 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 +201,87 @@ def cmd_build_state_doc(args: list[str]) -> int: "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), "{{customInstructions}}": str(config.get("customInstructions", "")), } + body["{{teaConfigurationBlock}}"] = policy_summary_block( + pinned_track, + pinned_sequence, + pinned_optional_steps, + policy_selection["notes"], + ) 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 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): + try: + config_json = read_text(config_file) + except OSError: + write_json({"ok": False, "error": "config_file_unreadable"}) + return 1 + 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 + try: + selection = build_run_policy(Path(get_project_root()), config) + except (FileNotFoundError, PolicyError, ValueError) as exc: + write_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + shape_error = _run_policy_selection_error(selection) + if shape_error: + write_json({"ok": False, "error": "policy_selection_invalid", "reason": shape_error}) + return 1 + write_json({"ok": True, **selection}) + return 0 + + +def _run_policy_selection_error(selection: object) -> str: + if not isinstance(selection, dict): + return "selection must be an object" + required = {"policyOverride", "workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "notes"} + missing = sorted(required - set(selection)) + if missing: + return f"missing selection keys: {', '.join(missing)}" + if not isinstance(selection["policyOverride"], dict): + return "policyOverride must be an object" + if selection["workflowTrack"] not in {"standard", "tea"}: + return "workflowTrack must be standard or tea" + for key in ("selectedOptionalSteps", "manualCheckpoints", "notes"): + value = selection[key] + if not isinstance(value, list) or any(not isinstance(item, str) for item in value): + return f"{key} must be a string array" + return "" + + +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 = "" @@ -209,30 +320,13 @@ def cmd_state_metrics(args: list[str]) -> int: if not state or not file_exists(state): write_json({"ok": False, "error": "state_not_found"}) return 1 - total = 0 - completed = 0 - in_table = False - for line in read_text(state).splitlines(): - if line.startswith("| Story "): - in_table = True - continue - if in_table and re.match(r"^\|[- ]*\|", line): - continue - if in_table and line.startswith("|"): - parts = [part.strip() for part in line.split("|")] - if len(parts) >= 8 and parts[1]: - total += 1 - if any(token in parts[7].lower() for token in ("done", "complete", "completed")): - completed += 1 - continue - if in_table and not line.startswith("|"): - in_table = False + metrics = progress_metrics(read_text(state)) print( json.dumps( { "ok": True, - "storiesCompleted": completed, - "total": total, + "storiesCompleted": metrics["storiesCompleted"], + "total": metrics["total"], "reviewCycles": count_matches(read_text(state), r"review cycle|code review cycle"), "escalations": count_matches(read_text(state), r"escalation|escalated"), }, 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/policy_state_rendering.py b/skills/bmad-story-automator/src/story_automator/core/policy_state_rendering.py new file mode 100644 index 00000000..b57e43b7 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/policy_state_rendering.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json + +from .workflow_steps import summary_steps_for_track + + +def policy_frontmatter_block( + track: str, + optional_steps: list[str], + manual_checkpoints: list[str], + notes: list[str], +) -> str: + if track == "standard": + return "" + return ( + f"workflowTrack: {json.dumps(track)}\n" + f"selectedOptionalSteps: {json.dumps(optional_steps)}\n" + f"manualCheckpoints: {json.dumps(manual_checkpoints)}\n" + f"policyNotes: {json.dumps(notes)}\n" + ) + + +def policy_summary_block(track: str, sequence: list[str], optional_steps: list[str], notes: list[str]) -> str: + if track == "standard": + return "" + display_track = track.upper() + selected_steps = summary_steps_for_track(sequence, track) + lines = [ + f"**{display_track} Configuration:**", + f"- Pinned {display_track} Steps: {', '.join(selected_steps) or 'none'}", + f"- Optional Automated Steps: {', '.join(optional_steps) or 'none'}", + f"- Policy Notes: {'; '.join(notes) or 'none'}", + "", + ] + return "\n".join(lines) 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..63e55761 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 @@ -1,26 +1,40 @@ from __future__ import annotations -import json +from copy import deepcopy import os from pathlib import Path from typing import Any from .frontmatter import parse_simple_frontmatter -from .runtime_layout import active_marker_path, bundled_story_skill_root, resolve_portable_path, resolve_skill_dir +from .runtime_layout import active_marker_path, resolve_portable_path 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_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} -VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} -VALID_PARSER_PROVIDERS = {"claude"} - +from .runtime_policy_support import ( + PolicyError, + _apply_legacy_env, + _clear_resolved_fields, + _deep_merge, + _display_path, + _ensure_within, + _load_bundled_policy_shape, + _load_policy_snapshot_shape, + _path_is_file, + _prune_unreferenced_steps, + _read_json, + _resolve_policy_paths, + _resolve_snapshot_dir, + _resolve_state_path, + _resolve_success_paths, + _stable_policy_json, + _state_policy_mode, + _validate_policy_shape, + bundled_skill_root, + parser_runtime_config, +) 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, bundle_root=bundle_root) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundle_root) else: @@ -28,18 +42,30 @@ def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool return policy -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 _path_is_file(override_path) else {} + except PolicyError as exc: + if str(exc).startswith("path unreadable:"): + raise PolicyError(f"project override unreadable: {override_path}") from exc + raise + except OSError as exc: + raise PolicyError(f"project override unreadable: {override_path}") from exc + inline = deepcopy(inline_override) if inline_override is not None else {} + policy = _deep_merge(_deep_merge(bundled, override), inline) _apply_legacy_env(policy) _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) + _validate_policy_shape(policy) _clear_resolved_fields(policy) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root)) @@ -66,9 +92,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 +122,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 +158,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,367 +229,15 @@ def review_max_cycles(policy: dict[str, Any]) -> int: return int(repeat.get("maxCycles", 5)) -def crash_max_retries(policy: dict[str, Any]) -> int: - crash = ((policy.get("workflow") or {}).get("crash")) or {} - return int(crash.get("maxRetries", 2)) - - -def parser_runtime_config(policy: dict[str, Any]) -> dict[str, object]: - runtime = _expect_optional_dict(policy, "runtime") - parser = _expect_optional_nested_dict(runtime, "parser", "runtime") - provider = str(parser.get("provider") or "").strip() - model = str(parser.get("model") or "").strip() - timeout = parser.get("timeoutSeconds") - if provider not in VALID_PARSER_PROVIDERS: - raise PolicyError(f"runtime.parser.provider must be one of: {', '.join(sorted(VALID_PARSER_PROVIDERS))}") - if not model: - raise PolicyError("runtime.parser.model must be a string") - if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: - raise PolicyError("runtime.parser.timeoutSeconds must be a positive integer") - return {"provider": provider, "model": model, "timeoutSeconds": timeout} - - -def bundled_skill_root(project_root: str | Path | None = None) -> Path: - root = Path(project_root or get_project_root()).resolve() - try: - return bundled_story_skill_root(root) - except FileNotFoundError as exc: - raise PolicyError("bundled policy not found") from exc - - -def _read_json(path: str | Path) -> dict[str, Any]: - try: - payload = json.loads(read_text(path)) - except json.JSONDecodeError as exc: - raise PolicyError(f"policy json invalid: {path}") from exc - if not isinstance(payload, dict): - raise PolicyError(f"policy json must be an object: {path}") - return payload - - -def _deep_merge(base: Any, override: Any) -> Any: - if isinstance(base, dict) and isinstance(override, dict): - merged = dict(base) - for key, value in override.items(): - merged[key] = _deep_merge(merged[key], value) if key in merged else value - return merged - if isinstance(override, list): - return list(override) - return override - - -def _clear_resolved_fields(policy: dict[str, Any]) -> None: - for contract in (policy.get("steps") or {}).values(): - if not isinstance(contract, dict): - continue - assets = contract.get("assets") - if isinstance(assets, dict): - assets.pop("files", None) - prompt = contract.get("prompt") - if isinstance(prompt, dict): - prompt.pop("templatePath", None) - prompt.pop("templateHash", None) - parse = contract.get("parse") - if isinstance(parse, dict): - parse.pop("schemaPath", None) - parse.pop("schemaHash", None) - success = contract.get("success") - if isinstance(success, dict): - success.pop("contractPath", None) - success.pop("contractHash", None) - - -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") - if review_cycles: - policy.setdefault("workflow", {}).setdefault("repeat", {}).setdefault("review", {})["maxCycles"] = _legacy_env_int( - "MAX_REVIEW_CYCLES", - review_cycles, - ) - if crash_retries: - policy.setdefault("workflow", {}).setdefault("crash", {})["maxRetries"] = _legacy_env_int( - "MAX_CRASH_RETRIES", - crash_retries, - ) - - -def _legacy_env_int(name: str, raw: str) -> int: - try: - return int(raw) - except ValueError as exc: - raise PolicyError(f"{name} must be an integer") from exc - - -def _validate_policy_shape(policy: dict[str, Any]) -> None: - unknown_keys = sorted(set(policy) - VALID_TOP_LEVEL_KEYS) - if unknown_keys: - raise PolicyError(f"unknown top-level policy keys: {', '.join(unknown_keys)}") - snapshot = _expect_optional_dict(policy, "snapshot") - if "snapshot" in policy and "relativeDir" in snapshot and not isinstance(snapshot.get("relativeDir"), str): - raise PolicyError("snapshot.relativeDir must be a string") - runtime = _expect_optional_dict(policy, "runtime") - _expect_optional_nested_dict(runtime, "merge", "runtime") - parser_runtime_config(policy) - workflow = _expect_optional_dict(policy, "workflow") - repeat = _expect_optional_nested_dict(workflow, "repeat", "workflow") - review = _expect_optional_nested_dict(repeat, "review", "workflow.repeat") - crash = _expect_optional_nested_dict(workflow, "crash", "workflow") - steps = policy.get("steps") - if not isinstance(steps, dict): - raise PolicyError("steps must be an object") - unknown_steps = sorted(set(steps) - VALID_STEP_NAMES) - if unknown_steps: - raise PolicyError(f"unknown step names: {', '.join(unknown_steps)}") - sequence = (workflow.get("sequence")) or [] - if not isinstance(sequence, list) or not all(isinstance(item, str) for item in sequence): - raise PolicyError("workflow.sequence must be a string array") - if "maxCycles" in review and not isinstance(review.get("maxCycles"), int): - raise PolicyError("workflow.repeat.review.maxCycles must be an integer") - if "maxRetries" in crash and not isinstance(crash.get("maxRetries"), int): - raise PolicyError("workflow.crash.maxRetries must be an integer") - for step in sequence: - if step not in steps: - raise PolicyError(f"workflow.sequence references missing step: {step}") - for name, contract in steps.items(): - if not isinstance(contract, dict): - raise PolicyError(f"step contract must be an object: {name}") - assets = _expect_step_dict(contract, "assets", name) - _expect_step_dict(contract, "prompt", name) - _expect_step_dict(contract, "parse", name) - _expect_step_dict(contract, "success", name) - verifier = str(((contract.get("success") or {}).get("verifier")) or "") - if verifier not in VALID_VERIFIERS: - raise PolicyError(f"invalid verifier for {name}: {verifier}") - required = (assets.get("required")) or [] - if not isinstance(required, list) or any(item not in VALID_ASSET_NAMES for item in required): - raise PolicyError(f"invalid required assets for {name}") - - -def _resolve_policy_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None: - for name, contract in (policy.get("steps") or {}).items(): - assets = contract.setdefault("assets", {}) - assets["files"] = _resolve_step_assets(name, assets, project_root) - prompt = contract.setdefault("prompt", {}) - template_file = str(prompt.get("templateFile") or "").strip() - if not template_file: - raise PolicyError(f"missing prompt template for {name}") - prompt["templatePath"] = _resolve_data_path(template_file, project_root=project_root, bundle_root=bundle_root) - _set_or_verify_hash(prompt, path_key="templatePath", hash_key="templateHash", label="policy template") - parse = contract.setdefault("parse", {}) - schema_file = str(parse.get("schemaFile") or "").strip() - if not schema_file: - raise PolicyError(f"missing parse schema for {name}") - parse["schemaPath"] = _resolve_data_path(schema_file, project_root=project_root, bundle_root=bundle_root) - _set_or_verify_hash(parse, path_key="schemaPath", hash_key="schemaHash", label="policy parse schema") - success = contract.setdefault("success", {}) - contract_file = str(success.get("contractFile") or "").strip() - if contract_file: - success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root) - _set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract") - - -def _resolve_success_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None: - for contract in (policy.get("steps") or {}).values(): - success = contract.setdefault("success", {}) - contract_file = str(success.get("contractFile") or "").strip() - if contract_file: - success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root) - _set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract") - - -def _resolve_step_assets(step: str, assets: dict[str, Any], project_root: Path) -> dict[str, str]: - skill_name = str(assets.get("skillName") or "").strip() - if not skill_name: - raise PolicyError(f"missing skillName for {step}") - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError as exc: - raise PolicyError(str(exc)) from exc - skills_root = skill_dir.parent - required = set(assets.get("required") or []) - files = { - "skill": _resolve_required_file(skill_dir / "SKILL.md", project_root, required, "skill", step), - "workflow": _resolve_candidate_file(skill_dir, assets.get("workflowCandidates"), project_root, required, "workflow", step), - "instructions": _resolve_candidate_file(skill_dir, assets.get("instructionsCandidates"), project_root, required, "instructions", step), - "checklist": _resolve_candidate_file(skill_dir, assets.get("checklistCandidates"), project_root, required, "checklist", step), - "template": _resolve_candidate_file(skill_dir, assets.get("templateCandidates"), project_root, required, "template", step), - } - if not files["skill"]: - files["workflow"] = "" - files["instructions"] = "" - files["checklist"] = "" - files["template"] = "" - return files - - -def _resolve_required_file(path: Path, project_root: Path, required: set[str], asset: str, step: str) -> str: - if path.is_file(): - return _display_path(path, project_root) - if asset in required: - raise PolicyError(f"missing required {asset} asset for {step}: {path}") - return "" - - -def _resolve_candidate_file( - skill_dir: Path, - candidates: Any, - project_root: Path, - required: set[str], - asset: str, - step: str, -) -> str: - if not isinstance(candidates, list): - candidates = [] - for name in candidates: - if not isinstance(name, str) or not name: - continue - path = _ensure_within(skill_dir / name, skill_dir, f"{asset} candidate for {step}") - if path.is_file(): - return _display_path(path, project_root) - if asset == "workflow" and asset in required: - skill_file = skill_dir / "SKILL.md" - if skill_file.is_file(): - return _display_path(skill_file, project_root) - if asset in required: - searched = ", ".join(str(skill_dir / str(name)) for name in candidates if isinstance(name, str) and name) - raise PolicyError(f"missing required {asset} asset for {step}: {searched}") - return "" - - -def _resolve_data_path(path_value: str, *, project_root: Path, bundle_root: Path) -> str: - portable = resolve_portable_path(path_value, project_root) - if portable: - if not portable.is_file(): - raise PolicyError(f"policy data file missing: {path_value}") - return str(portable) - raw = Path(path_value) - allowed_roots = (bundle_root.resolve(), project_root.resolve()) - if raw.is_absolute(): - resolved = raw.resolve() - if not _is_within_any(resolved, allowed_roots): - raise PolicyError(f"policy data path escapes allowed roots: {path_value}") - if not resolved.is_file(): - raise PolicyError(f"policy data file missing: {raw}") - return str(resolved) - escaped_all = True - for base in allowed_roots: - candidate = (base / raw).resolve() - if not _is_within(candidate, base): - continue - escaped_all = False - if candidate.is_file(): - return str(candidate) - if escaped_all: - raise PolicyError(f"policy data path escapes allowed roots: {path_value}") - raise PolicyError(f"policy data file missing: {path_value}") +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 _snapshot_relative_dir(policy: dict[str, Any]) -> str: - snapshot = _expect_optional_dict(policy, "snapshot") - relative_dir = str(snapshot.get("relativeDir") or "").strip() - if not relative_dir: - raise PolicyError("snapshot.relativeDir missing") - return relative_dir +def story_task_sequence(policy: dict[str, Any]) -> list[str]: + return [step for step in workflow_sequence(policy) if step != "retro"] -def _resolve_snapshot_dir(policy: dict[str, Any], project_root: Path) -> Path: - raw = Path(_snapshot_relative_dir(policy)) - candidate = raw if raw.is_absolute() else project_root / raw - return _ensure_within(candidate, project_root.resolve(), "snapshot.relativeDir") - - -def _stable_policy_json(policy: dict[str, Any]) -> str: - return json.dumps(policy, indent=2, sort_keys=True) + "\n" - - -def _display_path(path: Path, project_root: Path) -> str: - try: - return str(path.resolve().relative_to(project_root.resolve())) - except ValueError: - return str(path.resolve()) - - -def _resolve_state_path(project_root: Path, path: Path, *, allow_outside: bool = True, label: str = "state file") -> Path: - candidate = path if path.is_absolute() else project_root / path - if allow_outside: - return candidate.resolve() - return _ensure_within(candidate, project_root.resolve(), label) - - -def _set_or_verify_hash(payload: dict[str, Any], *, path_key: str, hash_key: str, label: str) -> None: - path = str(payload.get(path_key) or "").strip() - if not path: - return - actual = md5_hex8(read_text(path)) - expected = str(payload.get(hash_key) or "").strip() - if expected and expected != actual: - raise PolicyError(f"{label} hash mismatch: {path}") - payload[hash_key] = actual - - -def _ensure_within(path: Path, root: Path, label: str) -> Path: - resolved = path.resolve() - root_resolved = root.resolve() - try: - resolved.relative_to(root_resolved) - except ValueError as exc: - raise PolicyError(f"{label} escapes allowed root: {path}") from exc - return resolved - - -def _is_within(path: Path, root: Path) -> bool: - try: - path.resolve().relative_to(root.resolve()) - except ValueError: - return False - return True - - -def _is_within_any(path: Path, roots: tuple[Path, ...]) -> bool: - return any(_is_within(path, root) for root in roots) - - -def _state_policy_mode(fields: dict[str, Any]) -> tuple[str, str, bool]: - snapshot_file = str(fields.get("policySnapshotFile") or "").strip() - snapshot_hash = str(fields.get("policySnapshotHash") or "").strip() - policy_version = str(fields.get("policyVersion") or "").strip() - legacy_policy = str(fields.get("legacyPolicy") or "").strip().lower() - if snapshot_file or snapshot_hash: - if not snapshot_file or not snapshot_hash: - raise PolicyError("state policy metadata incomplete") - if legacy_policy == "true": - raise PolicyError("state policy metadata contradictory") - return snapshot_file, snapshot_hash, False - if legacy_policy == "false" or policy_version: - raise PolicyError("state policy snapshot missing") - if legacy_policy == "true": - return "", "", True - return "", "", True - - -def _expect_optional_dict(payload: dict[str, Any], key: str) -> dict[str, Any]: - value = payload.get(key) - if value is None: - return {} - if not isinstance(value, dict): - raise PolicyError(f"{key} must be an object") - return value - - -def _expect_step_dict(contract: dict[str, Any], key: str, step: str) -> dict[str, Any]: - value = contract.get(key) - if value is None: - return {} - if not isinstance(value, dict): - raise PolicyError(f"{step}.{key} must be an object") - return value - - -def _expect_optional_nested_dict(payload: dict[str, Any], key: str, label: str) -> dict[str, Any]: - value = payload.get(key) - if value is None: - return {} - if not isinstance(value, dict): - raise PolicyError(f"{label}.{key} must be an object") - return value +def crash_max_retries(policy: dict[str, Any]) -> int: + crash = ((policy.get("workflow") or {}).get("crash")) or {} + return int(crash.get("maxRetries", 2)) diff --git a/skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py b/skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py new file mode 100644 index 00000000..a65ba61f --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from .frontmatter import parse_simple_frontmatter +from .runtime_layout import bundled_story_skill_root, resolve_portable_path, resolve_skill_dir +from .utils import ensure_dir, get_project_root, md5_hex8, read_text +from .workflow_steps import VALID_STEP_NAMES + +VALID_TOP_LEVEL_KEYS = {"version", "snapshot", "runtime", "workflow", "steps"} +VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} +VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} +VALID_PARSER_PROVIDERS = {"claude"} +RESERVED_PROGRESS_LABELS = {"story", "status", "git-commit"} + + +class PolicyError(ValueError): + pass + + +def parser_runtime_config(policy: dict[str, Any]) -> dict[str, object]: + runtime = _expect_optional_dict(policy, "runtime") + parser = _expect_optional_nested_dict(runtime, "parser", "runtime") + provider = str(parser.get("provider") or "").strip() + model = str(parser.get("model") or "").strip() + timeout = parser.get("timeoutSeconds") + if provider not in VALID_PARSER_PROVIDERS: + raise PolicyError(f"runtime.parser.provider must be one of: {', '.join(sorted(VALID_PARSER_PROVIDERS))}") + if not model: + raise PolicyError("runtime.parser.model must be a string") + if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0: + raise PolicyError("runtime.parser.timeoutSeconds must be a positive integer") + return {"provider": provider, "model": model, "timeoutSeconds": timeout} + + +def bundled_skill_root(project_root: str | Path | None = None) -> Path: + root = Path(project_root or get_project_root()).resolve() + try: + return bundled_story_skill_root(root) + except FileNotFoundError as exc: + raise PolicyError("bundled policy not found") from exc + + +def _load_bundled_policy_shape(project_root: str | Path | None = None, bundle_root: Path | None = None) -> dict[str, Any]: + root = Path(project_root or get_project_root()).resolve() + bundle_root = bundle_root or 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) + _validate_policy_shape(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) + _validate_policy_shape(policy) + return policy + + +def _read_json(path: str | Path) -> dict[str, Any]: + try: + payload = json.loads(read_text(path)) + except json.JSONDecodeError as exc: + raise PolicyError(f"policy json invalid: {path}") from exc + if not isinstance(payload, dict): + raise PolicyError(f"policy json must be an object: {path}") + return payload + + +def _path_is_file(path: Path) -> bool: + try: + return path.is_file() + except OSError as exc: + raise PolicyError(f"path unreadable: {path}") from exc + + +def _deep_merge(base: Any, override: Any) -> Any: + if isinstance(base, dict) and isinstance(override, dict): + merged = dict(base) + for key, value in override.items(): + merged[key] = _deep_merge(merged[key], value) if key in merged else value + return merged + if isinstance(override, list): + return list(override) + return override + + +def _clear_resolved_fields(policy: dict[str, Any]) -> None: + for contract in (policy.get("steps") or {}).values(): + if not isinstance(contract, dict): + continue + assets = contract.get("assets") + if isinstance(assets, dict): + assets.pop("files", None) + prompt = contract.get("prompt") + if isinstance(prompt, dict): + prompt.pop("templatePath", None) + prompt.pop("templateHash", None) + parse = contract.get("parse") + if isinstance(parse, dict): + parse.pop("schemaPath", None) + parse.pop("schemaHash", None) + success = contract.get("success") + if isinstance(success, dict): + success.pop("contractPath", 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") + if review_cycles: + policy.setdefault("workflow", {}).setdefault("repeat", {}).setdefault("review", {})["maxCycles"] = _legacy_env_int( + "MAX_REVIEW_CYCLES", + review_cycles, + ) + if crash_retries: + policy.setdefault("workflow", {}).setdefault("crash", {})["maxRetries"] = _legacy_env_int( + "MAX_CRASH_RETRIES", + crash_retries, + ) + + +def _legacy_env_int(name: str, raw: str) -> int: + try: + return int(raw) + except ValueError as exc: + raise PolicyError(f"{name} must be an integer") from exc + + +def _validate_policy_shape(policy: dict[str, Any]) -> None: + unknown_keys = sorted(set(policy) - VALID_TOP_LEVEL_KEYS) + if unknown_keys: + raise PolicyError(f"unknown top-level policy keys: {', '.join(unknown_keys)}") + snapshot = _expect_optional_dict(policy, "snapshot") + if "snapshot" in policy and "relativeDir" in snapshot and not isinstance(snapshot.get("relativeDir"), str): + raise PolicyError("snapshot.relativeDir must be a string") + runtime = _expect_optional_dict(policy, "runtime") + _expect_optional_nested_dict(runtime, "merge", "runtime") + parser_runtime_config(policy) + workflow = _expect_optional_dict(policy, "workflow") + repeat = _expect_optional_nested_dict(workflow, "repeat", "workflow") + review = _expect_optional_nested_dict(repeat, "review", "workflow.repeat") + crash = _expect_optional_nested_dict(workflow, "crash", "workflow") + steps = policy.get("steps") + if not isinstance(steps, dict): + raise PolicyError("steps must be an object") + unknown_steps = sorted(set(steps) - VALID_STEP_NAMES) + if unknown_steps: + raise PolicyError(f"unknown step names: {', '.join(unknown_steps)}") + sequence = (workflow.get("sequence")) or [] + if not isinstance(sequence, list) or not all(isinstance(item, str) for item in sequence): + raise PolicyError("workflow.sequence must be a string array") + if "review" not in sequence: + raise PolicyError("workflow.sequence must include review") + duplicates = sorted({step for step in sequence if sequence.count(step) > 1}) + if duplicates: + raise PolicyError(f"workflow.sequence contains duplicate steps: {', '.join(duplicates)}") + if "maxCycles" in review and not isinstance(review.get("maxCycles"), int): + raise PolicyError("workflow.repeat.review.maxCycles must be an integer") + if "maxRetries" in crash and not isinstance(crash.get("maxRetries"), int): + raise PolicyError("workflow.crash.maxRetries must be an integer") + normalized_labels: dict[str, str] = {} + for step in sequence: + if step not in steps: + raise PolicyError(f"workflow.sequence references missing step: {step}") + for name, contract in steps.items(): + if not isinstance(contract, dict): + raise PolicyError(f"step contract must be an object: {name}") + assets = _expect_step_dict(contract, "assets", name) + _expect_step_dict(contract, "prompt", name) + _expect_step_dict(contract, "parse", name) + _expect_step_dict(contract, "success", name) + verifier = str(((contract.get("success") or {}).get("verifier")) or "") + if verifier not in VALID_VERIFIERS: + raise PolicyError(f"invalid verifier for {name}: {verifier}") + label = contract.get("label") + if label is not None and (not isinstance(label, str) or any(ch in label for ch in ("|", "\n", "\r"))): + raise PolicyError(f"invalid label for {name}") + normalized_label = _normalize_policy_label(str(label or name)) + if normalized_label in RESERVED_PROGRESS_LABELS: + raise PolicyError(f"step label collides with reserved progress column for {name}: {label or name}") + previous = normalized_labels.get(normalized_label) + if previous and previous != name: + raise PolicyError(f"step label collides with another progress column: {name}, {previous}") + normalized_labels[normalized_label] = name + required = (assets.get("required")) or [] + if not isinstance(required, list) or any(item not in VALID_ASSET_NAMES for item in required): + raise PolicyError(f"invalid required assets for {name}") + + +def _resolve_policy_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None: + for name, contract in (policy.get("steps") or {}).items(): + assets = contract.setdefault("assets", {}) + assets["files"] = _resolve_step_assets(name, assets, project_root) + prompt = contract.setdefault("prompt", {}) + template_file = str(prompt.get("templateFile") or "").strip() + if not template_file: + raise PolicyError(f"missing prompt template for {name}") + prompt["templatePath"] = _resolve_data_path(template_file, project_root=project_root, bundle_root=bundle_root) + _set_or_verify_hash(prompt, path_key="templatePath", hash_key="templateHash", label="policy template") + parse = contract.setdefault("parse", {}) + schema_file = str(parse.get("schemaFile") or "").strip() + if not schema_file: + raise PolicyError(f"missing parse schema for {name}") + parse["schemaPath"] = _resolve_data_path(schema_file, project_root=project_root, bundle_root=bundle_root) + _set_or_verify_hash(parse, path_key="schemaPath", hash_key="schemaHash", label="policy parse schema") + success = contract.setdefault("success", {}) + contract_file = str(success.get("contractFile") or "").strip() + if contract_file: + success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root) + _set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract") + + +def _resolve_success_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None: + for contract in (policy.get("steps") or {}).values(): + success = contract.setdefault("success", {}) + contract_file = str(success.get("contractFile") or "").strip() + if contract_file: + success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root) + _set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract") + + +def _resolve_step_assets(step: str, assets: dict[str, Any], project_root: Path) -> dict[str, str]: + skill_name = str(assets.get("skillName") or "").strip() + if not skill_name: + raise PolicyError(f"missing skillName for {step}") + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError as exc: + raise PolicyError(str(exc)) from exc + skills_root = skill_dir.parent + required = set(assets.get("required") or []) + files = { + "skill": _resolve_required_file(skill_dir / "SKILL.md", project_root, required, "skill", step), + "workflow": _resolve_candidate_file(skill_dir, assets.get("workflowCandidates"), project_root, required, "workflow", step), + "instructions": _resolve_candidate_file(skill_dir, assets.get("instructionsCandidates"), project_root, required, "instructions", step), + "checklist": _resolve_candidate_file(skill_dir, assets.get("checklistCandidates"), project_root, required, "checklist", step), + "template": _resolve_candidate_file(skill_dir, assets.get("templateCandidates"), project_root, required, "template", step), + } + if not files["skill"]: + files["workflow"] = "" + files["instructions"] = "" + files["checklist"] = "" + files["template"] = "" + return files + + +def _resolve_required_file(path: Path, project_root: Path, required: set[str], asset: str, step: str) -> str: + if path.is_file(): + return _display_path(path, project_root) + if asset in required: + raise PolicyError(f"missing required {asset} asset for {step}: {path}") + return "" + + +def _resolve_candidate_file( + skill_dir: Path, + candidates: Any, + project_root: Path, + required: set[str], + asset: str, + step: str, +) -> str: + if not isinstance(candidates, list): + candidates = [] + for name in candidates: + if not isinstance(name, str) or not name: + continue + path = _ensure_within(skill_dir / name, skill_dir, f"{asset} candidate for {step}") + if path.is_file(): + return _display_path(path, project_root) + if asset == "workflow" and asset in required: + skill_file = skill_dir / "SKILL.md" + if skill_file.is_file(): + return _display_path(skill_file, project_root) + if asset in required: + searched = ", ".join(str(skill_dir / str(name)) for name in candidates if isinstance(name, str) and name) + raise PolicyError(f"missing required {asset} asset for {step}: {searched}") + return "" + + +def _resolve_data_path(path_value: str, *, project_root: Path, bundle_root: Path) -> str: + portable = resolve_portable_path(path_value, project_root) + if portable: + if not portable.is_file(): + raise PolicyError(f"policy data file missing: {path_value}") + return str(portable) + raw = Path(path_value) + allowed_roots = (bundle_root.resolve(), project_root.resolve()) + if raw.is_absolute(): + resolved = raw.resolve() + if not _is_within_any(resolved, allowed_roots): + raise PolicyError(f"policy data path escapes allowed roots: {path_value}") + if not resolved.is_file(): + raise PolicyError(f"policy data file missing: {raw}") + return str(resolved) + escaped_all = True + for base in allowed_roots: + candidate = (base / raw).resolve() + if not _is_within(candidate, base): + continue + escaped_all = False + if candidate.is_file(): + return str(candidate) + if escaped_all: + raise PolicyError(f"policy data path escapes allowed roots: {path_value}") + raise PolicyError(f"policy data file missing: {path_value}") + + +def _snapshot_relative_dir(policy: dict[str, Any]) -> str: + snapshot = _expect_optional_dict(policy, "snapshot") + relative_dir = str(snapshot.get("relativeDir") or "").strip() + if not relative_dir: + raise PolicyError("snapshot.relativeDir missing") + return relative_dir + + +def _resolve_snapshot_dir(policy: dict[str, Any], project_root: Path) -> Path: + raw = Path(_snapshot_relative_dir(policy)) + candidate = raw if raw.is_absolute() else project_root / raw + return _ensure_within(candidate, project_root.resolve(), "snapshot.relativeDir") + + +def _stable_policy_json(policy: dict[str, Any]) -> str: + return json.dumps(policy, indent=2, sort_keys=True) + "\n" + + +def _display_path(path: Path, project_root: Path) -> str: + try: + return str(path.resolve().relative_to(project_root.resolve())) + except ValueError: + return str(path.resolve()) + + +def _resolve_state_path(project_root: Path, path: Path, *, allow_outside: bool = True, label: str = "state file") -> Path: + candidate = path if path.is_absolute() else project_root / path + if allow_outside: + return candidate.resolve() + return _ensure_within(candidate, project_root.resolve(), label) + + +def _set_or_verify_hash(payload: dict[str, Any], *, path_key: str, hash_key: str, label: str) -> None: + path = str(payload.get(path_key) or "").strip() + if not path: + return + actual = md5_hex8(read_text(path)) + expected = str(payload.get(hash_key) or "").strip() + if expected and expected != actual: + raise PolicyError(f"{label} hash mismatch: {path}") + payload[hash_key] = actual + + +def _ensure_within(path: Path, root: Path, label: str) -> Path: + resolved = path.resolve() + root_resolved = root.resolve() + try: + resolved.relative_to(root_resolved) + except ValueError as exc: + raise PolicyError(f"{label} escapes allowed root: {path}") from exc + return resolved + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def _is_within_any(path: Path, roots: tuple[Path, ...]) -> bool: + return any(_is_within(path, root) for root in roots) + + +def _state_policy_mode(fields: dict[str, Any]) -> tuple[str, str, bool]: + snapshot_file = str(fields.get("policySnapshotFile") or "").strip() + snapshot_hash = str(fields.get("policySnapshotHash") or "").strip() + policy_version = str(fields.get("policyVersion") or "").strip() + legacy_policy = str(fields.get("legacyPolicy") or "").strip().lower() + if snapshot_file or snapshot_hash: + if not snapshot_file or not snapshot_hash: + raise PolicyError("state policy metadata incomplete") + if legacy_policy == "true": + raise PolicyError("state policy metadata contradictory") + return snapshot_file, snapshot_hash, False + if legacy_policy == "false" or policy_version: + raise PolicyError("state policy snapshot missing") + if legacy_policy == "true": + return "", "", True + return "", "", True + + +def _expect_optional_dict(payload: dict[str, Any], key: str) -> dict[str, Any]: + value = payload.get(key) + if value is None: + return {} + if not isinstance(value, dict): + raise PolicyError(f"{key} must be an object") + return value + + +def _expect_step_dict(contract: dict[str, Any], key: str, step: str) -> dict[str, Any]: + value = contract.get(key) + if value is None: + return {} + if not isinstance(value, dict): + raise PolicyError(f"{step}.{key} must be an object") + return value + + +def _expect_optional_nested_dict(payload: dict[str, Any], key: str, label: str) -> dict[str, Any]: + value = payload.get(key) + if value is None: + return {} + if not isinstance(value, dict): + raise PolicyError(f"{label}.{key} must be an object") + return value + + +def _normalize_policy_label(value: str) -> str: + return value.strip().lower().replace("_", "-") diff --git a/skills/bmad-story-automator/src/story_automator/core/state_document.py b/skills/bmad-story-automator/src/story_automator/core/state_document.py new file mode 100644 index 00000000..26739885 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/state_document.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .runtime_policy import workflow_sequence +from .utils import read_text + + +def story_progress_steps(policy: dict[str, Any]) -> list[str]: + return [step for step in workflow_sequence(policy) if step != "retro"] + + +def progress_headers(policy: dict[str, Any]) -> list[str]: + headers = ["Story"] + steps = policy.get("steps") or {} + for step in story_progress_steps(policy): + contract = steps.get(step) if isinstance(steps, dict) else None + label = str((contract or {}).get("label") or step).strip() or step + headers.append(label.replace("_", "-")) + headers.extend(["git-commit", "Status"]) + return headers + + +def progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: + headers = progress_headers(policy) + divider = markdown_divider(len(headers)) + pending_cells = ["⏳"] * (len(headers) - 3) + ["⏳", "pending"] + rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) + return ( + "| " + " | ".join(headers) + " |", + "| " + " | ".join(divider) + " |", + rows, + ) + + +def markdown_divider(width: int) -> list[str]: + return ["-------" if idx == 0 else "----------" for idx in range(width)] + + +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 normalize_progress_key(value: str, policy: dict[str, Any] | None = None) -> str: + key = str(value or "").strip().lower().replace("_", "-") + aliases = { + "create": "create-story", + "create-story": "create-story", + "dev": "dev-story", + "dev-story": "dev-story", + "auto": "automate", + "automate": "automate", + "review": "code-review", + "code-review": "code-review", + "git_commit": "git-commit", + "git-commit": "git-commit", + "status": "status", + "story": "story", + } + if policy is not None: + steps = policy.get("steps") or {} + for step in story_progress_steps(policy): + contract = steps.get(step) if isinstance(steps, dict) else None + label = str((contract or {}).get("label") or step).strip().lower().replace("_", "-") + aliases[step.replace("_", "-")] = label + aliases[label] = label + return aliases.get(key, key) + + +def sanitize_progress_value(value: str) -> str | None: + trimmed = value.strip() + if not trimmed or "|" in trimmed or "\n" in trimmed or "\r" in trimmed: + return None + return trimmed + + +def update_story_progress(state_file: str | Path, story_id: str, updates: dict[str, str], *, policy: dict[str, Any] | None = None) -> tuple[bool, dict[str, Any]]: + try: + lines = read_text(state_file).splitlines() + except OSError: + return False, {"ok": False, "error": "state_file_unreadable"} + 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, policy) 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: + return False, {"ok": False, "error": "progress_table_not_found"} + if story_idx < 0 or not story_cells: + return False, {"ok": False, "error": "story_row_not_found"} + if len(story_cells) != len(headers): + return False, {"ok": False, "error": "progress_row_misaligned"} + + header_map = {name: pos for pos, name in enumerate(headers)} + applied: list[str] = [] + unresolved: list[str] = [] + for key, value in updates.items(): + normalized_key = normalize_progress_key(key, policy) + if normalized_key == "story": + return False, {"ok": False, "error": "story_column_immutable"} + sanitized = sanitize_progress_value(value) + if sanitized is None: + return False, {"ok": False, "error": "invalid_progress_value", "argument": f"{key}={value}"} + pos = header_map.get(normalized_key) + if pos is None: + unresolved.append(normalized_key) + continue + story_cells[pos] = sanitized + applied.append(normalized_key) + if unresolved: + return False, {"ok": False, "error": "progress_columns_not_found", "missing": unresolved} + if not applied: + return False, {"ok": False, "error": "progress_columns_not_found"} + lines[story_idx] = render_markdown_row(story_cells) + try: + Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") + except OSError: + return False, {"ok": False, "error": "state_file_unwritable"} + return True, {"ok": True, "story": story_id, "updated": applied} + + +def progress_metrics(text: str) -> dict[str, int]: + total = 0 + completed = 0 + in_table = False + for line in text.splitlines(): + if line.startswith("| Story "): + in_table = True + continue + if in_table and re.match(r"^\|[- ]*\|", line): + continue + if in_table and line.startswith("|"): + parts = [part.strip() for part in line.split("|")] + values = parts[1:-1] + if len(values) >= 2: + first_cell = values[0].strip() + if re.fullmatch(r"-+", first_cell): + continue + total += 1 + if values[-1].strip().lower() in {"done", "complete", "completed"}: + completed += 1 + continue + if in_table and not line.startswith("|"): + in_table = False + return {"storiesCompleted": completed, "total": total} diff --git a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py new file mode 100644 index 00000000..ba42634e --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .runtime_policy import PolicyError, load_effective_policy +from .tea_policy_assets import ( + explicit_tea_assets_status, + resolved_explicit_tea_status, + tea_assets_complete, + tea_assets_root, + tea_detected_assets_root, + tea_project_signals, + tea_skill_availability, + tea_skill_installed, + tea_step_contracts, +) +from .tea_policy_config import ( + as_bool, + explicit_policy_path, + explicit_policy_payload, + explicit_policy_sequence, + normalize_option_list, + normalize_string_list, + path_is_file, + policy_copy, +) +from .workflow_steps import ( + STANDARD_SEQUENCE, + selected_optional_steps_from_sequence, + tea_sequence, + tea_steps_from_sequence, + workflow_track_for_sequence, +) + + +def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: + explicit_override = config.get("policyOverride") + if isinstance(explicit_override, dict): + resolved = load_effective_policy(str(project_root), inline_override=policy_copy(explicit_override)) + sequence = [step for step in ((resolved.get("workflow") or {}).get("sequence") or []) if isinstance(step, str)] + track = workflow_track_for_sequence(sequence) + 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": selected_optional_steps_from_sequence(sequence), + "manualCheckpoints": [], + "notes": notes, + } + + explicit_override_payload, explicit_override_error = explicit_policy_payload(project_root) + explicit_override_sequence, explicit_override_shape_error = explicit_policy_sequence(explicit_override_payload) + explicit_override_error = explicit_override_error or explicit_override_shape_error + explicit_override_track = workflow_track_for_sequence(explicit_override_sequence) if explicit_override_payload else "standard" + explicit_override_resolved, explicit_override_validation_error = explicit_project_policy_details( + project_root, explicit_override_payload + ) + + has_run_selection = any( + key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") + ) + if not has_run_selection: + if explicit_override_error: + raise PolicyError(explicit_override_error) + if explicit_override_payload and explicit_override_resolved is None: + raise PolicyError(explicit_override_validation_error or "explicit story-automator policy is invalid") + sequence = [ + step + for step in (((explicit_override_resolved or {}).get("workflow") or {}).get("sequence") or []) + if isinstance(step, str) + ] + return { + "policyOverride": {}, + "workflowTrack": workflow_track_for_sequence(sequence) if sequence else "standard", + "selectedOptionalSteps": selected_optional_steps_from_sequence(sequence), + "manualCheckpoints": [], + "notes": [], + } + + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + raise PolicyError(f"unknown workflowTrack: {track}") + 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": + if explicit_override_payload and explicit_override_track == "tea": + if explicit_override_resolved is None: + raise PolicyError(explicit_override_validation_error or "explicit TEA story-automator policy is invalid") + explicit_sequence = ((explicit_override_resolved.get("workflow") or {}).get("sequence")) or [] + selected = set(selected_optional_steps_from_sequence([step for step in explicit_sequence if isinstance(step, str)])) + if normalize_option_list(config.get("selectedOptionalSteps")) or "includeRetro" in config: + notes.append("Per-run TEA optional-step selection was ignored because the project defines an explicit TEA story-automator policy.") + if normalize_option_list(config.get("manualCheckpoints")): + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") + return { + "policyOverride": {}, + "workflowTrack": track, + "selectedOptionalSteps": sorted(selected), + "manualCheckpoints": [], + "notes": notes, + } + + 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 "retro" in selected else as_bool(config.get("includeRetro"), False) + 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 = tea_sequence(include_nfr=include_nfr, include_retro=include_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: + if explicit_override_payload and explicit_override_track == "standard": + if explicit_override_resolved is None: + raise PolicyError(explicit_override_validation_error or "explicit standard story-automator policy is invalid") + explicit_sequence = ((explicit_override_resolved.get("workflow") or {}).get("sequence")) or [] + selected = set(selected_optional_steps_from_sequence([step for step in explicit_sequence if isinstance(step, str)])) + if normalize_option_list(config.get("selectedOptionalSteps")) or "includeRetro" in config: + notes.append("Per-run standard optional-step selection was ignored because the project defines an explicit standard story-automator policy.") + if normalize_option_list(config.get("manualCheckpoints")): + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") + return { + "policyOverride": {}, + "workflowTrack": track, + "selectedOptionalSteps": sorted(selected), + "manualCheckpoints": [], + "notes": notes, + } + include_retro = "retro" in selected if "retro" in selected else as_bool(config.get("includeRetro"), True) + sequence = list(STANDARD_SEQUENCE[:-1]) + 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.") + selection = { + "policyOverride": policy_override, + "workflowTrack": track, + "selectedOptionalSteps": sorted(selected), + "manualCheckpoints": [], + "notes": notes, + } + load_effective_policy(str(project_root), inline_override=policy_copy(selection["policyOverride"])) + return selection + + +def detect_workflow_track(project_root: Path) -> dict[str, Any]: + signals = tea_project_signals(project_root) + explicit_override_present, explicit_override_stat_error = path_is_file(explicit_policy_path(project_root)) + explicit_override_payload, explicit_override_error = explicit_policy_payload(project_root) + explicit_override_sequence, explicit_override_shape_error = explicit_policy_sequence(explicit_override_payload) + explicit_override_error = explicit_override_error or explicit_override_shape_error + explicit_override_track = workflow_track_for_sequence(explicit_override_sequence) if explicit_override_payload else "standard" + explicit_override_resolved, explicit_override_validation_error = explicit_project_policy_details( + project_root, explicit_override_payload + ) + explicit_steps = explicit_tea_steps(project_root) + explicit_policy = bool(explicit_steps) + explicit_policy_valid = explicit_policy and explicit_override_resolved is not None + if explicit_policy and explicit_policy_valid: + available_skills, assets_root = resolved_explicit_tea_status(explicit_override_resolved, explicit_steps) + missing_skills = [] + missing_assets = [] + assets_ok = True + elif explicit_policy: + available_skills, missing_skills = tea_skill_availability(project_root, explicit_steps or None) + assets_root, missing_assets = explicit_tea_assets_status(project_root, explicit_override_payload, explicit_steps) + assets_ok = not missing_assets + else: + assets_root = tea_detected_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_override_validation_error: + reasons.append(explicit_override_validation_error) + elif explicit_override_payload and explicit_override_track == "standard" and explicit_override_resolved is not None: + reasons.append("Project already defines an explicit standard story-automator policy override.") + elif explicit_override_payload and explicit_override_track == "standard": + reasons.append("Project defines an explicit standard story-automator policy override, but it is invalid.") + if explicit_override_validation_error: + reasons.append(explicit_override_validation_error) + elif explicit_override_stat_error: + reasons.append("Project defines a story-automator policy override, but it is unreadable.") + reasons.append(explicit_override_stat_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 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 explicit_tea_steps(project_root: Path) -> list[str]: + payload, _ = explicit_policy_payload(project_root) + sequence, _ = explicit_policy_sequence(payload) + return tea_steps_from_sequence(sequence) + + +def explicit_project_policy_details(project_root: Path, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, str]: + if not payload: + return None, "" + try: + return load_effective_policy(str(project_root), resolve_assets=True), "" + except (FileNotFoundError, PolicyError, ValueError) as exc: + return None, str(exc) diff --git a/skills/bmad-story-automator/src/story_automator/core/tea_policy_assets.py b/skills/bmad-story-automator/src/story_automator/core/tea_policy_assets.py new file mode 100644 index 00000000..9c4ca0cc --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy_assets.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .runtime_layout import bundled_story_skill_root, resolve_skill_dir +from .utils import file_exists +from .workflow_steps import WORKFLOW_STEPS, tea_required_steps, tea_skill_aliases + + +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 + first_existing: Path | None = None + for candidate in candidates: + if not candidate.exists(): + continue + if first_existing is None: + first_existing = candidate + if tea_assets_complete_for_base(candidate): + return candidate + if first_existing is not None: + return first_existing + 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(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: + for skill_name in tea_skill_aliases(step): + 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]: + steps: dict[str, Any] = {} + for step in tea_required_steps(include_nfr): + prompt, schema = tea_contract_files(project_root, assets_root, step) + steps[step] = { + "label": WORKFLOW_STEPS[step].label, + "assets": { + "skillName": resolve_tea_skill_name(project_root, step), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": schema}, + "success": {"verifier": "session_exit"}, + } + return steps + + +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 tea_required_steps()): + skill_name = resolve_tea_skill_name(project_root, step) + aliases = tea_skill_aliases(step) + missing_name = aliases[0] if aliases else step + if not skill_name: + missing.append(missing_name) + continue + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + missing.append(missing_name) + continue + if file_exists(str(skill_dir / "SKILL.md")): + available.append(skill_name) + else: + missing.append(missing_name) + 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 explicit_tea_assets_status(project_root: Path, payload: dict[str, Any], required_steps: list[str]) -> tuple[str, list[str]]: + steps = payload.get("steps") if isinstance(payload.get("steps"), dict) else {} + asset_roots: list[str] = [] + missing_assets: list[str] = [] + for step in required_steps: + contract = steps.get(step) + if not isinstance(contract, dict): + continue + prompt = contract.get("prompt") or {} + template_file = str(prompt.get("templateFile") or "").strip() + if not template_file: + continue + root = str(Path(template_file).parent.parent).replace("\\", "/") + if not root or root in asset_roots: + continue + asset_roots.append(root) + _, missing = tea_assets_complete(project_root, root) + missing_assets.extend(missing) + if not asset_roots: + return "", ["missing TEA story-automator assets root"] + dedup_missing = list(dict.fromkeys(missing_assets)) + return ", ".join(asset_roots), dedup_missing diff --git a/skills/bmad-story-automator/src/story_automator/core/tea_policy_config.py b/skills/bmad-story-automator/src/story_automator/core/tea_policy_config.py new file mode 100644 index 00000000..ed91cb64 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy_config.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +from .utils import read_text + + +def explicit_policy_path(project_root: Path) -> Path: + return project_root / "_bmad" / "bmm" / "story-automator.policy.json" + + +def path_is_file(path: Path) -> tuple[bool, str]: + try: + return path.is_file(), "" + except OSError as exc: + return False, str(exc) + + +def explicit_policy_payload(project_root: Path) -> tuple[dict[str, Any], str]: + override_path = explicit_policy_path(project_root) + override_exists, override_error = path_is_file(override_path) + if override_error: + return {}, f"explicit story-automator policy unreadable: {override_error}" + if not override_exists: + 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_policy_sequence(payload: dict[str, Any]) -> tuple[list[str], str]: + if not payload: + return [], "" + workflow = payload.get("workflow") + if workflow is None: + return [], "" + if not isinstance(workflow, dict): + return [], "explicit story-automator policy workflow must be an object" + sequence = workflow.get("sequence") or [] + if not isinstance(sequence, list) or any(not isinstance(item, str) for item in sequence): + return [], "explicit story-automator policy workflow.sequence must be a string array" + return list(sequence), "" + + +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 policy_copy(payload: dict[str, Any]) -> dict[str, Any]: + return deepcopy(payload) diff --git a/skills/bmad-story-automator/src/story_automator/core/workflow_steps.py b/skills/bmad-story-automator/src/story_automator/core/workflow_steps.py new file mode 100644 index 00000000..ee179050 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/workflow_steps.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class WorkflowStep: + name: str + label: str + track: str + optional: bool = False + skill_aliases: tuple[str, ...] = () + + +WORKFLOW_STEPS = { + "create": WorkflowStep("create", "create-story", "standard"), + "dev": WorkflowStep("dev", "dev-story", "standard"), + "auto": WorkflowStep("auto", "automate", "standard"), + "review": WorkflowStep("review", "code-review", "standard"), + "retro": WorkflowStep("retro", "retro", "standard", optional=True), + "atdd": WorkflowStep("atdd", "atdd", "tea", skill_aliases=("bmad-testarch-atdd", "bmad-tea-testarch-atdd")), + "test_automate": WorkflowStep( + "test_automate", + "test-automate", + "tea", + skill_aliases=("bmad-testarch-automate", "bmad-tea-testarch-automate"), + ), + "test_review": WorkflowStep( + "test_review", + "test-review", + "tea", + skill_aliases=("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), + ), + "trace": WorkflowStep("trace", "trace", "tea", skill_aliases=("bmad-testarch-trace", "bmad-tea-testarch-trace")), + "nfr": WorkflowStep("nfr", "nfr", "tea", optional=True, skill_aliases=("bmad-testarch-nfr", "bmad-tea-testarch-nfr")), +} + +STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] +TEA_CORE_SEQUENCE = ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"] +TEA_OPTIONAL_STEPS = {"nfr", "retro"} +TEA_QUALITY_STEPS = ["test_automate", "test_review", "nfr", "trace"] +TEA_TRACK_STEPS = {name for name, step in WORKFLOW_STEPS.items() if step.track == "tea"} +VALID_STEP_NAMES = set(WORKFLOW_STEPS) + + +def selected_optional_steps_from_sequence(sequence: list[str]) -> list[str]: + return [step for step in ("nfr", "retro") if step in sequence] + + +def workflow_track_for_sequence(sequence: list[str]) -> str: + return "tea" if any(step in TEA_TRACK_STEPS for step in sequence) else "standard" + + +def tea_steps_from_sequence(sequence: list[str]) -> list[str]: + return [step for step in sequence if step in TEA_TRACK_STEPS] + + +def tea_summary_steps(sequence: list[str]) -> list[str]: + return [WORKFLOW_STEPS[step].label for step in sequence if step in TEA_TRACK_STEPS] + + +def summary_steps_for_track(sequence: list[str], track: str) -> list[str]: + return [WORKFLOW_STEPS[step].label for step in sequence if step in WORKFLOW_STEPS and WORKFLOW_STEPS[step].track == track] + + +def tea_skill_aliases(step: str) -> tuple[str, ...]: + return WORKFLOW_STEPS.get(step, WorkflowStep(step, step, "unknown")).skill_aliases + + +def tea_required_steps(include_nfr: bool = False) -> list[str]: + steps = ["atdd", "test_automate", "test_review", "trace"] + if include_nfr: + steps.append("nfr") + return steps + + +def tea_sequence(*, include_nfr: bool, include_retro: bool) -> list[str]: + sequence = list(TEA_CORE_SEQUENCE[:-2]) + if include_nfr: + sequence.append("nfr") + sequence.extend(TEA_CORE_SEQUENCE[-2:]) + if include_retro: + sequence.append("retro") + return sequence 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 2f839fc4..60deb849 100644 --- a/skills/bmad-story-automator/steps-c/step-01b-continue.md +++ b/skills/bmad-story-automator/steps-c/step-01b-continue.md @@ -150,8 +150,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..9d888655 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,57 @@ 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` = JSON array string containing zero or more of `retro`, `nfr` + - examples: `[]`, `["retro"]`, `["nfr","retro"]` +- `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 +154,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 +196,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..22e71c17 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,12 @@ 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 + +policy_sequence=$("$scripts" orchestrator-helper policy-sequence --state-file "$state_file") ``` Display: "**Story {N}/{total}: {title}**" @@ -148,30 +158,97 @@ 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 +if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow sequence unavailable; cannot evaluate ATDD scope." + exit 1 +fi +if echo "$policy_sequence" | jq -e '.sequence | index("atdd")' >/dev/null; then + "$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep=atdd \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + resolve_agent_for_task "atdd" "$state_file" "{story_id}" + if should_apply_primary_model "$current_agent"; then + built_cmd=$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") + else + built_cmd=$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --state-file "$state_file") + fi + session=$("$scripts" tmux-wrapper spawn atdd {epic} {story_id} \ + --agent "$current_agent" \ + --command "$built_cmd") + 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") + next_action=$(echo "$parsed" | jq -r '.next_action') + if [ "$next_action" = "proceed" ]; then + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set atdd=done \ + --set status=in-progress + fi +else + echo "[story {N}/{total}] atdd -> skipped (not in policy sequence)" + next_action="proceed" +fi +``` + +- If ATDD ran and `next_action == "proceed"`: + → continue to the next policy-defined step +- If ATDD ran and `next_action == "retry"` or session crashed → retry with fallback pattern +- If ATDD was not in the pinned sequence → skip directly to the next policy-defined step without writing `atdd=done` +- 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 +*Run only if the pinned policy sequence includes `dev`* + +If `dev` is not present in the pinned sequence, skip this phase entirely and proceed directly to the review phase transition below. **Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents. ```bash -# Retry loop with agent alternation: see {retryStrategy} -resolve_agent_for_task "dev" "$state_file" "{story_id}" -if should_apply_primary_model "$current_agent"; then - built_cmd=$("$scripts" tmux-wrapper build-cmd dev {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") +if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow sequence unavailable; cannot evaluate Dev Story scope." + exit 1 +fi +dev_in_scope=false +if echo "$policy_sequence" | jq -e '.sequence | index("dev")' >/dev/null; then + dev_in_scope=true else - built_cmd=$("$scripts" tmux-wrapper build-cmd dev {story_id} --agent "$current_agent" --state-file "$state_file") + echo "[story {N}/{total}] dev -> skipped (not in policy sequence)" +fi +if [ "$dev_in_scope" = "true" ]; then + # Retry loop with agent alternation: see {retryStrategy} + "$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep=dev \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + resolve_agent_for_task "dev" "$state_file" "{story_id}" + if should_apply_primary_model "$current_agent"; then + built_cmd=$("$scripts" tmux-wrapper build-cmd dev {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") + else + built_cmd=$("$scripts" tmux-wrapper build-cmd dev {story_id} --agent "$current_agent" --state-file "$state_file") + fi + session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \ + --agent "$current_agent" \ + --command "$built_cmd") + result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") + "$scripts" tmux-wrapper kill "$session" fi -session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \ - --agent "$current_agent" \ - --command "$built_cmd") -result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") -"$scripts" tmux-wrapper kill "$session" ``` **Session Parsing Contract (required):** @@ -180,28 +257,35 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") - Return normalized schema only: `next_action`, `confidence`, `error_class`, `reasons` ```bash -parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" dev) -next_action=$(echo "$parsed" | jq -r '.next_action') -confidence=$(echo "$parsed" | jq -r '.confidence // 0.0') -error_class=$(echo "$parsed" | jq -r '.error_class // "none"') -reasons=$(echo "$parsed" | jq -c '.reasons // []') +if [ "$dev_in_scope" = "true" ]; then + parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" dev --state-file "$state_file") + next_action=$(echo "$parsed" | jq -r '.next_action') + confidence=$(echo "$parsed" | jq -r '.confidence // 0.0') + error_class=$(echo "$parsed" | jq -r '.error_class // "none"') + reasons=$(echo "$parsed" | jq -c '.reasons // []') +else + next_action="proceed" +fi ``` -- If `next_action == "proceed"`: +- If `dev_in_scope == "false"` → skip directly to C (next step) +- If `dev_in_scope == "true"` and `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"`: +- If `dev_in_scope == "true"` and (`next_action == "retry"` OR `result.final_state == "crashed"`): - Attempts < 5 → retry with next agent (see `{retryStrategy}`) - Plateau detected (same task 3x) → DEFER story, continue to next - Attempts == 5 → escalate (all retries exhausted) ## 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..f507d88a 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,42 +26,166 @@ 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. ```bash -# --command required (see Spawn Pattern in step-03) -resolve_agent_for_task "auto" "$state_file" "{story_id}" -if should_apply_primary_model "$current_agent"; then - built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") +policy_sequence=$("$scripts" orchestrator-helper policy-sequence --state-file "$state_file") +if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow sequence unavailable; cannot evaluate automate scope." + exit 1 +fi +skip_automate=$(awk -F': ' '/^ skipAutomate:/ {print $2}' "$state_file" | tr -d '"') +auto_in_scope=false +if [ "$skip_automate" != "true" ] && echo "$policy_sequence" | jq -e '.sequence | index("auto")' >/dev/null; then + auto_in_scope=true +fi +if [ "$auto_in_scope" = "true" ]; then + # --command required (see Spawn Pattern in step-03) + resolve_agent_for_task "auto" "$state_file" "{story_id}" + if should_apply_primary_model "$current_agent"; then + built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") + else + built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --state-file "$state_file") + fi + session=$("$scripts" tmux-wrapper spawn auto {epic} {story_id} \ + --agent "$current_agent" \ + --command "$built_cmd") + result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") + "$scripts" tmux-wrapper kill "$session" else - built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --state-file "$state_file") + echo "[story {N}/{total}] automate -> skipped (disabled or not in policy sequence)" fi -session=$("$scripts" tmux-wrapper spawn auto {epic} {story_id} \ - --agent "$current_agent" \ - --command "$built_cmd") -result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") -"$scripts" tmux-wrapper kill "$session" ``` -- SUCCESS: +- If `auto_in_scope == "true"` and 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: +- If `auto_in_scope == "true"` and 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 +- If `auto_in_scope == "false"` → skip without writing any `auto=*` progress update + +### 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 +policy_sequence=$("$scripts" orchestrator-helper policy-sequence --state-file "$state_file") +if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow sequence unavailable; cannot evaluate TEA quality-step scope." + exit 1 +fi +resume_summary=$("$scripts" orchestrator-helper state-summary "$state_file") +resume_step=$(echo "$resume_summary" | jq -r '.currentStep // ""') +quality_steps_json=$("$scripts" orchestrator-helper policy-steps --state-file "$state_file" --group tea-quality) +if ! echo "$quality_steps_json" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow quality steps unavailable." + exit 1 +fi +resume_mode=false +skip_tea_quality_steps=false +if [ "$resume_step" = "review" ]; then + skip_tea_quality_steps=true +elif echo "$quality_steps_json" | jq -e --arg step "$resume_step" '.steps | index($step)' >/dev/null; then + resume_mode=true +fi +resume_gate_open=false +mapfile -t tea_steps < <(echo "$quality_steps_json" | jq -r '.steps[]') +for idx in "${!tea_steps[@]}"; do + if [ "$skip_tea_quality_steps" = "true" ]; then + break + fi + tea_step="${tea_steps[$idx]}" + [ -n "$tea_step" ] || continue + if [ "$resume_mode" = "true" ] && [ "$resume_gate_open" = "false" ]; then + if [ "$tea_step" != "$resume_step" ]; then + continue + fi + resume_gate_open=true + fi + "$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep="$tea_step" \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + resolve_agent_for_task "$tea_step" "$state_file" "{story_id}" + if should_apply_primary_model "$current_agent"; then + built_cmd=$("$scripts" tmux-wrapper build-cmd "$tea_step" {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") + else + built_cmd=$("$scripts" tmux-wrapper build-cmd "$tea_step" {story_id} --agent "$current_agent" --state-file "$state_file") + fi + session=$("$scripts" tmux-wrapper spawn "$tea_step" {epic} {story_id} \ + --agent "$current_agent" \ + --command "$built_cmd") + result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") + "$scripts" tmux-wrapper kill "$session" + if ! parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" "$tea_step" --state-file "$state_file"); then + echo "TEA quality-step parser failed for $tea_step." + exit 1 + fi + next_action=$(echo "$parsed" | jq -r '.next_action') + + if [ "$next_action" = "proceed" ]; then + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set "$tea_step=done" \ + --set status=in-progress + next_quality_step="" + if [ $((idx + 1)) -lt "${#tea_steps[@]}" ]; then + next_quality_step="${tea_steps[$((idx + 1))]}" + fi + if [ -n "$next_quality_step" ]; then + "$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep="$next_quality_step" \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + else + "$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep=review \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + fi + else + break + fi +done +``` + +- If each concrete `tea_step` returns `next_action == "proceed"`: + → continue to the next policy-defined step +- If any `tea_step` returns `next_action == "retry"` or the session crashes → apply the retry/fallback pattern for that concrete step before continuing +- If `parse-output` fails for any `tea_step` → fail/retry/escalate before entering code review; do not treat the pre-review phase as complete +- 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 @@ -98,8 +222,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..b1b2785b 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` @@ -108,12 +111,21 @@ fi wait "$epic_status_pid" epic_status=$(cat "$tmp_epic_status") rm -f "$tmp_epic_status" +policy_sequence=$("{scriptsDir}" orchestrator-helper policy-sequence --state-file "{outputFile}") +if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then + echo "Pinned workflow sequence unavailable; cannot evaluate retrospective scope." + exit 1 +fi +policy_has_retro=false +if echo "$policy_sequence" | jq -e '.sequence | index("retro")' >/dev/null; then + policy_has_retro=true +fi epic_complete=$(echo "$epic_status" | jq -r '.allStoriesDone') epic_ok=$(echo "$epic_status" | jq -r '.ok') # Both checks must pass -if [ "$all_done" = "true" ] && [ "$epic_ok" = "true" ] && [ "$epic_complete" = "true" ]; then +if [ "$policy_has_retro" = "true" ] && [ "$all_done" = "true" ] && [ "$epic_ok" = "true" ] && [ "$epic_complete" = "true" ]; then trigger_retro=true else trigger_retro=false 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/tea_test_support.py b/tests/tea_test_support.py new file mode 100644 index 00000000..9cc7d176 --- /dev/null +++ b/tests/tea_test_support.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import json +import shutil +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class patch_env: + def __init__(self, project_root: Path, extra: dict[str, str] | None = None) -> None: + self.project_root = str(project_root) + self.extra = extra or {} + self.previous: dict[str, str | None] = {} + + def __enter__(self) -> None: + import os + + self.previous["PROJECT_ROOT"] = os.environ.get("PROJECT_ROOT") + os.environ["PROJECT_ROOT"] = self.project_root + for key, value in self.extra.items(): + self.previous[key] = os.environ.get(key) + os.environ[key] = value + + def __exit__(self, exc_type, exc, tb) -> None: + import os + + for key, value in self.previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def install_bundle(project_root: Path) -> None: + source_skill = REPO_ROOT / "skills" / "bmad-story-automator" + source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" + target_root = project_root / ".claude" / "skills" + target_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_skill, target_root / "bmad-story-automator") + shutil.copytree(source_review, target_root / "bmad-story-automator-review") + + +def install_required_skills(project_root: Path) -> None: + for name in ("bmad-create-story", "bmad-dev-story", "bmad-retrospective", "bmad-qa-generate-e2e-tests"): + skill_dir = 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") + (project_root / ".claude" / "skills" / "bmad-create-story" / "discover-inputs.md").write_text( + "# discover\n", encoding="utf-8" + ) + (project_root / ".claude" / "skills" / "bmad-create-story" / "checklist.md").write_text( + "# checklist\n", encoding="utf-8" + ) + (project_root / ".claude" / "skills" / "bmad-create-story" / "template.md").write_text( + "# template\n", encoding="utf-8" + ) + (project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text( + "# checklist\n", encoding="utf-8" + ) + (project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text( + "# checklist\n", encoding="utf-8" + ) + + +def install_tea_skills( + project_root: Path, + *, + include_nfr: bool = False, + canonical: bool = False, + write_assets: bool = True, +) -> None: + if write_assets: + write_tea_assets(project_root) + else: + (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 = 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, *, 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", + }, + } + ), + 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", + }, + } + ), + 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", + }, + } + ), + 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", + }, + } + ), + 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", + }, + } + ), + encoding="utf-8", + ) + + +def tea_steps_override( + *, + 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 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..dd700890 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,41 @@ 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") + schema = { + "requiredKeys": ["status", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "summary": "brief description", + "next_action": "proceed|retry", + }, + } + (parse / "atdd.json").write_text(json.dumps(schema), 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_orchestrator_progress.py b/tests/test_orchestrator_progress.py new file mode 100644 index 00000000..cbf4a268 --- /dev/null +++ b/tests/test_orchestrator_progress.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from story_automator.commands.orchestrator import cmd_orchestrator_helper +from tests.tea_test_support import install_bundle + + +class OrchestratorProgressTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + target_root = Path(self.tmp.name) / ".claude" / "skills" + install_bundle(Path(self.tmp.name)) + self.state_file = Path(self.tmp.name) / "state.md" + self.state_file.write_text( + "\n".join( + [ + "| Story | create-story | code-review | git-commit | Status |", + "|-------|----------|----------|----------|----------|", + "| 1.1 | ⏳ | ⏳ | ⏳ | pending |", + "", + ] + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_state_progress_rejects_markdown_unsafe_value(self) -> None: + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": self.tmp.name}), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(self.state_file), + "--story", + "1.1", + "--set", + "status=done|oops", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "invalid_progress_value") + + def test_state_progress_preserves_standard_aliases_without_policy(self) -> None: + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": self.tmp.name}), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(self.state_file), + "--story", + "1.1", + "--set", + "create=done", + "--set", + "review=done", + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + text = self.state_file.read_text(encoding="utf-8") + self.assertIn("| 1.1 | done | done | ⏳ | pending |", text) + + def test_state_progress_fails_when_any_requested_column_is_missing(self) -> None: + stdout = io.StringIO() + original = self.state_file.read_text(encoding="utf-8") + with patch.dict("os.environ", {"PROJECT_ROOT": self.tmp.name}), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(self.state_file), + "--story", + "1.1", + "--set", + "create=done", + "--set", + "atdd=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "progress_columns_not_found") + self.assertEqual(payload["missing"], ["atdd"]) + self.assertEqual(self.state_file.read_text(encoding="utf-8"), original) + + def test_state_progress_fails_closed_when_policy_snapshot_is_invalid(self) -> None: + self.state_file.write_text( + "\n".join( + [ + "---", + 'policySnapshotFile: "missing.json"', + 'policySnapshotHash: "deadbeef"', + "---", + "| Story | create-story | code-review | git-commit | Status |", + "|-------|----------|----------|----------|----------|", + "| 1.1 | ⏳ | ⏳ | ⏳ | pending |", + "", + ] + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": self.tmp.name}), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + ["state-progress", str(self.state_file), "--story", "1.1", "--set", "status=done"] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_invalid") + self.assertIn("policy snapshot missing", payload["reason"]) + + def test_policy_sequence_returns_pinned_sequence(self) -> None: + snapshot_dir = Path(self.tmp.name) / "_bmad-output" / "story-automator" / "snapshots" + snapshot_dir.mkdir(parents=True, exist_ok=True) + snapshot_path = snapshot_dir / "snap.json" + snapshot_path.write_text( + json.dumps( + { + "version": 1, + "runtime": {"parser": {"provider": "claude", "model": "sonnet", "timeoutSeconds": 30}}, + "workflow": {"sequence": ["create", "atdd", "dev", "trace", "review"]}, + "steps": { + "create": _session_exit_step("bmad-create-story"), + "dev": _session_exit_step("bmad-dev-story"), + "review": _review_step("bmad-qa-generate-e2e-tests"), + "atdd": _session_exit_step("bmad-testarch-atdd"), + "trace": _session_exit_step("bmad-testarch-trace"), + }, + } + ), + encoding="utf-8", + ) + state_file = Path(self.tmp.name) / "orchestration.md" + state_file.write_text( + "\n".join( + [ + "---", + f'policySnapshotFile: "{snapshot_path.relative_to(self.tmp.name)}"', + f'policySnapshotHash: "{_md5_hex8(snapshot_path.read_text(encoding="utf-8"))}"', + "---", + "", + ] + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": self.tmp.name}), redirect_stdout(stdout): + code = cmd_orchestrator_helper(["policy-sequence", "--state-file", str(state_file)]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["sequence"], ["create", "atdd", "dev", "trace", "review"]) + + +def _session_exit_step(skill_name: str) -> dict[str, object]: + return { + "label": skill_name, + "assets": { + "skillName": skill_name, + "workflowCandidates": [], + "instructionsCandidates": [], + "checklistCandidates": [], + "templateCandidates": [], + "required": [], + }, + "prompt": {"templateFile": "data/prompts/review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/parse/review.json"}, + "success": {"verifier": "session_exit"}, + } + + +def _review_step(skill_name: str) -> dict[str, object]: + step = _session_exit_step(skill_name) + step["success"] = {"verifier": "review_completion"} + return step + + +def _md5_hex8(text: str) -> str: + import hashlib + + return hashlib.md5(text.encode("utf-8"), usedforsecurity=False).hexdigest()[:8] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_policy_invariants.py b/tests/test_policy_invariants.py new file mode 100644 index 00000000..64fa87ee --- /dev/null +++ b/tests/test_policy_invariants.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +from story_automator.commands.state import cmd_build_run_policy +from story_automator.core.runtime_policy import PolicyError, load_effective_policy +from tests.tea_test_support import install_bundle, install_required_skills, install_tea_skills, patch_env, tea_steps_override + + +class PolicyInvariantTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.tmp.name) + install_bundle(self.project_root) + install_required_skills(self.project_root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_workflow_sequence_requires_review(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev"]}}) + with self.assertRaisesRegex(PolicyError, "workflow.sequence must include review"): + load_effective_policy(str(self.project_root)) + + def test_workflow_sequence_rejects_duplicates(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev", "dev", "review"]}}) + with self.assertRaisesRegex(PolicyError, "workflow.sequence contains duplicate steps: dev"): + load_effective_policy(str(self.project_root)) + + def test_unknown_workflow_track_fails_closed(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", json.dumps({"workflowTrack": "teaa"})]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_invalid") + self.assertIn("unknown workflowTrack: teaa", payload["reason"]) + + def test_explicit_tea_policy_ignores_include_retro_with_note(self) -> None: + install_tea_skills(self.project_root, canonical=True) + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(canonical=True), + } + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", json.dumps({"workflowTrack": "tea", "includeRetro": True})]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(any("Per-run TEA optional-step selection was ignored" in note for note in payload["notes"])) + + def _write_override(self, payload: dict[str, object]) -> None: + 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(payload), encoding="utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_progress_invariants.py b/tests/test_progress_invariants.py new file mode 100644 index 00000000..4c1861b1 --- /dev/null +++ b/tests/test_progress_invariants.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +from story_automator.commands.orchestrator import cmd_orchestrator_helper +from story_automator.commands.state import cmd_build_state_doc +from tests.tea_test_support import install_bundle, install_required_skills, patch_env + + +class ProgressInvariantTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.tmp.name) + self.output_dir = self.project_root / "_bmad-output" / "story-automator" + install_bundle(self.project_root) + install_required_skills(self.project_root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_build_state_doc_rejects_duplicate_story_ids(self) -> None: + payload = self._build_state_payload({"storyRange": ["1.1", "1.1"]}, expect_code=1) + self.assertEqual(payload["error"], "storyRange_contains_duplicates") + + def test_build_state_doc_rejects_markdown_unsafe_story_ids(self) -> None: + payload = self._build_state_payload({"storyRange": ["1|1"]}, expect_code=1) + self.assertEqual(payload["error"], "storyRange_contains_invalid_ids") + + def test_standard_progress_rejects_atdd_updates(self) -> None: + state_file = Path(self._build_state_payload({}, expect_code=0)["path"]) + 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", "status=in-progress"] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "progress_columns_not_found") + self.assertEqual(payload["missing"], ["atdd"]) + + def test_standard_progress_rejects_auto_updates_when_auto_column_missing(self) -> None: + state_file = self._write_minimal_state_without_auto() + 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", "auto=done", "--set", "status=in-progress"] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "progress_columns_not_found") + self.assertEqual(payload["missing"], ["automate"]) + + def _build_state_payload(self, overrides: dict[str, object], *, expect_code: int) -> dict[str, object]: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + config = { + "epic": "1", + "epicName": "Epic 1", + "storyRange": ["1.1"], + "status": "READY", + "aiCommand": "claude --dangerously-skip-permissions", + } + config.update(overrides) + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + ["--template", str(template), "--output-folder", str(self.output_dir), "--config-json", json.dumps(config)] + ) + self.assertEqual(code, expect_code) + return json.loads(stdout.getvalue()) + + def _write_minimal_state_without_auto(self) -> Path: + state_file = self.project_root / "state-no-auto.md" + state_file.write_text( + "\n".join( + [ + "| Story | create-story | dev-story | code-review | git-commit | Status |", + "|-------|----------|----------|----------|----------|----------|", + "| 1.1 | ⏳ | ⏳ | ⏳ | ⏳ | pending |", + "", + ] + ), + encoding="utf-8", + ) + return state_file + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_resume_matrix.py b/tests/test_resume_matrix.py new file mode 100644 index 00000000..cee484c9 --- /dev/null +++ b/tests/test_resume_matrix.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +STEP_01B = REPO_ROOT / "skills" / "bmad-story-automator" / "steps-c" / "step-01b-continue.md" +STEP_03A = REPO_ROOT / "skills" / "bmad-story-automator" / "steps-c" / "step-03a-execute-review.md" + + +class ResumeMatrixTests(unittest.TestCase): + def test_step_01b_routes_review_and_tea_tokens_to_step_03a(self) -> None: + text = STEP_01B.read_text(encoding="utf-8") + self.assertIn( + '`step-03a-execute-review` or `auto` or `test_automate` or `test_review` or `nfr` or `trace` or `review` → `{executeReviewStep}`', + text, + ) + + def test_step_03a_handles_review_resume_without_replaying_tea_steps(self) -> None: + text = STEP_03A.read_text(encoding="utf-8") + self.assertIn('skip_tea_quality_steps=false', text) + self.assertIn('policy-steps --state-file "$state_file" --group tea-quality', text) + self.assertIn('.steps | index($step)', text) + self.assertIn('mapfile -t tea_steps < <(echo "$quality_steps_json" | jq -r \'.steps[]\')', text) + self.assertIn('if [ "$skip_tea_quality_steps" = "true" ]; then', text) + self.assertIn('break', text) + + def test_step_03a_advances_current_step_after_each_tea_success(self) -> None: + text = STEP_03A.read_text(encoding="utf-8") + self.assertIn('next_quality_step=""', text) + self.assertIn('--set currentStep="$next_quality_step"', text) + self.assertIn('--set currentStep=review', text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index b3d9b475..0098a02e 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -10,10 +10,12 @@ 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, ) +from tests.tea_test_support import install_tea_skills, tea_steps_override REPO_ROOT = Path(__file__).resolve().parents[1] @@ -45,21 +47,119 @@ 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_inline_override_is_not_mutated_by_policy_resolution(self) -> None: + self._install_tea_skills() + inline_override = { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(), + } + load_effective_policy(str(self.project_root), inline_override=inline_override) + + atdd = inline_override["steps"]["atdd"] + self.assertNotIn("files", atdd["assets"]) + self.assertNotIn("templatePath", atdd["prompt"]) + self.assertNotIn("templateHash", atdd["prompt"]) + self.assertNotIn("schemaPath", atdd["parse"]) + self.assertNotIn("schemaHash", atdd["parse"]) + def test_invalid_step_name_rejected(self) -> None: - self._write_override({"steps": {"ship": {"success": {"verifier": "session_exit"}}}}) + self._write_override({"workflow": {"sequence": ["create", "ship"]}, "steps": {"ship": {"success": {"verifier": "session_exit"}}}}) with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_policy_sequence_requires_review(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev"]}}) + with self.assertRaisesRegex(PolicyError, "workflow.sequence must include review"): + load_effective_policy(str(self.project_root)) + + def test_duplicate_workflow_sequence_entries_rejected(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev", "dev", "review"]}}) + with self.assertRaisesRegex(PolicyError, "workflow.sequence contains duplicate steps: dev"): + load_effective_policy(str(self.project_root)) + + def test_invalid_unreferenced_step_definition_is_rejected_before_pruning(self) -> None: + self._write_override( + { + "workflow": {"sequence": ["create", "dev", "review"]}, + "steps": {"typo_step": {"success": {"verifier": "nope"}}}, + } + ) + with self.assertRaisesRegex(PolicyError, "unknown step names: typo_step"): + load_effective_policy(str(self.project_root)) + + def test_invalid_unreferenced_known_step_definition_is_rejected_before_pruning(self) -> None: + self._write_override( + { + "workflow": {"sequence": ["create", "dev", "review"]}, + "steps": {"auto": {"success": {"verifier": "nope"}}}, + } + ) + with self.assertRaisesRegex(PolicyError, "invalid verifier for auto: nope"): + 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._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): load_effective_policy(str(self.project_root)) + def test_invalid_label_with_markdown_delimiter_rejected(self) -> None: + self._write_override({"steps": {"review": {"label": "code|review"}}}) + with self.assertRaisesRegex(PolicyError, "invalid label for review"): + load_effective_policy(str(self.project_root)) + + def test_label_collision_with_reserved_progress_column_rejected(self) -> None: + self._write_override({"steps": {"review": {"label": "Status"}}}) + with self.assertRaisesRegex(PolicyError, "step label collides with reserved progress column for review: Status"): + load_effective_policy(str(self.project_root)) + + def test_label_collision_with_another_progress_column_rejected(self) -> None: + self._write_override({"steps": {"review": {"label": "dev-story"}}}) + with self.assertRaisesRegex(PolicyError, "step label collides with another progress column: review, dev"): + load_effective_policy(str(self.project_root)) + def test_required_asset_missing_fails(self) -> None: shutil.rmtree(self.project_root / ".claude" / "skills" / "bmad-create-story") 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._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": { + "atdd": steps["atdd"], + }, + } + ) + with self.assertRaisesRegex(PolicyError, "missing required skill asset for atdd"): + 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 +203,60 @@ 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_unreadable_override_probe_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") + + with patch( + "story_automator.core.runtime_policy._path_is_file", + side_effect=lambda path: (_ for _ in ()).throw(OSError("permission denied")) if Path(path).resolve() == override_path else Path(path).is_file(), + ): + with self.assertRaisesRegex(PolicyError, r"project override unreadable: .*story-automator\.policy\.json"): + load_effective_policy(str(self.project_root)) + + def test_unused_invalid_override_step_breaks_standard_inline_selection(self) -> None: + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {"atdd": {"assets": []}}, + } + ) + with self.assertRaisesRegex(PolicyError, "atdd.assets must be an object"): + load_effective_policy( + str(self.project_root), + inline_override={"workflow": {"sequence": ["create", "dev", "review"]}}, + ) + + 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 +417,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 +487,9 @@ 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: + install_tea_skills(self.project_root, canonical=False, write_assets=True) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 802bf343..3edf690f 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -2,19 +2,16 @@ import io import json -import shutil import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path -from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper +from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.state import cmd_build_state_doc, cmd_validate_state from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper - - -REPO_ROOT = Path(__file__).resolve().parents[1] +from tests.tea_test_support import install_bundle, install_required_skills, patch_env class StatePolicyMetadataTests(unittest.TestCase): @@ -22,8 +19,8 @@ def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() self.project_root = Path(self.tmp.name) self.output_dir = self.project_root / "_bmad-output" / "story-automator" - self._install_bundle() - self._install_required_skills() + install_bundle(self.project_root) + install_required_skills(self.project_root) def tearDown(self) -> None: self.tmp.cleanup() @@ -33,14 +30,7 @@ def test_state_doc_writes_policy_metadata(self) -> None: template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_build_state_doc( - [ - "--template", - str(template), - "--output-folder", - str(self.output_dir), - "--config-json", - json.dumps(self._config()), - ] + ["--template", str(template), "--output-folder", str(self.output_dir), "--config-json", json.dumps(self._config())] ) self.assertEqual(code, 0) state_file = Path(json.loads(stdout.getvalue())["path"]) @@ -78,14 +68,7 @@ def test_validate_state_accepts_agent_config_without_legacy_ai_command(self) -> config["agentConfig"] = {"defaultPrimary": "auto", "defaultFallback": False} with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_build_state_doc( - [ - "--template", - str(template), - "--output-folder", - str(self.output_dir), - "--config-json", - json.dumps(config), - ] + ["--template", str(template), "--output-folder", str(self.output_dir), "--config-json", json.dumps(config)] ) self.assertEqual(code, 0) state_file = Path(json.loads(stdout.getvalue())["path"]) @@ -187,10 +170,7 @@ def test_summary_does_not_mark_contradictory_legacy_flag_as_legacy(self) -> None def test_summary_clears_contradictory_snapshot_metadata(self) -> None: state_file = self.project_root / "orchestration.md" - state_file.write_text( - "---\npolicySnapshotFile: \"snap.json\"\npolicySnapshotHash: \"deadbeef\"\nlegacyPolicy: true\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\npolicySnapshotFile: \"snap.json\"\npolicySnapshotHash: \"deadbeef\"\nlegacyPolicy: true\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["state-summary", str(state_file)]) @@ -203,10 +183,7 @@ def test_summary_clears_contradictory_snapshot_metadata(self) -> None: def test_summary_clears_incomplete_snapshot_metadata(self) -> None: state_file = self.project_root / "orchestration.md" - state_file.write_text( - "---\npolicySnapshotFile: \"snap.json\"\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\npolicySnapshotFile: \"snap.json\"\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["state-summary", str(state_file)]) @@ -219,10 +196,7 @@ def test_summary_clears_incomplete_snapshot_metadata(self) -> None: def test_summary_reports_missing_snapshot_reference(self) -> None: state_file = self.project_root / "orchestration.md" - state_file.write_text( - "---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["state-summary", str(state_file)]) @@ -236,10 +210,7 @@ def test_summary_reports_snapshot_hash_mismatch(self) -> None: state_file = self._build_state() lines = [] for line in state_file.read_text(encoding="utf-8").splitlines(): - if line.startswith("policySnapshotHash: "): - lines.append('policySnapshotHash: "deadbeef"') - else: - lines.append(line) + lines.append('policySnapshotHash: "deadbeef"' if line.startswith("policySnapshotHash: ") else line) state_file.write_text("\n".join(lines) + "\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): @@ -256,10 +227,7 @@ def test_summary_uses_runtime_root_for_relative_snapshot_validation(self) -> Non shadow = outside / "snap.json" shadow.write_text("{}", encoding="utf-8") state_file = outside / "orchestration.md" - state_file.write_text( - "---\npolicySnapshotFile: \"snap.json\"\npolicySnapshotHash: \"99999999\"\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\npolicySnapshotFile: \"snap.json\"\npolicySnapshotHash: \"99999999\"\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["state-summary", str(state_file)]) @@ -285,10 +253,7 @@ def test_escalate_uses_pinned_snapshot_when_state_file_provided(self) -> None: def test_escalate_returns_json_when_state_snapshot_is_invalid(self) -> None: state_file = self.project_root / "orchestration.md" - state_file.write_text( - "---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["escalate", "review-loop", "cycles=1", "--state-file", str(state_file)]) @@ -361,39 +326,27 @@ def test_retro_agent_uses_per_task_override_from_state(self) -> None: def test_parse_agent_config_ignores_null_per_task(self) -> None: config = parse_agent_config( json.dumps( - { - "defaultPrimary": "codex", - "defaultFallback": "claude", - "perTask": None, - "retro": {"primary": "claude", "fallback": False}, - } + {"defaultPrimary": "codex", "defaultFallback": "claude", "perTask": None, "retro": {"primary": "claude", "fallback": False}} ) ) - self.assertEqual(config["perTask"]["retro"]["primary"], "claude") self.assertEqual(config["perTask"]["retro"]["fallback"], False) def test_parse_agent_config_disables_fallback_when_missing(self) -> None: config = parse_agent_config(json.dumps({})) - self.assertEqual(config["defaultFallback"], "false") def test_parse_agent_config_disables_fallback_for_primary_only(self) -> None: config = parse_agent_config(json.dumps({"defaultPrimary": "claude"})) - self.assertEqual(config["defaultFallback"], "false") def test_parse_agent_config_keeps_explicit_disabled_fallback(self) -> None: config = parse_agent_config(json.dumps({"defaultPrimary": "claude", "defaultFallback": False})) - self.assertEqual(config["defaultFallback"], "false") def test_retro_agent_inherits_default_primary_when_unset(self) -> None: state_file = self.project_root / "retro-default-state.md" - state_file.write_text( - "---\nagentConfig:\n defaultPrimary: \"codex\"\n defaultFallback: \"claude\"\n---\n", - encoding="utf-8", - ) + state_file.write_text("---\nagentConfig:\n defaultPrimary: \"codex\"\n defaultFallback: \"claude\"\n---\n", encoding="utf-8") stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_orchestrator_helper(["retro-agent", "--state-file", str(state_file)]) @@ -405,12 +358,10 @@ def test_retro_agent_inherits_default_primary_when_unset(self) -> None: def test_build_state_doc_coerces_null_default_fallback_to_false(self) -> None: state_file = self._build_state({"agentConfig": {"defaultPrimary": "codex", "defaultFallback": None}}) - self.assertIn("defaultFallback: false", state_file.read_text(encoding="utf-8")) def test_build_state_doc_coerces_null_default_primary_to_auto(self) -> None: state_file = self._build_state({"agentConfig": {"defaultPrimary": None, "defaultFallback": False}}) - self.assertIn('defaultPrimary: "auto"', state_file.read_text(encoding="utf-8")) def test_build_cmd_returns_exit_code_one_when_prompt_template_becomes_directory(self) -> None: @@ -440,22 +391,12 @@ def test_tmux_subcommand_help_matches_step_preflight_contract(self) -> None: def test_build_state_doc_returns_json_on_policy_snapshot_failure(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( - json.dumps({"snapshot": {"relativeDir": "../outside"}}), - encoding="utf-8", - ) + (override_dir / "story-automator.policy.json").write_text(json.dumps({"snapshot": {"relativeDir": "../outside"}}), encoding="utf-8") stdout = io.StringIO() template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_build_state_doc( - [ - "--template", - str(template), - "--output-folder", - str(self.output_dir), - "--config-json", - json.dumps(self._config()), - ] + ["--template", str(template), "--output-folder", str(self.output_dir), "--config-json", json.dumps(self._config())] ) self.assertEqual(code, 1) payload = json.loads(stdout.getvalue()) @@ -486,14 +427,7 @@ def _build_state(self, overrides: dict[str, object] | None = None) -> Path: config.update(overrides) with patch_env(self.project_root), redirect_stdout(stdout): cmd_build_state_doc( - [ - "--template", - str(template), - "--output-folder", - str(self.output_dir), - "--config-json", - json.dumps(config), - ] + ["--template", str(template), "--output-folder", str(self.output_dir), "--config-json", json.dumps(config)] ) return Path(json.loads(stdout.getvalue())["path"]) @@ -506,51 +440,6 @@ def _config(self) -> dict[str, object]: "aiCommand": "claude --dangerously-skip-permissions", } - def _install_bundle(self) -> None: - source_skill = REPO_ROOT / "skills" / "bmad-story-automator" - source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" - target_root = self.project_root / ".claude" / "skills" - target_root.mkdir(parents=True, exist_ok=True) - shutil.copytree(source_skill, target_root / "bmad-story-automator") - shutil.copytree(source_review, target_root / "bmad-story-automator-review") - - def _install_required_skills(self) -> None: - for name in ("bmad-create-story", "bmad-dev-story", "bmad-retrospective", "bmad-qa-generate-e2e-tests"): - 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") - (self.project_root / ".claude" / "skills" / "bmad-create-story" / "discover-inputs.md").write_text("# discover\n", encoding="utf-8") - (self.project_root / ".claude" / "skills" / "bmad-create-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") - (self.project_root / ".claude" / "skills" / "bmad-create-story" / "template.md").write_text("# template\n", encoding="utf-8") - (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") - - -class patch_env: - def __init__(self, project_root: Path, extra: dict[str, str] | None = None) -> None: - self.project_root = str(project_root) - self.extra = extra or {} - self.previous: dict[str, str | None] = {} - - def __enter__(self) -> None: - import os - - self.previous["PROJECT_ROOT"] = os.environ.get("PROJECT_ROOT") - os.environ["PROJECT_ROOT"] = self.project_root - for key, value in self.extra.items(): - self.previous[key] = os.environ.get(key) - os.environ[key] = value - - def __exit__(self, exc_type, exc, tb) -> None: - import os - - for key, value in self.previous.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - if __name__ == "__main__": unittest.main() diff --git a/tests/test_tea_detection.py b/tests/test_tea_detection.py new file mode 100644 index 00000000..7badd7ca --- /dev/null +++ b/tests/test_tea_detection.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +from story_automator.commands.orchestrator import cmd_orchestrator_helper +from story_automator.commands.state import cmd_build_state_doc, cmd_detect_workflow_track +from tests.tea_test_support import ( + install_bundle, + install_required_skills, + install_tea_skills, + patch_env, + tea_steps_override, + write_tea_assets, +) + + +class TeaDetectionTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.tmp.name) + self.output_dir = self.project_root / "_bmad-output" / "story-automator" + install_bundle(self.project_root) + install_required_skills(self.project_root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_detect_workflow_track_recommends_tea_when_project_is_capable(self) -> None: + install_tea_skills(self.project_root) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + incomplete_dir = self.project_root / "_bmad" / "tea" / "story-automator" / "prompts" + incomplete_dir.mkdir(parents=True, exist_ok=True) + payload = self._detect() + 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_data_assets_are_incomplete(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + incomplete_dir = self.project_root / "data" / "tea-story-automator" / "prompts" + incomplete_dir.mkdir(parents=True, exist_ok=True) + payload = self._detect() + 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) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["missingSkills"]) + + def test_detect_workflow_track_honors_explicit_standard_override_even_when_project_is_tea_capable(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + self._write_policy_override({"workflow": {"sequence": ["create", "dev", "review"]}}) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["requiresConfirmation"]) + self.assertFalse(payload["explicitTeaPolicy"]) + self.assertTrue(any("explicit standard story-automator policy override" in note for note in payload["reasons"])) + + def test_detect_workflow_track_rejects_invalid_explicit_standard_override(self) -> None: + self._write_policy_override({"workflow": {"sequence": ["create", "bogus", "review"]}}) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(any("explicit standard story-automator policy override, but it is invalid" in note for note in payload["reasons"])) + self.assertTrue(any("workflow.sequence references missing step: bogus" in note for note in payload["reasons"])) + + def test_detect_workflow_track_rejects_malformed_explicit_standard_override_shape(self) -> None: + self._write_policy_override({"workflow": "x"}) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(any("workflow must be an object" in note for note in payload["reasons"])) + + def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: + install_tea_skills(self.project_root) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(), + } + ) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + custom_root = self.project_root / "custom-tea-assets" + write_tea_assets(self.project_root, root=custom_root) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(canonical=True, assets_root="custom-tea-assets"), + } + ) + payload = self._detect() + 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: + install_tea_skills(self.project_root, 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_steps = tea_steps_override(canonical=True) + override_steps["trace"] = tea_steps_override(canonical=True, assets_root="custom-tea-assets")["trace"] + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": override_steps, + } + ) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True) + self._write_policy_override({"workflow": {"sequence": ["create", "atdd", "dev", "review"]}, "steps": {}}) + payload = self._detect() + 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") + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertFalse(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) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(), + } + ) + payload = self._detect() + 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: + install_tea_skills(self.project_root, canonical=True) + self._write_policy_override( + { + "workflow": { + "sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"] + }, + "steps": tea_steps_override(canonical=True, include_nfr=True), + } + ) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["explicitTeaPolicy"]) + self.assertIn("bmad-testarch-nfr", payload["missingSkills"]) + + def test_detect_workflow_track_reports_explicit_invalid_custom_asset_root_consistently(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(canonical=True, assets_root="missing-custom-root"), + } + ) + payload = self._detect() + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "missing-custom-root") + self.assertEqual(payload["missingAssets"], ["missing TEA story-automator assets root"]) + self.assertTrue(any("missing-custom-root" in item for item in payload["reasons"])) + + def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: + install_tea_skills(self.project_root) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(), + } + ) + 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 _detect(self) -> dict[str, object]: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + return json.loads(stdout.getvalue()) + + def _write_policy_override(self, payload: dict[str, object]) -> None: + 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(payload), encoding="utf-8") + + def _build_state(self, overrides: dict[str, object] | None = None) -> Path: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + config = { + "epic": "1", + "epicName": "Epic 1", + "storyRange": ["1.1"], + "status": "READY", + "aiCommand": "claude --dangerously-skip-permissions", + } + if overrides: + config.update(overrides) + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps(config), + ] + ) + self.assertEqual(code, 0) + return Path(json.loads(stdout.getvalue())["path"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tea_policy_flow.py b/tests/test_tea_policy_flow.py new file mode 100644 index 00000000..a4422fc4 --- /dev/null +++ b/tests/test_tea_policy_flow.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path +from unittest.mock import patch + +from story_automator.commands.orchestrator_parse import parse_output_action +from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_detect_workflow_track +from story_automator.commands.tmux import _build_cmd +from story_automator.core.runtime_policy import load_policy_for_state +from story_automator.core.utils import CommandResult +from tests.tea_test_support import install_bundle, install_required_skills, install_tea_skills, patch_env + + +class TeaPolicyFlowTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.tmp.name) + self.output_dir = self.project_root / "_bmad-output" / "story-automator" + install_bundle(self.project_root) + install_required_skills(self.project_root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_build_run_policy_rejects_tea_track_when_core_skills_are_missing(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"})]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_invalid") + self.assertIn("bmad-testarch-atdd", payload["reason"]) + + def test_build_run_policy_rejects_unknown_workflow_track(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", json.dumps({"workflowTrack": "teaa"})]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_invalid") + self.assertIn("unknown workflowTrack: teaa", payload["reason"]) + + def test_generated_policy_override_remains_portable_after_validation(self) -> None: + install_tea_skills(self.project_root, 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"})]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + atdd = payload["policyOverride"]["steps"]["atdd"] + self.assertNotIn("templatePath", atdd["prompt"]) + self.assertNotIn("schemaPath", atdd["parse"]) + self.assertNotIn("templateHash", atdd["prompt"]) + self.assertNotIn("schemaHash", atdd["parse"]) + + def test_detect_workflow_track_keeps_standard_override_out_of_tea_detection(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( + json.dumps({"workflow": {"sequence": ["create", "dev", "review"]}}), + 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.assertFalse(payload["teaDetected"]) + self.assertEqual(payload["recommendedTrack"], "standard") + + def test_build_state_doc_preserves_explicit_standard_override_without_run_selection(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( + json.dumps({"workflow": {"sequence": ["create", "dev", "review"]}}), + encoding="utf-8", + ) + state_file = self._build_state() + policy = load_policy_for_state(state_file, project_root=str(self.project_root)) + self.assertEqual(policy["workflow"]["sequence"], ["create", "dev", "review"]) + + def test_build_state_doc_preserves_explicit_standard_override_with_standard_track_selection(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( + json.dumps({"workflow": {"sequence": ["create", "dev", "review"]}}), + encoding="utf-8", + ) + state_file = self._build_state({"workflowTrack": "standard", "selectedOptionalSteps": []}) + policy = load_policy_for_state(state_file, project_root=str(self.project_root)) + self.assertEqual(policy["workflow"]["sequence"], ["create", "dev", "review"]) + + def test_build_state_doc_preserves_explicit_project_tea_policy(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override = { + "workflow": {"sequence": ["create", "atdd", "dev", "trace", "review"]}, + "steps": { + "atdd": { + "label": "acceptance-tests", + "assets": { + "skillName": "bmad-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + }, + } + (override_dir / "story-automator.policy.json").write_text(json.dumps(override), encoding="utf-8") + + state_file = self._build_state({"workflowTrack": "tea"}) + policy = load_policy_for_state(state_file, project_root=str(self.project_root)) + self.assertEqual(policy["workflow"]["sequence"], ["create", "atdd", "dev", "trace", "review"]) + self.assertEqual(policy["steps"]["atdd"]["label"], "acceptance-tests") + + def test_build_state_doc_preserves_explicit_project_tea_policy_without_track_selection(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override = { + "workflow": {"sequence": ["create", "atdd", "dev", "trace", "review"]}, + "steps": { + "atdd": { + "label": "acceptance-tests", + "assets": { + "skillName": "bmad-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + }, + } + (override_dir / "story-automator.policy.json").write_text(json.dumps(override), encoding="utf-8") + + state_file = self._build_state() + policy = load_policy_for_state(state_file, project_root=str(self.project_root)) + self.assertEqual(policy["workflow"]["sequence"], ["create", "atdd", "dev", "trace", "review"]) + + def test_build_state_doc_renders_tea_summary_from_pinned_sequence(self) -> None: + install_tea_skills(self.project_root, canonical=True, write_assets=False) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override = { + "workflow": {"sequence": ["create", "atdd", "dev", "trace", "review"]}, + "steps": { + "atdd": { + "label": "acceptance-tests", + "assets": { + "skillName": "bmad-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "data/tea-story-automator/prompts/tea_step.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "data/tea-story-automator/parse/tea_step.json"}, + "success": {"verifier": "session_exit"}, + }, + }, + } + (override_dir / "story-automator.policy.json").write_text(json.dumps(override), encoding="utf-8") + + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertIn("- Pinned TEA Steps: atdd, trace", text) + self.assertNotIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) + + def test_build_state_doc_rejects_duplicate_story_ids(self) -> None: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + config = { + "epic": "1", + "epicName": "Epic 1", + "storyRange": ["1.1", "1.1"], + "status": "READY", + "aiCommand": "claude --dangerously-skip-permissions", + } + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps(config), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "storyRange_contains_duplicates") + self.assertEqual(payload["duplicates"], ["1.1"]) + + def test_bundled_tea_adapter_contract_supports_build_and_parse_for_all_steps(self) -> None: + install_tea_skills(self.project_root, canonical=True, include_nfr=True, write_assets=False) + stdout = io.StringIO() + config = {"workflowTrack": "tea", "selectedOptionalSteps": ["nfr"]} + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", json.dumps(config)]) + self.assertEqual(code, 0) + build_payload = json.loads(stdout.getvalue()) + self.assertTrue(build_payload["ok"]) + self.assertEqual( + build_payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"], + ) + + state_file = self._build_state(config) + policy = load_policy_for_state(state_file, project_root=str(self.project_root)) + output_file = self.project_root / "session.txt" + output_file.write_text("session output\n", encoding="utf-8") + + for step in ("atdd", "test_automate", "test_review", "nfr", "trace"): + contract = policy["steps"][step] + self.assertEqual(Path(contract["prompt"]["templatePath"]).name, "tea_step.md") + self.assertEqual(Path(contract["parse"]["schemaPath"]).name, "tea_step.json") + + build_stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(build_stdout): + code = _build_cmd([step, "1.1", "--state-file", str(state_file)]) + self.assertEqual(code, 0) + rendered = build_stdout.getvalue() + self.assertIn("Run the", rendered) + self.assertIn("story `1.1`", rendered) + + parse_stdout = io.StringIO() + with patch_env(self.project_root), patch( + "story_automator.commands.orchestrator_parse.run_cmd", + return_value=CommandResult('{"status":"SUCCESS","summary":"ok","next_action":"proceed"}', 0), + ), redirect_stdout(parse_stdout): + code = parse_output_action([str(output_file), step, "--state-file", str(state_file)]) + self.assertEqual(code, 0) + payload = json.loads(parse_stdout.getvalue()) + self.assertEqual(payload["next_action"], "proceed") + + def _build_state(self, overrides: dict[str, object] | None = None) -> Path: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + config = { + "epic": "1", + "epicName": "Epic 1", + "storyRange": ["1.1"], + "status": "READY", + "aiCommand": "claude --dangerously-skip-permissions", + } + if overrides: + config.update(overrides) + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps(config), + ] + ) + self.assertEqual(code, 0) + return Path(json.loads(stdout.getvalue())["path"]) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py new file mode 100644 index 00000000..4283790b --- /dev/null +++ b/tests/test_tea_state_rendering.py @@ -0,0 +1,561 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from story_automator.commands.orchestrator import cmd_orchestrator_helper +from story_automator.commands.orchestrator_epic_agents import parse_agent_config +from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_state_metrics +from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper +from tests.tea_test_support import ( + install_bundle, + install_required_skills, + install_tea_skills, + patch_env, + tea_steps_override, +) + + +class TeaStateRenderingTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.tmp.name) + self.output_dir = self.project_root / "_bmad-output" / "story-automator" + install_bundle(self.project_root) + install_required_skills(self.project_root) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) -> None: + install_tea_skills(self.project_root) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(), + } + ) + 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: + install_tea_skills(self.project_root, 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_build_run_policy_honors_include_retro_on_tea_track(self) -> None: + install_tea_skills(self.project_root, 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", + "includeRetro": True, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review", "retro"], + ) + self.assertEqual(payload["selectedOptionalSteps"], ["retro"]) + + def test_build_state_doc_returns_structured_snapshot_error_for_unrunnable_tea_track(self) -> None: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps({**self._base_config(), "workflowTrack": "tea"}), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_snapshot_failed") + + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: + install_tea_skills(self.project_root, 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("- Pinned TEA Steps: atdd, test-automate, test-review, nfr, 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: + install_tea_skills(self.project_root, canonical=True) + self._write_policy_override( + { + "workflow": { + "sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"] + }, + "steps": tea_steps_override(include_nfr=True), + } + ) + 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_preserves_explicit_tea_override(self) -> None: + install_tea_skills(self.project_root, canonical=True) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(canonical=True), + } + ) + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertIn("**TEA Configuration:**", text) + self.assertIn( + "| Story | create-story | atdd | dev-story | test-automate | test-review | trace | code-review | git-commit | Status |", + text, + ) + + def test_build_state_doc_uses_pinned_standard_override_metadata_for_explicit_policy_override(self) -> None: + state_file = self._build_state( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr", "retro"], + "policyOverride": {"workflow": {"sequence": ["create", "dev", "review"]}}, + } + ) + text = state_file.read_text(encoding="utf-8") + self.assertNotIn('workflowTrack: "tea"', text) + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | code-review | git-commit | Status |", text) + + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: + install_tea_skills(self.project_root, 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"], "standard") + self.assertEqual(payload["selectedOptionalSteps"], []) + + 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"], []) + + 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.assertTrue(any("checkpoint-preview is out of scope" in note for note in payload["notes"])) + + def test_build_run_policy_notes_ignored_include_retro_for_explicit_tea_override(self) -> None: + install_tea_skills(self.project_root, canonical=True) + self._write_policy_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": tea_steps_override(canonical=True), + } + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "tea", + "includeRetro": True, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(any("Per-run TEA optional-step selection was ignored" in note for note in payload["notes"])) + + def test_build_run_policy_rejects_invalid_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", "ship"]}}}), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_invalid") + + 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_state_doc_rejects_non_string_story_range_items(self) -> None: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps({**self._base_config(), "storyRange": ["1.1", 2]}), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "storyRange_must_be_array_of_strings") + + def test_build_state_doc_rejects_story_range_markdown_control_characters(self) -> None: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps({**self._base_config(), "storyRange": ["1|1"]}), + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "storyRange_contains_invalid_ids") + self.assertEqual(payload["invalid"], ["1|1"]) + + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: + install_tea_skills(self.project_root, 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_rejects_malformed_selection_shape(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout), patch( + "story_automator.commands.state.build_run_policy", + return_value={"policyOverride": {}, "workflowTrack": "tea"}, + ): + code = cmd_build_run_policy(["--config-json", json.dumps({"workflowTrack": "tea"})]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "policy_selection_invalid") + self.assertIn("missing selection keys", payload["reason"]) + + def test_build_run_policy_normalizes_selected_optional_steps_on_tea_track(self) -> None: + install_tea_skills(self.project_root, 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: + install_tea_skills(self.project_root, 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.core.state_document.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_build_cmd_rejects_unknown_step_via_policy(self) -> None: + stderr = io.StringIO() + with patch_env(self.project_root), redirect_stderr(stderr): + code = _build_cmd(["ship", "1.1"]) + self.assertEqual(code, 1) + self.assertIn("unknown step: ship", stderr.getvalue()) + + def test_build_cmd_help_mentions_state_file(self) -> None: + stdout = io.StringIO() + with redirect_stdout(stdout): + code = cmd_tmux_wrapper(["build-cmd", "--help"]) + self.assertEqual(code, 0) + self.assertIn("--state-file", stdout.getvalue()) + + def test_parse_agent_config_keeps_default_fallback_false(self) -> None: + payload = parse_agent_config(json.dumps({"defaultPrimary": "auto", "defaultFallback": False})) + self.assertEqual(payload["defaultFallback"], "false") + + def _write_policy_override(self, payload: dict[str, object]) -> None: + 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(payload), encoding="utf-8") + + def _build_state(self, overrides: dict[str, object] | None = None) -> Path: + stdout = io.StringIO() + template = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "templates" / "state-document.md" + config = self._base_config() + if overrides: + config.update(overrides) + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_state_doc( + [ + "--template", + str(template), + "--output-folder", + str(self.output_dir), + "--config-json", + json.dumps(config), + ] + ) + self.assertEqual(code, 0) + return Path(json.loads(stdout.getvalue())["path"]) + + def _base_config(self) -> dict[str, object]: + return { + "epic": "1", + "epicName": "Epic 1", + "storyRange": ["1.1"], + "status": "READY", + "aiCommand": "claude --dangerously-skip-permissions", + } + + +if __name__ == "__main__": + unittest.main()