From 224082766786f84dc8d8390ebb690db0546c5340 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 22 May 2026 14:57:19 +0100 Subject: [PATCH 01/33] Add TEA-capable policy and state support --- .../story_automator/commands/orchestrator.py | 2 +- .../commands/orchestrator_epic_agents.py | 5 +- .../src/story_automator/commands/state.py | 50 +++++- .../src/story_automator/core/agent_config.py | 4 +- .../story_automator/core/runtime_policy.py | 11 +- .../steps-c/step-01b-continue.md | 4 +- .../steps-c/step-03-execute.md | 32 +++- .../steps-c/step-03a-execute-review.md | 37 ++++- skills/bmad-story-automator/workflow.md | 6 +- tests/test_orchestrator_parse.py | 64 ++++++++ tests/test_runtime_policy.py | 118 ++++++++++++++ tests/test_state_policy_metadata.py | 151 ++++++++++++++++++ 12 files changed, 470 insertions(+), 14 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py index 740335d7..fe0be7bd 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -110,7 +110,7 @@ def _usage(code: int) -> int: print(" get-epic-stories [--state-file path]", file=target) print(" check-blocking ", file=target) print(" agents-build --state-file path --complexity-file path --output path --config-json '{}'", file=target) - print(" agents-resolve (--state-file path | --agents-file path) --story ID --task create|dev|auto|review", file=target) + print(" agents-resolve (--state-file path | --agents-file path) --story ID --task STEP_NAME", file=target) print(" retro-agent --state-file path", file=target) return code 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 89b04ee8..8f2171eb 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py @@ -5,6 +5,7 @@ from pathlib import Path from story_automator.core.frontmatter import extract_frontmatter, find_frontmatter_value, parse_frontmatter +from story_automator.core.runtime_policy import load_policy_for_state, story_task_sequence from story_automator.core.runtime_layout import runtime_provider from story_automator.core.sprint import sprint_status_epic from story_automator.core.story_keys import normalize_story_key @@ -116,11 +117,13 @@ 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"])) + policy = load_policy_for_state(options["state-file"]) + tasks_in_scope = story_task_sequence(policy) 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 = resolve_agent(config, level, task) tasks[task] = {"primary": primary, "fallback": False if fallback == "false" else fallback} stories.append({"storyId": story["storyId"], "title": story.get("title", ""), "complexity": level, "tasks": tasks}) 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 8f41b919..62bf6c63 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -10,6 +10,47 @@ from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json +STEP_DISPLAY_NAMES = { + "create": "create-story", + "dev": "dev-story", + "auto": "automate", + "review": "code-review", + "atdd": "atdd", + "test_automate": "test-automate", + "test_review": "test-review", + "trace": "trace", +} + + +def _story_progress_steps(policy: dict[str, Any]) -> list[str]: + sequence = ((policy.get("workflow") or {}).get("sequence")) or [] + return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] + + +def _progress_headers(steps: list[str]) -> list[str]: + headers = ["Story"] + headers.extend(STEP_DISPLAY_NAMES.get(step, step.replace("_", "-")) for step in steps) + headers.extend(["git-commit", "Status"]) + return headers + + +def _markdown_divider(width: int) -> list[str]: + return ["-------" if idx == 0 else "----------" for idx in range(width)] + + +def _progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: + steps = _story_progress_steps(policy) + headers = _progress_headers(steps) + divider = _markdown_divider(len(headers)) + pending_cells = ["⏳"] * len(steps) + ["⏳", "pending"] + rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) + return ( + "| " + " | ".join(headers) + " |", + "| " + " | ".join(divider) + " |", + rows, + ) + + def cmd_build_state_doc(args: list[str]) -> int: template = "" output_folder = "" @@ -48,6 +89,7 @@ def cmd_build_state_doc(args: list[str]) -> int: except (FileNotFoundError, PolicyError, ValueError) as exc: write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)}) return 1 + progress_header, progress_divider, progress_rows = _progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) text = read_text(template) replacements: dict[str, Any] = { "epic": config.get("epic", ""), @@ -134,7 +176,6 @@ def cmd_build_state_doc(args: list[str]) -> int: for key, value in replacements.items(): text = re.sub(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {json.dumps(v)}", text) story_range = [item for item in config.get("storyRange", []) if isinstance(item, str)] - progress_rows = "\n".join(f"| {story_id} | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |" for story_id in story_range) body = { "{{epicName}}": str(config.get("epicName", "")), "{{epic}}": str(config.get("epic", "")), @@ -146,6 +187,8 @@ def cmd_build_state_doc(args: list[str]) -> int: } 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}) @@ -201,9 +244,10 @@ def cmd_state_metrics(args: list[str]) -> int: continue if in_table and line.startswith("|"): parts = [part.strip() for part in line.split("|")] - if len(parts) >= 8 and parts[1]: + values = [part for part in parts[1:-1] if part] + if len(values) >= 2: total += 1 - if any(token in parts[7].lower() for token in ("done", "complete", "completed")): + if any(token in values[-1].lower() for token in ("done", "complete", "completed")): completed += 1 continue if in_table and not line.startswith("|"): diff --git a/skills/bmad-story-automator/src/story_automator/core/agent_config.py b/skills/bmad-story-automator/src/story_automator/core/agent_config.py index 7c18be0a..e6e3e61a 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 load_policy_for_state, story_task_sequence from .runtime_layout import runtime_provider @@ -142,11 +143,12 @@ 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)) + tasks_in_scope = story_task_sequence(load_policy_for_state(state_file)) 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 = resolve_agent_for_task(config, level, task) tasks[task] = {"primary": primary, "fallback": False if fallback == "false" else fallback} stories.append( 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..77f4aed6 100644 --- a/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py @@ -10,7 +10,7 @@ from .utils import ensure_dir, get_project_root, iso_now, md5_hex8, read_text, write_atomic VALID_TOP_LEVEL_KEYS = {"version", "snapshot", "runtime", "workflow", "steps"} -VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro"} +VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro", "atdd", "test_automate", "test_review", "trace"} VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} VALID_PARSER_PROVIDERS = {"claude"} @@ -188,6 +188,15 @@ def review_max_cycles(policy: dict[str, Any]) -> int: return int(repeat.get("maxCycles", 5)) +def workflow_sequence(policy: dict[str, Any]) -> list[str]: + sequence = ((policy.get("workflow") or {}).get("sequence")) or [] + return [str(step) for step in sequence if isinstance(step, str) and step] + + +def story_task_sequence(policy: dict[str, Any]) -> list[str]: + return [step for step in workflow_sequence(policy) if step != "retro"] + + def crash_max_retries(policy: dict[str, Any]) -> int: crash = ((policy.get("workflow") or {}).get("crash")) or {} return int(crash.get("maxRetries", 2)) diff --git a/skills/bmad-story-automator/steps-c/step-01b-continue.md b/skills/bmad-story-automator/steps-c/step-01b-continue.md index 5226d7c7..3d9a19b3 100644 --- a/skills/bmad-story-automator/steps-c/step-01b-continue.md +++ b/skills/bmad-story-automator/steps-c/step-01b-continue.md @@ -136,8 +136,8 @@ Active sessions: {count or 'None'} - READY → `{preflightFinalizeStep}` - INITIALIZING → `{preflightConfigStep}` - IN_PROGRESS / PAUSED → route by `currentStep`: - - `step-03-execute` or `create` or `dev` → `{executeStep}` - - `step-03a-execute-review` or `auto` or `review` → `{executeReviewStep}` + - `step-03-execute` or `create` or `atdd` or `dev` → `{executeStep}` + - `step-03a-execute-review` or `auto` or `test_automate` or `test_review` or `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-03-execute.md b/skills/bmad-story-automator/steps-c/step-03-execute.md index 7faa3fc4..74742f21 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:** @@ -146,6 +156,26 @@ validation=$("$scripts" orchestrator-helper verify-step create {story_id} --stat - If `validation.verified == false` AND attempts < 5 → retry with next agent (see `{retryStrategy}`) - If `validation.verified == false` AND attempts == 5 → escalate (all retries exhausted) +### A.1 ATDD +*Run only if the pinned policy sequence includes `atdd`* + +Use the same spawn/monitor/parse pattern as other session-exit steps: + +```bash +session=$("$scripts" tmux-wrapper spawn atdd {epic} {story_id} \ + --agent "$current_agent" \ + --command "$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --state-file "$state_file")") +result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") +"$scripts" tmux-wrapper kill "$session" +parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" atdd --state-file "$state_file") +``` + +- If `next_action == "proceed"` → continue to dev +- If `next_action == "retry"` or session crashed → retry with fallback pattern +- Treat successful completion as execution completion only; TEA artifact verification is not part of v1 + +When updating progress, do not assume the standard fixed column order if TEA mode is active. + ### B. Dev Story **Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents. @@ -186,7 +216,7 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') ## Auto-Proceed to Review Phase -Display: "**Dev story complete. Proceeding to automate and code review...**" +Display: "**Dev story complete. Proceeding to the next policy-defined quality phase...**" ```bash "$scripts" orchestrator-helper state-update "$state_file" \ diff --git a/skills/bmad-story-automator/steps-c/step-03a-execute-review.md b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md index 61a06bd2..c63d2b59 100644 --- a/skills/bmad-story-automator/steps-c/step-03a-execute-review.md +++ b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md @@ -10,7 +10,7 @@ reviewLoop: '../data/code-review-loop.md' # Step 3a: Execute Review Phase -**Goal:** Run automate (guardrails) and code review loop for the current story. +**Goal:** Run the policy-defined quality phase and final code review loop for the current story. **Interaction mode:** Deterministic autonomous execution. --- @@ -26,8 +26,22 @@ Set: `scripts="{scriptsDir}"` ## Story Loop (Continue from Step 3) +### C. Pre-Review Quality Steps + +The pinned workflow policy snapshot decides which pre-review quality steps apply. + +- Standard default path: optional `auto`, then `review` +- TEA v1 opt-in path: `test_automate`, `test_review`, `trace`, then `review` + +For TEA v1: + +- `test_automate`, `test_review`, 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. @@ -57,6 +71,25 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") Display: `[story {N}/{total}] automate -> skip (non-blocking)` → proceed to D +### C.1 TEA Quality Steps + +*Run only if the pinned policy sequence includes any of: `test_automate`, `test_review`, `trace`* + +For each enabled TEA step: + +```bash +session=$("$scripts" tmux-wrapper spawn {step} {epic} {story_id} \ + --agent "$current_agent" \ + --command "$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --state-file "$state_file")") +result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") +"$scripts" tmux-wrapper kill "$session" +parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" {step} --state-file "$state_file") +``` + +- If `next_action == "proceed"` → continue to the next policy-defined step +- If `next_action == "retry"` or the session crashes → apply the retry/fallback pattern +- TEA v1 success for these steps means session execution completed successfully + ### D. Code Review Loop **See `{reviewLoop}` for complete script-based review cycle with v2.3 per-task agent configuration.** diff --git a/skills/bmad-story-automator/workflow.md b/skills/bmad-story-automator/workflow.md index 9dacea3b..d1053f45 100644 --- a/skills/bmad-story-automator/workflow.md +++ b/skills/bmad-story-automator/workflow.md @@ -10,7 +10,7 @@ outputFolder: '{output_folder}/story-automator' # story-automator -**Goal:** Automate the entire development build cycle (create-story → dev-story → automate → code-review → retrospective) for multiple stories in one or more epics, using T-Mux to spawn isolated AI agent sessions while providing visibility, resumability, and graceful decision escalation. +**Goal:** Automate the entire development build cycle for multiple stories in one or more epics, using T-Mux to spawn isolated AI agent sessions while providing visibility, resumability, and graceful decision escalation. The default path remains `create-story → dev-story → automate → code-review → retrospective`. Projects may explicitly opt into a TEA-assisted path through the pinned runtime policy snapshot. **Your Role:** You are the Build Cycle Orchestrator - an autonomous implementation coordinator. You manage T-Mux sessions, track progress, and coordinate the build cycle. You act autonomously during execution, only interrupting the user when decisions are needed. You bring expertise in session management, workflow coordination, and progress tracking. The user brings their epic(s), stories, and domain context. Work efficiently with minimal interruption. @@ -18,10 +18,12 @@ outputFolder: '{output_folder}/story-automator' - Preflight/continue/user-choice phases: collaborative, ask one clarifying question when input is ambiguous. - Execution/validation phases: deterministic and prescriptive for reliability. -**Meta-Context:** This orchestrator spawns and monitors other workflows (create-story, dev-story, automate, code-review, retrospective) in isolated T-Mux sessions. It tracks state for full resumability and escalates to the user only when autonomous decisions cannot be made. +**Meta-Context:** This orchestrator spawns and monitors other workflows (create-story, dev-story, automate, code-review, retrospective, and TEA-specific steps when explicitly configured) in isolated T-Mux sessions. It tracks state for full resumability and escalates to the user only when autonomous decisions cannot be made. **Runtime Policy:** Machine settings live in `data/orchestration-policy.json`. Prompt contracts, parse contracts, retry budgets, and verifier selection should follow the pinned policy snapshot written at orchestration start. +**TEA v1 Scope:** If the pinned policy explicitly includes TEA steps, treat them as an opt-in per-story path. TEA step completion in v1 means successful session execution only. Artifact-level verification for TEA steps is deferred. Final story completion remains gated by the `review` verifier. + --- ## MULTI-EPIC SUPPORT diff --git a/tests/test_orchestrator_parse.py b/tests/test_orchestrator_parse.py index a82454c2..75e04520 100644 --- a/tests/test_orchestrator_parse.py +++ b/tests/test_orchestrator_parse.py @@ -145,6 +145,29 @@ def test_parser_runtime_uses_policy_settings(self) -> None: self.assertEqual(mock_run.call_args.args[:4], ("claude", "-p", "--model", "sonnet")) self.assertEqual(mock_run.call_args.kwargs["timeout"], 33) + def test_parse_schema_supports_tea_step_from_override(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {"atdd": _tea_steps_override(self.project_root)["atdd"]}, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch.dict("os.environ", {"PROJECT_ROOT": str(self.project_root)}), patch( + "story_automator.commands.orchestrator_parse.run_cmd", + return_value=CommandResult('{"status":"SUCCESS","failing_tests_created":true,"summary":"ok","next_action":"proceed"}', 0), + ), redirect_stdout(stdout): + code = parse_output_action([str(self.output_file), "atdd"]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["failing_tests_created"]) + def _install_bundle(self) -> None: source_skill = REPO_ROOT / "skills" / "bmad-story-automator" source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" @@ -165,6 +188,19 @@ def _install_required_skills(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") (self.project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text("# checklist\n", encoding="utf-8") + def _install_tea_skills(self) -> None: + _write_tea_assets(self.project_root) + for name in ( + "bmad-tea-testarch-atdd", + "bmad-tea-testarch-automate", + "bmad-tea-testarch-test-review", + "bmad-tea-testarch-trace", + ): + skill_dir = self.project_root / ".claude" / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") + def _build_state(self) -> Path: output_dir = self.project_root / "_bmad-output" / "story-automator" output_dir.mkdir(parents=True, exist_ok=True) @@ -192,5 +228,33 @@ def _build_state(self) -> Path: return Path(json.loads(stdout.getvalue())["path"]) +def _write_tea_assets(project_root: Path) -> None: + prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" + parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override(project_root: Path) -> dict[str, object]: + return { + "atdd": { + "label": "atdd", + "assets": { + "skillName": "bmad-tea-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/atdd.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/atdd.json"}, + "success": {"verifier": "session_exit"}, + } + } + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index b3d9b475..aaec15b6 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -50,6 +50,22 @@ def test_invalid_step_name_rejected(self) -> None: with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_tea_steps_allowed_when_explicitly_configured_and_installed(self) -> None: + self._install_tea_skills() + steps = _tea_steps_override(self.project_root) + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": steps, + } + ) + policy = load_effective_policy(str(self.project_root)) + self.assertEqual( + policy["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], + ) + self.assertEqual(policy["steps"]["trace"]["assets"]["skillName"], "bmad-tea-testarch-trace") + def test_invalid_verifier_name_rejected(self) -> None: self._write_override({"steps": {"review": {"success": {"verifier": "nope"}}}}) with self.assertRaises(PolicyError): @@ -60,6 +76,19 @@ def test_required_asset_missing_fails(self) -> None: with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_tea_policy_fails_when_required_tea_skill_missing(self) -> None: + steps = _tea_steps_override(self.project_root) + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": { + "atdd": steps["atdd"], + }, + } + ) + with self.assertRaises(PolicyError): + load_effective_policy(str(self.project_root)) + def test_dependency_workflow_file_optional(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-create-story" / "workflow.md").unlink() policy = load_effective_policy(str(self.project_root)) @@ -293,6 +322,95 @@ def _write_override(self, payload: dict[str, object]) -> None: override_dir.mkdir(parents=True, exist_ok=True) (override_dir / "story-automator.policy.json").write_text(json.dumps(payload), encoding="utf-8") + def _install_tea_skills(self) -> None: + _write_tea_assets(self.project_root) + for name in ( + "bmad-tea-testarch-atdd", + "bmad-tea-testarch-automate", + "bmad-tea-testarch-test-review", + "bmad-tea-testarch-trace", + ): + skill_dir = self.project_root / ".claude" / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") + (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") + + +def _write_tea_assets(project_root: Path) -> None: + prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" + parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") + (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override(project_root: Path) -> 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"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": "bmad-tea-testarch-automate", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_automate.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_automate.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": "bmad-tea-testarch-test-review", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_review.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-tea-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/trace.json"}, + "success": {"verifier": "session_exit"}, + }, + } + if __name__ == "__main__": unittest.main() diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 531883f3..42391b26 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -461,6 +461,68 @@ def test_build_state_doc_returns_json_on_policy_snapshot_failure(self) -> None: self.assertFalse(payload["ok"]) self.assertEqual(payload["error"], "policy_snapshot_failed") + def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state() + 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_agents_build_uses_pinned_tea_story_sequence(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state() + complexity_file = self.project_root / "complexity.json" + complexity_file.write_text( + json.dumps({"stories": [{"storyId": "1.1", "title": "Story 1", "complexity": {"level": "medium"}}]}), + encoding="utf-8", + ) + agents_file = self.project_root / "agents.md" + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "agents-build", + "--state-file", + str(state_file), + "--complexity-file", + str(complexity_file), + "--output", + str(agents_file), + "--config-json", + json.dumps({"defaultPrimary": "claude", "defaultFallback": False}), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + text = agents_file.read_text(encoding="utf-8") + self.assertIn('"atdd"', text) + self.assertIn('"test_automate"', text) + self.assertIn('"test_review"', text) + self.assertIn('"trace"', text) + def test_build_cmd_rejects_unknown_step_via_policy(self) -> None: stderr = io.StringIO() with patch_env(self.project_root), redirect_stderr(stderr): @@ -525,6 +587,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") + class patch_env: def __init__(self, project_root: Path, extra: dict[str, str] | None = None) -> None: @@ -551,5 +626,81 @@ def __exit__(self, exc_type, exc, tb) -> None: os.environ[key] = value +def _write_tea_assets(project_root: Path) -> None: + prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" + parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" + prompts.mkdir(parents=True, exist_ok=True) + parse.mkdir(parents=True, exist_ok=True) + (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") + (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") + (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") + (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + + +def _tea_steps_override(project_root: Path) -> 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"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": "bmad-tea-testarch-automate", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_automate.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_automate.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": "bmad-tea-testarch-test-review", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_review.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-tea-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/trace.json"}, + "success": {"verifier": "session_exit"}, + }, + } + + if __name__ == "__main__": unittest.main() From 04813c5c1d0c72c3cb242d948d4a703307717ff8 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 22 May 2026 15:59:29 +0100 Subject: [PATCH 02/33] Add runtime TEA policy selection --- .../src/story_automator/cli.py | 4 +- .../src/story_automator/commands/state.py | 251 +++++++++++++++++- .../story_automator/core/runtime_policy.py | 15 +- .../steps-c/step-02a-preflight-config.md | 44 ++- .../steps-c/step-03a-execute-review.md | 6 +- .../steps-c/step-03b-execute-finish.md | 9 + .../templates/state-document.md | 10 + tests/test_runtime_policy.py | 31 ++- tests/test_state_policy_metadata.py | 75 +++++- 9 files changed, 419 insertions(+), 26 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/cli.py b/skills/bmad-story-automator/src/story_automator/cli.py index 5ef5a801..6ec707c5 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_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,7 @@ 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, "commit-story": cmd_commit_story, "parse-epic": _cmd_parse_epic, "parse-story": _cmd_parse_story, @@ -75,6 +76,7 @@ def _usage(stream: object) -> None: "ensure-stop-hook", "stop-hook", "build-state-doc", + "build-run-policy", "commit-story", "parse-epic", "parse-story", 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 62bf6c63..503b8095 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -10,6 +10,12 @@ from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json +STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] +TEA_CORE_SEQUENCE = ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"] +TEA_OPTIONAL_AUTOMATED_STEPS = {"nfr", "retro"} +MANUAL_CHECKPOINTS = {"checkpoint-preview"} +UNSUPPORTED_AUTOMATED_OPTIONS = {"validate-create-story"} + STEP_DISPLAY_NAMES = { "create": "create-story", "dev": "dev-story", @@ -18,10 +24,201 @@ "atdd": "atdd", "test_automate": "test-automate", "test_review": "test-review", + "nfr": "nfr", "trace": "trace", } +def _normalize_string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if isinstance(value, str) and value.strip(): + return [part.strip() for part in value.split(",") if part.strip()] + return [] + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "y", "on"}: + return True + if lowered in {"0", "false", "no", "n", "off"}: + return False + return default + + +def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: + configured = str(config.get("teaAssetsRoot") or "").strip() + if configured: + return configured.rstrip("/") + wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" + if wrapper_assets.is_dir(): + return "docs/plans/tea-story-automator/assets" + return "_bmad/tea/story-automator" + + +def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any]: + root = assets_root.rstrip("/") + steps: dict[str, Any] = { + "atdd": { + "label": "atdd", + "assets": { + "skillName": "bmad-tea-testarch-atdd", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{root}/prompts/atdd.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{root}/parse/atdd.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_automate": { + "label": "test-automate", + "assets": { + "skillName": "bmad-tea-testarch-automate", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{root}/prompts/test_automate.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{root}/parse/test_automate.json"}, + "success": {"verifier": "session_exit"}, + }, + "test_review": { + "label": "test-review", + "assets": { + "skillName": "bmad-tea-testarch-test-review", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{root}/prompts/test_review.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{root}/parse/test_review.json"}, + "success": {"verifier": "session_exit"}, + }, + "trace": { + "label": "trace", + "assets": { + "skillName": "bmad-tea-testarch-trace", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{root}/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{root}/parse/trace.json"}, + "success": {"verifier": "session_exit"}, + }, + } + if include_nfr: + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": "bmad-tea-testarch-nfr", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": f"{root}/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{root}/parse/nfr.json"}, + "success": {"verifier": "session_exit"}, + } + return steps + + +def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: + explicit_override = config.get("policyOverride") + if isinstance(explicit_override, dict): + return { + "policyOverride": explicit_override, + "workflowTrack": str(config.get("workflowTrack") or "standard"), + "selectedOptionalSteps": _normalize_string_list(config.get("selectedOptionalSteps")), + "manualCheckpoints": _normalize_string_list(config.get("manualCheckpoints")), + "notes": _normalize_string_list(config.get("policyNotes")), + } + + has_run_selection = any( + key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") + ) + if not has_run_selection: + return { + "policyOverride": {}, + "workflowTrack": "standard", + "selectedOptionalSteps": [], + "manualCheckpoints": [], + "notes": [], + } + + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" + selected = set(_normalize_string_list(config.get("selectedOptionalSteps"))) + manual = set(_normalize_string_list(config.get("manualCheckpoints"))) + notes: list[str] = [] + policy_override: dict[str, Any] = {} + + if track == "tea": + assets_root = _tea_assets_root(project_root, config) + include_nfr = "nfr" in selected + include_retro = "retro" in selected + for unsupported in sorted(selected & UNSUPPORTED_AUTOMATED_OPTIONS): + notes.append(f"{unsupported} is not automated on the TEA track in v1 and was not added to the workflow sequence.") + 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.") + 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.") + sequence = ["create", "atdd", "dev", "test_automate", "test_review"] + if include_nfr: + sequence.append("nfr") + sequence.extend(["trace", "review"]) + if include_retro: + sequence.append("retro") + policy_override = { + "workflow": {"sequence": sequence}, + "steps": _tea_step_contracts(assets_root, include_nfr=include_nfr), + } + selected = {"nfr" if include_nfr else "", "retro" if include_retro else ""} + selected.discard("") + else: + include_retro = "retro" in selected if "retro" in selected else _as_bool(config.get("includeRetro"), True) + sequence = ["create", "dev", "auto", "review"] + if include_retro: + sequence.append("retro") + selected.add("retro") + else: + selected.discard("retro") + for unsupported in sorted(selected & {"nfr"}): + notes.append("nfr is only available on the TEA track and was ignored for the standard workflow.") + selected.discard(unsupported) + if "validate-create-story" in selected: + notes.append("validate-create-story is not yet an automated story-automator step and was recorded as advisory only.") + selected.discard("validate-create-story") + if "qa-generate-e2e-tests" in selected: + notes.append("qa-generate-e2e-tests is already represented by the standard auto step; use skipAutomate to disable it.") + selected.discard("qa-generate-e2e-tests") + policy_override = {"workflow": {"sequence": sequence}} + + recognized_manual = sorted(checkpoint for checkpoint in manual if checkpoint in MANUAL_CHECKPOINTS) + return { + "policyOverride": policy_override, + "workflowTrack": track, + "selectedOptionalSteps": sorted(selected), + "manualCheckpoints": recognized_manual, + "notes": notes, + } + + def _story_progress_steps(policy: dict[str, Any]) -> list[str]: sequence = ((policy.get("workflow") or {}).get("sequence")) or [] return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] @@ -84,8 +281,9 @@ def cmd_build_state_doc(args: list[str]) -> int: epic = str(config.get("epic") or "epic") safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" + policy_selection = _build_run_policy(Path(get_project_root()), config) try: - snapshot = snapshot_effective_policy(get_project_root()) + snapshot = snapshot_effective_policy(get_project_root(), inline_override=policy_selection["policyOverride"]) except (FileNotFoundError, PolicyError, ValueError) as exc: write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)}) return 1 @@ -108,6 +306,10 @@ def cmd_build_state_doc(args: list[str]) -> int: "policySnapshotFile": snapshot["policySnapshotFile"], "policySnapshotHash": snapshot["policySnapshotHash"], "legacyPolicy": False, + "workflowTrack": policy_selection["workflowTrack"], + "selectedOptionalSteps": policy_selection["selectedOptionalSteps"], + "manualCheckpoints": policy_selection["manualCheckpoints"], + "policyNotes": policy_selection["notes"], } overrides = config.get("overrides", {}) if isinstance(config.get("overrides"), dict) else {} text = re.sub( @@ -119,6 +321,26 @@ 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) + text = re.sub( + r"(?m)^workflowTrack:.*$", + lambda m: f'workflowTrack: {json.dumps(policy_selection["workflowTrack"])}', + text, + ) + text = re.sub( + r"(?m)^selectedOptionalSteps:.*$", + lambda m: f"selectedOptionalSteps: {json.dumps(policy_selection['selectedOptionalSteps'])}", + text, + ) + text = re.sub( + r"(?m)^manualCheckpoints:.*$", + lambda m: f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}", + text, + ) + text = re.sub( + r"(?m)^policyNotes:.*$", + lambda m: f"policyNotes: {json.dumps(policy_selection['notes'])}", + text, + ) agent_config = config.get("agentConfig") if isinstance(agent_config, dict): per_task = agent_config.get("perTask", {}) @@ -184,6 +406,10 @@ def cmd_build_state_doc(args: list[str]) -> int: "{{overrides.skipAutomate}}": str(bool(overrides.get("skipAutomate", False))).lower(), "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), "{{customInstructions}}": str(config.get("customInstructions", "")), + "{{workflowTrack}}": str(policy_selection["workflowTrack"]), + "{{selectedOptionalSteps}}": ", ".join(policy_selection["selectedOptionalSteps"]) or "none", + "{{manualCheckpoints}}": ", ".join(policy_selection["manualCheckpoints"]) or "none", + "{{policyNotes}}": "\n".join(f"- {note}" for note in policy_selection["notes"]) or "- none", } for key, value in body.items(): text = text.replace(key, value) @@ -195,6 +421,29 @@ def cmd_build_state_doc(args: list[str]) -> int: 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): + config_json = read_text(config_file) + if not config_json.strip(): + write_json({"ok": False, "error": "missing_config"}) + return 1 + try: + config = json.loads(config_json) + except json.JSONDecodeError: + write_json({"ok": False, "error": "missing_config"}) + return 1 + selection = _build_run_policy(Path(get_project_root()), config) + write_json({"ok": True, **selection}) + return 0 + + def cmd_sprint_compare(args: list[str]) -> int: state = "" sprint = "" 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 77f4aed6..f4117a3e 100644 --- a/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py @@ -10,7 +10,7 @@ from .utils import ensure_dir, get_project_root, iso_now, md5_hex8, read_text, write_atomic VALID_TOP_LEVEL_KEYS = {"version", "snapshot", "runtime", "workflow", "steps"} -VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro", "atdd", "test_automate", "test_review", "trace"} +VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro", "atdd", "test_automate", "test_review", "trace", "nfr"} VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} VALID_PARSER_PROVIDERS = {"claude"} @@ -32,12 +32,17 @@ 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) + policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) _validate_policy_shape(policy) _clear_resolved_fields(policy) @@ -66,9 +71,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) 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..0331cdf8 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 @@ -25,22 +25,44 @@ agentConfigPresets: '../data/agent-config-presets.json' ## Do -### 1. Configure Execution Preferences +### 1. Configure Workflow Track and Execution Preferences > **PREREQUISITE:** Step 2 (preflight) MUST be complete. The Complexity Matrix MUST have been displayed. If not, STOP and complete step 2 first. ``` -**Execution Settings:** +**Workflow + Execution Settings:** -1. **Skip the 'automate' step (test automation)?** [N]o (default) / [Y]es -2. **Max parallel sessions?** (tmux sessions running concurrently, default: 1) - -Enter choices (e.g., `N 1` or `Y 3`): +1. **Workflow track?** [S]tandard (default) / [T]EA +2. **Skip the standard 'automate' step?** [N]o (default) / [Y]es + - Standard track only. Ignored on TEA track. +3. **Max parallel sessions?** (tmux sessions running concurrently, default: 1) ``` **Wait.** -Store responses as `skip_automate` (true/false) and `max_parallel` (integer). +Store responses as: +- `workflow_track` = `standard` or `tea` +- `skip_automate` = true/false +- `max_parallel` = integer + +### 1b. Configure Optional Steps + +If `workflow_track == standard`, offer: +- `retro` (optional epic-level retrospective) +- `checkpoint-preview` (optional manual checkpoint before commit) +- `validate-create-story` (optional advisory only; not automated in v1) + +If `workflow_track == tea`, state clearly: +- **Mandatory automated TEA core:** `atdd`, `test_automate`, `test_review`, `trace` +- **Optional automated TEA add-on:** `nfr` +- **Optional epic-level add-on:** `retro` +- **Optional manual checkpoint:** `checkpoint-preview` +- `validate-create-story` remains advisory only and is not automated in v1 +- legacy `qa-generate-e2e-tests` is not added on the TEA track because `test_automate` supersedes it + +Collect: +- `selected_optional_steps` = zero or more of `retro`, `nfr`, `validate-create-story` +- `manual_checkpoints` = zero or more of `checkpoint-preview` ### 2. Configure Agent (Complexity-Aware) @@ -99,7 +121,10 @@ Only when user chose **[U]niform** or **[C]ustom**, follow the Save Configuratio Display configuration summary: - Epic and story range +- Workflow track - Custom instructions (if any) +- Selected optional automated steps +- Selected manual checkpoints - Agent configuration - Execution settings @@ -140,9 +165,12 @@ 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 manualCheckpoints "$manual_checkpoints" \ --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,manualCheckpoints:$manualCheckpoints,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-03a-execute-review.md b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md index c63d2b59..3dd4df20 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 @@ -31,11 +31,11 @@ Set: `scripts="{scriptsDir}"` 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`, `trace`, then `review` +- TEA v1 opt-in path: `test_automate`, `test_review`, optional `nfr`, `trace`, then `review` For TEA v1: -- `test_automate`, `test_review`, and `trace` use the same spawn/monitor/parse pattern as other session-exit steps +- `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 @@ -73,7 +73,7 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") ### C.1 TEA Quality Steps -*Run only if the pinned policy sequence includes any of: `test_automate`, `test_review`, `trace`* +*Run only if the pinned policy sequence includes any of: `test_automate`, `test_review`, `nfr`, `trace`* For each enabled TEA step: 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 ea9c5bc4..b3f187f0 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 @@ -15,6 +15,15 @@ outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp} ## Story Loop (Continue from Step 3) +### D. Optional Manual Checkpoint + +If the state frontmatter `manualCheckpoints` includes `checkpoint-preview`, pause here before the git commit step. + +- Display that a manual checkpoint is required for this story. +- Instruct the user to run `bmad-checkpoint-preview` against the current change set. +- Wait for explicit confirmation before proceeding. +- Do not treat `checkpoint-preview` as an autonomous step or as part of the pinned workflow sequence. + ### E. Git Commit **Required:** Commit after every story (do not skip). diff --git a/skills/bmad-story-automator/templates/state-document.md b/skills/bmad-story-automator/templates/state-document.md index de50b019..074d165c 100644 --- a/skills/bmad-story-automator/templates/state-document.md +++ b/skills/bmad-story-automator/templates/state-document.md @@ -16,6 +16,10 @@ overrides: skipAutomate: false maxParallel: 1 customInstructions: "" # User-provided instructions for orchestration +workflowTrack: "standard" +selectedOptionalSteps: [] +manualCheckpoints: [] +policyNotes: [] agentsFile: "" # Deterministic per-story agent selections complexityFile: "" # Persisted story complexity data policyVersion: 0 @@ -76,6 +80,12 @@ completedSessions: [] **Overrides:** - Skip Automate: {{overrides.skipAutomate}} - Max Parallel: {{overrides.maxParallel}} +- Workflow Track: {{workflowTrack}} +- Optional Steps: {{selectedOptionalSteps}} +- Manual Checkpoints: {{manualCheckpoints}} + +**Policy Notes:** +{{policyNotes}} **Custom Instructions:** {{customInstructions}} diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index aaec15b6..5e4f72d0 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -45,6 +45,15 @@ def test_project_override_deep_merges_and_arrays_replace(self) -> None: self.assertEqual(policy["workflow"]["sequence"], ["create", "review"]) self.assertEqual(policy["steps"]["review"]["prompt"]["defaultExtraInstruction"], "fix critical issues only") + def test_inline_override_deep_merges_after_project_override(self) -> None: + self._write_override({"workflow": {"sequence": ["create", "dev", "review"]}}) + policy = load_effective_policy( + str(self.project_root), + inline_override={"workflow": {"repeat": {"review": {"maxCycles": 3}}}}, + ) + self.assertEqual(policy["workflow"]["sequence"], ["create", "dev", "review"]) + self.assertEqual(policy["workflow"]["repeat"]["review"]["maxCycles"], 3) + def test_invalid_step_name_rejected(self) -> None: self._write_override({"steps": {"ship": {"success": {"verifier": "session_exit"}}}}) with self.assertRaises(PolicyError): @@ -344,15 +353,17 @@ def _write_tea_assets(project_root: Path) -> None: (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") -def _tea_steps_override(project_root: Path) -> dict[str, object]: - return { +def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dict[str, object]: + steps: dict[str, object] = { "atdd": { "label": "atdd", "assets": { @@ -410,6 +421,22 @@ def _tea_steps_override(project_root: Path) -> dict[str, object]: "success": {"verifier": "session_exit"}, }, } + if include_nfr: + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": "bmad-tea-testarch-nfr", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/nfr.json"}, + "success": {"verifier": "session_exit"}, + } + return steps if __name__ == "__main__": diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 42391b26..603f1212 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -10,7 +10,7 @@ from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper -from story_automator.commands.state import cmd_build_state_doc, cmd_validate_state +from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_validate_state from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper @@ -479,6 +479,48 @@ def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) - 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: + 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"], ["checkpoint-preview"]) + 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"])) + + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: + self._install_tea_skills(include_nfr=True) + state_file = self._build_state( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + "manualCheckpoints": ["checkpoint-preview"], + } + ) + text = state_file.read_text(encoding="utf-8") + self.assertIn('workflowTrack: "tea"', text) + self.assertIn('selectedOptionalSteps: ["nfr"]', text) + self.assertIn('manualCheckpoints: ["checkpoint-preview"]', text) + self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: self._install_tea_skills() override_dir = self.project_root / "_bmad" / "bmm" @@ -587,14 +629,17 @@ 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: + def _install_tea_skills(self, *, include_nfr: bool = False) -> None: _write_tea_assets(self.project_root) - for name in ( + names = [ "bmad-tea-testarch-atdd", "bmad-tea-testarch-automate", "bmad-tea-testarch-test-review", "bmad-tea-testarch-trace", - ): + ] + if include_nfr: + names.append("bmad-tea-testarch-nfr") + for name in names: skill_dir = self.project_root / ".claude" / "skills" / name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") @@ -634,15 +679,17 @@ def _write_tea_assets(project_root: Path) -> None: (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") + (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") -def _tea_steps_override(project_root: Path) -> dict[str, object]: - return { +def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dict[str, object]: + steps: dict[str, object] = { "atdd": { "label": "atdd", "assets": { @@ -700,6 +747,22 @@ def _tea_steps_override(project_root: Path) -> dict[str, object]: "success": {"verifier": "session_exit"}, }, } + if include_nfr: + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": "bmad-tea-testarch-nfr", + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": "_bmad/tea/story-automator/parse/nfr.json"}, + "success": {"verifier": "session_exit"}, + } + return steps if __name__ == "__main__": From a21e5f6b23c9591c373c7a662302d187e65344b2 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 22 May 2026 16:36:11 +0100 Subject: [PATCH 03/33] Preserve standard-mode state and preflight UX --- .../src/story_automator/commands/state.py | 410 +++++++++--------- .../steps-c/step-02a-preflight-config.md | 45 +- .../steps-c/step-03b-execute-finish.md | 9 - .../templates/state-document.md | 12 +- tests/test_state_policy_metadata.py | 15 +- 5 files changed, 235 insertions(+), 256 deletions(-) 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 503b8095..efb41ad2 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -11,10 +11,6 @@ STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] -TEA_CORE_SEQUENCE = ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"] -TEA_OPTIONAL_AUTOMATED_STEPS = {"nfr", "retro"} -MANUAL_CHECKPOINTS = {"checkpoint-preview"} -UNSUPPORTED_AUTOMATED_OPTIONS = {"validate-create-story"} STEP_DISPLAY_NAMES = { "create": "create-story", @@ -29,6 +25,199 @@ } +def _story_progress_steps(policy: dict[str, Any]) -> list[str]: + sequence = ((policy.get("workflow") or {}).get("sequence")) or [] + return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] + + +def _progress_headers(steps: list[str]) -> list[str]: + headers = ["Story"] + headers.extend(STEP_DISPLAY_NAMES.get(step, step.replace("_", "-")) for step in steps) + headers.extend(["git-commit", "Status"]) + return headers + + +def _markdown_divider(width: int) -> list[str]: + return ["-------" if idx == 0 else "----------" for idx in range(width)] + + +def _progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: + steps = _story_progress_steps(policy) + headers = _progress_headers(steps) + divider = _markdown_divider(len(headers)) + pending_cells = ["⏳"] * len(steps) + ["⏳", "pending"] + rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) + return ( + "| " + " | ".join(headers) + " |", + "| " + " | ".join(divider) + " |", + rows, + ) + + +def cmd_build_state_doc(args: list[str]) -> int: + template = "" + output_folder = "" + config_file = "" + config_json = "" + for idx, arg in enumerate(args): + if arg == "--template" and idx + 1 < len(args): + template = args[idx + 1] + elif arg == "--output-folder" and idx + 1 < len(args): + output_folder = args[idx + 1] + elif 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 not template or not file_exists(template) or not output_folder: + 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) + 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"}) + return 1 + ensure_dir(output_folder) + now = now_utc_z() + stamp = now_utc().strftime("%Y%m%d-%H%M%S") + epic = str(config.get("epic") or "epic") + safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" + output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" + policy_selection = _build_run_policy(Path(get_project_root()), config) + try: + snapshot = snapshot_effective_policy(get_project_root(), inline_override=policy_selection["policyOverride"]) + except (FileNotFoundError, PolicyError, ValueError) as exc: + write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)}) + return 1 + progress_header, progress_divider, progress_rows = _progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) + text = read_text(template) + replacements: dict[str, Any] = { + "epic": config.get("epic", ""), + "epicName": config.get("epicName", ""), + "storyRange": config.get("storyRange", []), + "status": config.get("status", "READY"), + "currentStory": config.get("currentStory"), + "currentStep": config.get("currentStep"), + "stepsCompleted": config.get("stepsCompleted", []), + "lastUpdated": now, + "createdAt": now, + "aiCommand": config.get("aiCommand", ""), + "agentsFile": config.get("agentsFile", ""), + "complexityFile": config.get("complexityFile", ""), + "policyVersion": snapshot["policyVersion"], + "policySnapshotFile": snapshot["policySnapshotFile"], + "policySnapshotHash": snapshot["policySnapshotHash"], + "legacyPolicy": False, + } + overrides = config.get("overrides", {}) if isinstance(config.get("overrides"), dict) else {} + text = re.sub( + r"(?m)^overrides:\n(?:(?:\s{2}.*\n)*)", + "overrides:\n" + f" skipAutomate: {str(bool(overrides.get('skipAutomate', False))).lower()}\n" + f" maxParallel: {int(overrides.get('maxParallel', 1) or 1)}\n", + text, + ) + custom_instructions = json.dumps(config.get("customInstructions", "")) + text = re.sub(r"(?m)^customInstructions:.*$", lambda m: f"customInstructions: {custom_instructions}", text) + if policy_selection["workflowTrack"] == "tea": + tea_frontmatter = ( + f'workflowTrack: {json.dumps(policy_selection["workflowTrack"])}\n' + f"selectedOptionalSteps: {json.dumps(policy_selection['selectedOptionalSteps'])}\n" + f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}\n" + f"policyNotes: {json.dumps(policy_selection['notes'])}\n" + ) + text = text.replace("customInstructions: " + custom_instructions + "\n", "customInstructions: " + custom_instructions + "\n" + tea_frontmatter) + agent_config = config.get("agentConfig") + if isinstance(agent_config, dict): + per_task = agent_config.get("perTask", {}) + if not isinstance(per_task, dict): + per_task = {} + legacy_retro = agent_config.get("retro") + if isinstance(legacy_retro, dict) and "retro" not in per_task: + per_task = {**per_task, "retro": legacy_retro} + default_fallback = agent_config.get("defaultFallback") + if "defaultFallback" not in agent_config: + default_fallback = agent_config.get("fallback", False) + if default_fallback is None: + default_fallback = False + default_primary = agent_config.get("defaultPrimary") + if default_primary is None: + default_primary = agent_config.get("primary") or "auto" + + lines = [ + "agentConfig:", + f" defaultPrimary: {json.dumps(default_primary)}", + f" defaultFallback: {json.dumps(default_fallback)}", + ] + if isinstance(per_task, dict) and per_task: + lines.append(" perTask:") + for task in sorted(per_task): + entry = per_task[task] + if not isinstance(entry, dict): + continue + lines.append(f" {task}:") + if "primary" in entry: + lines.append(f" primary: {json.dumps(entry['primary'])}") + if "fallback" in entry: + value = entry["fallback"] + lines.append(f" fallback: {'false' if value is False else json.dumps(value)}") + complexity_overrides = agent_config.get("complexityOverrides", {}) + if isinstance(complexity_overrides, dict) and complexity_overrides: + lines.append(" complexityOverrides:") + for level in sorted(complexity_overrides): + task_map = complexity_overrides[level] + if not isinstance(task_map, dict) or not task_map: + continue + lines.append(f" {level}:") + for task in sorted(task_map): + entry = task_map[task] + if not isinstance(entry, dict): + continue + lines.append(f" {task}:") + if "primary" in entry: + lines.append(f" primary: {json.dumps(entry['primary'])}") + if "fallback" in entry: + value = entry["fallback"] + lines.append(f" fallback: {'false' if value is False else json.dumps(value)}") + block = "\n".join(lines) + "\n" + 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)] + body = { + "{{epicName}}": str(config.get("epicName", "")), + "{{epic}}": str(config.get("epic", "")), + "{{storyRange}}": ", ".join(story_range), + "{{createdAt}}": now, + "{{overrides.skipAutomate}}": str(bool(overrides.get("skipAutomate", False))).lower(), + "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), + "{{customInstructions}}": str(config.get("customInstructions", "")), + } + tea_block = "" + if policy_selection["workflowTrack"] == "tea": + tea_block_lines = [ + "**TEA Configuration:**", + "- Mandatory TEA Core: atdd, test_automate, test_review, trace", + f"- Optional Automated Steps: {', '.join(policy_selection['selectedOptionalSteps']) or 'none'}", + f"- Policy Notes: {'; '.join(policy_selection['notes']) or 'none'}", + "", + ] + tea_block = "\n".join(tea_block_lines) + body["{{teaConfigurationBlock}}"] = tea_block + for key, value in body.items(): + text = text.replace(key, value) + text = text.replace("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", progress_header) + text = text.replace("|-------|--------------|-----------|----------|-------------|------------|--------|", progress_divider) + text = text.replace("", progress_rows) + output_path.write_text(text) + write_json({"ok": True, "path": str(output_path), "createdAt": now}) + return 0 + + def _normalize_string_list(value: Any) -> list[str]: if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] @@ -172,12 +361,10 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A assets_root = _tea_assets_root(project_root, config) include_nfr = "nfr" in selected include_retro = "retro" in selected - for unsupported in sorted(selected & UNSUPPORTED_AUTOMATED_OPTIONS): - notes.append(f"{unsupported} is not automated on the TEA track in v1 and was not added to the workflow sequence.") - 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.") if "validate-create-story" in selected: notes.append("validate-create-story remains an advisory pre-dev quality check and is not yet automated by story-automator.") + if "qa-generate-e2e-tests" in selected: + notes.append("qa-generate-e2e-tests is superseded by TEA test_automate on the TEA track and was ignored.") sequence = ["create", "atdd", "dev", "test_automate", "test_review"] if include_nfr: sequence.append("nfr") @@ -209,218 +396,17 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A selected.discard("qa-generate-e2e-tests") policy_override = {"workflow": {"sequence": sequence}} - recognized_manual = sorted(checkpoint for checkpoint in manual if checkpoint in MANUAL_CHECKPOINTS) + if manual: + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") return { "policyOverride": policy_override, "workflowTrack": track, "selectedOptionalSteps": sorted(selected), - "manualCheckpoints": recognized_manual, + "manualCheckpoints": [], "notes": notes, } -def _story_progress_steps(policy: dict[str, Any]) -> list[str]: - sequence = ((policy.get("workflow") or {}).get("sequence")) or [] - return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] - - -def _progress_headers(steps: list[str]) -> list[str]: - headers = ["Story"] - headers.extend(STEP_DISPLAY_NAMES.get(step, step.replace("_", "-")) for step in steps) - headers.extend(["git-commit", "Status"]) - return headers - - -def _markdown_divider(width: int) -> list[str]: - return ["-------" if idx == 0 else "----------" for idx in range(width)] - - -def _progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: - steps = _story_progress_steps(policy) - headers = _progress_headers(steps) - divider = _markdown_divider(len(headers)) - pending_cells = ["⏳"] * len(steps) + ["⏳", "pending"] - rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) - return ( - "| " + " | ".join(headers) + " |", - "| " + " | ".join(divider) + " |", - rows, - ) - - -def cmd_build_state_doc(args: list[str]) -> int: - template = "" - output_folder = "" - config_file = "" - config_json = "" - for idx, arg in enumerate(args): - if arg == "--template" and idx + 1 < len(args): - template = args[idx + 1] - elif arg == "--output-folder" and idx + 1 < len(args): - output_folder = args[idx + 1] - elif 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 not template or not file_exists(template) or not output_folder: - 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) - 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"}) - return 1 - ensure_dir(output_folder) - now = now_utc_z() - stamp = now_utc().strftime("%Y%m%d-%H%M%S") - epic = str(config.get("epic") or "epic") - safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" - output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" - policy_selection = _build_run_policy(Path(get_project_root()), config) - try: - snapshot = snapshot_effective_policy(get_project_root(), inline_override=policy_selection["policyOverride"]) - except (FileNotFoundError, PolicyError, ValueError) as exc: - write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)}) - return 1 - progress_header, progress_divider, progress_rows = _progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) - text = read_text(template) - replacements: dict[str, Any] = { - "epic": config.get("epic", ""), - "epicName": config.get("epicName", ""), - "storyRange": config.get("storyRange", []), - "status": config.get("status", "READY"), - "currentStory": config.get("currentStory"), - "currentStep": config.get("currentStep"), - "stepsCompleted": config.get("stepsCompleted", []), - "lastUpdated": now, - "createdAt": now, - "aiCommand": config.get("aiCommand", ""), - "agentsFile": config.get("agentsFile", ""), - "complexityFile": config.get("complexityFile", ""), - "policyVersion": snapshot["policyVersion"], - "policySnapshotFile": snapshot["policySnapshotFile"], - "policySnapshotHash": snapshot["policySnapshotHash"], - "legacyPolicy": False, - "workflowTrack": policy_selection["workflowTrack"], - "selectedOptionalSteps": policy_selection["selectedOptionalSteps"], - "manualCheckpoints": policy_selection["manualCheckpoints"], - "policyNotes": policy_selection["notes"], - } - overrides = config.get("overrides", {}) if isinstance(config.get("overrides"), dict) else {} - text = re.sub( - r"(?m)^overrides:\n(?:(?:\s{2}.*\n)*)", - "overrides:\n" - f" skipAutomate: {str(bool(overrides.get('skipAutomate', False))).lower()}\n" - f" maxParallel: {int(overrides.get('maxParallel', 1) or 1)}\n", - text, - ) - custom_instructions = json.dumps(config.get("customInstructions", "")) - text = re.sub(r"(?m)^customInstructions:.*$", lambda m: f"customInstructions: {custom_instructions}", text) - text = re.sub( - r"(?m)^workflowTrack:.*$", - lambda m: f'workflowTrack: {json.dumps(policy_selection["workflowTrack"])}', - text, - ) - text = re.sub( - r"(?m)^selectedOptionalSteps:.*$", - lambda m: f"selectedOptionalSteps: {json.dumps(policy_selection['selectedOptionalSteps'])}", - text, - ) - text = re.sub( - r"(?m)^manualCheckpoints:.*$", - lambda m: f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}", - text, - ) - text = re.sub( - r"(?m)^policyNotes:.*$", - lambda m: f"policyNotes: {json.dumps(policy_selection['notes'])}", - text, - ) - agent_config = config.get("agentConfig") - if isinstance(agent_config, dict): - per_task = agent_config.get("perTask", {}) - if not isinstance(per_task, dict): - per_task = {} - legacy_retro = agent_config.get("retro") - if isinstance(legacy_retro, dict) and "retro" not in per_task: - per_task = {**per_task, "retro": legacy_retro} - default_fallback = agent_config.get("defaultFallback") - if "defaultFallback" not in agent_config: - default_fallback = agent_config.get("fallback", False) - if default_fallback is None: - default_fallback = False - default_primary = agent_config.get("defaultPrimary") - if default_primary is None: - default_primary = agent_config.get("primary") or "auto" - - lines = [ - "agentConfig:", - f" defaultPrimary: {json.dumps(default_primary)}", - f" defaultFallback: {json.dumps(default_fallback)}", - ] - if isinstance(per_task, dict) and per_task: - lines.append(" perTask:") - for task in sorted(per_task): - entry = per_task[task] - if not isinstance(entry, dict): - continue - lines.append(f" {task}:") - if "primary" in entry: - lines.append(f" primary: {json.dumps(entry['primary'])}") - if "fallback" in entry: - value = entry["fallback"] - lines.append(f" fallback: {'false' if value is False else json.dumps(value)}") - complexity_overrides = agent_config.get("complexityOverrides", {}) - if isinstance(complexity_overrides, dict) and complexity_overrides: - lines.append(" complexityOverrides:") - for level in sorted(complexity_overrides): - task_map = complexity_overrides[level] - if not isinstance(task_map, dict) or not task_map: - continue - lines.append(f" {level}:") - for task in sorted(task_map): - entry = task_map[task] - if not isinstance(entry, dict): - continue - lines.append(f" {task}:") - if "primary" in entry: - lines.append(f" primary: {json.dumps(entry['primary'])}") - if "fallback" in entry: - value = entry["fallback"] - lines.append(f" fallback: {'false' if value is False else json.dumps(value)}") - block = "\n".join(lines) + "\n" - 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)] - body = { - "{{epicName}}": str(config.get("epicName", "")), - "{{epic}}": str(config.get("epic", "")), - "{{storyRange}}": ", ".join(story_range), - "{{createdAt}}": now, - "{{overrides.skipAutomate}}": str(bool(overrides.get("skipAutomate", False))).lower(), - "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), - "{{customInstructions}}": str(config.get("customInstructions", "")), - "{{workflowTrack}}": str(policy_selection["workflowTrack"]), - "{{selectedOptionalSteps}}": ", ".join(policy_selection["selectedOptionalSteps"]) or "none", - "{{manualCheckpoints}}": ", ".join(policy_selection["manualCheckpoints"]) or "none", - "{{policyNotes}}": "\n".join(f"- {note}" for note in policy_selection["notes"]) or "- none", - } - 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 = "" 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 0331cdf8..54776169 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 @@ -25,44 +25,42 @@ agentConfigPresets: '../data/agent-config-presets.json' ## Do -### 1. Configure Workflow Track and Execution Preferences +### 1. Configure Execution Preferences > **PREREQUISITE:** Step 2 (preflight) MUST be complete. The Complexity Matrix MUST have been displayed. If not, STOP and complete step 2 first. ``` -**Workflow + Execution Settings:** +**Execution Settings:** -1. **Workflow track?** [S]tandard (default) / [T]EA -2. **Skip the standard 'automate' step?** [N]o (default) / [Y]es - - Standard track only. Ignored on TEA track. -3. **Max parallel sessions?** (tmux sessions running concurrently, default: 1) +1. **Skip the 'automate' step (test automation)?** [N]o (default) / [Y]es +2. **Max parallel sessions?** (tmux sessions running concurrently, default: 1) + +Enter choices (e.g., `N 1` or `Y 3`): ``` **Wait.** -Store responses as: -- `workflow_track` = `standard` or `tea` -- `skip_automate` = true/false -- `max_parallel` = integer +Store responses as `skip_automate` (true/false) and `max_parallel` (integer). -### 1b. Configure Optional Steps +### 1b. Configure TEA Options (Only When Explicitly Enabling TEA) -If `workflow_track == standard`, offer: -- `retro` (optional epic-level retrospective) -- `checkpoint-preview` (optional manual checkpoint before commit) -- `validate-create-story` (optional advisory only; not automated in v1) +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. -If `workflow_track == tea`, state clearly: +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` -- **Optional manual checkpoint:** `checkpoint-preview` - `validate-create-story` remains advisory only and is not automated in v1 +- `checkpoint-preview` is out of scope for story-automator and must not be modeled as an in-run checkpoint - legacy `qa-generate-e2e-tests` is not added on the TEA track because `test_automate` supersedes it Collect: - `selected_optional_steps` = zero or more of `retro`, `nfr`, `validate-create-story` -- `manual_checkpoints` = zero or more of `checkpoint-preview` +- `workflow_track` = `tea` + +If TEA is not explicitly enabled: +- `workflow_track` = `standard` +- `selected_optional_steps` = `[]` ### 2. Configure Agent (Complexity-Aware) @@ -121,13 +119,15 @@ Only when user chose **[U]niform** or **[C]ustom**, follow the Save Configuratio Display configuration summary: - Epic and story range -- Workflow track - Custom instructions (if any) -- Selected optional automated steps -- Selected manual checkpoints - 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) @@ -167,10 +167,9 @@ config_json=$(jq -n \ --arg customInstructions "$custom_instructions" \ --arg workflowTrack "$workflow_track" \ --argjson selectedOptionalSteps "$selected_optional_steps" \ - --argjson manualCheckpoints "$manual_checkpoints" \ --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,workflowTrack:$workflowTrack,selectedOptionalSteps:$selectedOptionalSteps,manualCheckpoints:$manualCheckpoints,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-03b-execute-finish.md b/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md index b3f187f0..ea9c5bc4 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 @@ -15,15 +15,6 @@ outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp} ## Story Loop (Continue from Step 3) -### D. Optional Manual Checkpoint - -If the state frontmatter `manualCheckpoints` includes `checkpoint-preview`, pause here before the git commit step. - -- Display that a manual checkpoint is required for this story. -- Instruct the user to run `bmad-checkpoint-preview` against the current change set. -- Wait for explicit confirmation before proceeding. -- Do not treat `checkpoint-preview` as an autonomous step or as part of the pinned workflow sequence. - ### E. Git Commit **Required:** Commit after every story (do not skip). diff --git a/skills/bmad-story-automator/templates/state-document.md b/skills/bmad-story-automator/templates/state-document.md index 074d165c..df174707 100644 --- a/skills/bmad-story-automator/templates/state-document.md +++ b/skills/bmad-story-automator/templates/state-document.md @@ -16,10 +16,6 @@ overrides: skipAutomate: false maxParallel: 1 customInstructions: "" # User-provided instructions for orchestration -workflowTrack: "standard" -selectedOptionalSteps: [] -manualCheckpoints: [] -policyNotes: [] agentsFile: "" # Deterministic per-story agent selections complexityFile: "" # Persisted story complexity data policyVersion: 0 @@ -80,16 +76,12 @@ completedSessions: [] **Overrides:** - Skip Automate: {{overrides.skipAutomate}} - Max Parallel: {{overrides.maxParallel}} -- Workflow Track: {{workflowTrack}} -- Optional Steps: {{selectedOptionalSteps}} -- Manual Checkpoints: {{manualCheckpoints}} - -**Policy Notes:** -{{policyNotes}} **Custom Instructions:** {{customInstructions}} +{{teaConfigurationBlock}} + --- ## Story Progress diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 603f1212..2aa73f38 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -501,10 +501,11 @@ def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_ch payload["policyOverride"]["workflow"]["sequence"], ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], ) - self.assertEqual(payload["manualCheckpoints"], ["checkpoint-preview"]) + 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_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: self._install_tea_skills(include_nfr=True) @@ -518,9 +519,19 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s text = state_file.read_text(encoding="utf-8") self.assertIn('workflowTrack: "tea"', text) self.assertIn('selectedOptionalSteps: ["nfr"]', text) - self.assertIn('manualCheckpoints: ["checkpoint-preview"]', text) + self.assertIn('manualCheckpoints: []', text) + self.assertIn("**TEA Configuration:**", text) + self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + def test_build_state_doc_keeps_standard_summary_shape_unchanged(self) -> None: + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertNotIn("Workflow Track:", text) + self.assertNotIn("Optional Steps:", text) + self.assertNotIn("Manual Checkpoints:", text) + def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: self._install_tea_skills() override_dir = self.project_root / "_bmad" / "bmm" From de7d69b11b7bf7fbf5cff762ac36122166276d5d Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 22 May 2026 17:12:13 +0100 Subject: [PATCH 04/33] Add TEA workflow detection --- .../src/story_automator/cli.py | 4 +- .../src/story_automator/commands/state.py | 134 ++++++++++++++++++ .../steps-c/step-02a-preflight-config.md | 30 +++- tests/test_state_policy_metadata.py | 47 +++++- 4 files changed, 212 insertions(+), 3 deletions(-) diff --git a/skills/bmad-story-automator/src/story_automator/cli.py b/skills/bmad-story-automator/src/story_automator/cli.py index 6ec707c5..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_run_policy, 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 @@ -40,6 +40,7 @@ def main(argv: list[str] | None = None) -> int: "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, @@ -77,6 +78,7 @@ def _usage(stream: object) -> None: "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/state.py b/skills/bmad-story-automator/src/story_automator/commands/state.py index efb41ad2..d8d155fc 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,11 +6,18 @@ from typing import Any from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter +from ..core.runtime_layout import resolve_skill_dir from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] +TEA_REQUIRED_SKILLS = ( + "bmad-tea-testarch-atdd", + "bmad-tea-testarch-automate", + "bmad-tea-testarch-test-review", + "bmad-tea-testarch-trace", +) STEP_DISPLAY_NAMES = { "create": "create-story", @@ -430,6 +437,133 @@ def cmd_build_run_policy(args: list[str]) -> int: return 0 +def _has_explicit_tea_policy(project_root: Path) -> bool: + override_path = project_root / "_bmad" / "bmm" / "story-automator.policy.json" + if not override_path.is_file(): + return False + try: + payload = json.loads(read_text(override_path)) + except (OSError, json.JSONDecodeError): + return False + sequence = ((payload.get("workflow") or {}).get("sequence")) or [] + return any(step in {"atdd", "test_automate", "test_review", "trace", "nfr"} for step in sequence if isinstance(step, str)) + + +def _tea_detection_assets_root(project_root: Path) -> str: + wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" + if wrapper_assets.is_dir(): + return "docs/plans/tea-story-automator/assets" + project_assets = project_root / "_bmad" / "tea" / "story-automator" + if project_assets.is_dir(): + return "_bmad/tea/story-automator" + return "" + + +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"] + prompt_dir = project_root / assets_root / "prompts" + parse_dir = project_root / assets_root / "parse" + required = [ + prompt_dir / "atdd.md", + prompt_dir / "test_automate.md", + prompt_dir / "test_review.md", + prompt_dir / "trace.md", + parse_dir / "atdd.json", + parse_dir / "test_automate.json", + parse_dir / "test_review.json", + parse_dir / "trace.json", + ] + missing = [str(path.relative_to(project_root)) for path in required if not path.is_file()] + return not missing, 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) -> tuple[list[str], list[str]]: + available: list[str] = [] + missing: list[str] = [] + for skill_name in TEA_REQUIRED_SKILLS: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + missing.append(skill_name) + continue + if file_exists(str(skill_dir / "SKILL.md")): + available.append(skill_name) + else: + missing.append(skill_name) + return available, missing + + +def _detect_workflow_track(project_root: Path) -> dict[str, Any]: + signals = _tea_project_signals(project_root) + explicit_policy = _has_explicit_tea_policy(project_root) + assets_root = _tea_detection_assets_root(project_root) + assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) + available_skills, missing_skills = _tea_skill_availability(project_root) + reasons: list[str] = [] + prompt = "" + recommended_track = "standard" + requires_confirmation = False + tea_capable = bool(signals) and assets_ok and not missing_skills + + if explicit_policy: + recommended_track = "tea" + reasons.append("Project already defines an explicit TEA story-automator policy override.") + 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 or tea_capable, + "explicitTeaPolicy": explicit_policy, + "signals": signals, + "availableSkills": available_skills, + "missingSkills": missing_skills, + "assetsRoot": assets_root, + "missingAssets": missing_assets, + "reasons": reasons, + } + + +def cmd_detect_workflow_track(args: list[str]) -> int: + project_root = Path(get_project_root()) + for idx, arg in enumerate(args): + if arg == "--project-root" and idx + 1 < len(args): + project_root = Path(args[idx + 1]).expanduser().resolve() + write_json(_detect_workflow_track(project_root)) + return 0 + + def cmd_sprint_compare(args: list[str]) -> int: state = "" sprint = "" 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 54776169..a2709cbc 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,7 +42,35 @@ Enter choices (e.g., `N 1` or `Y 3`): Store responses as `skip_automate` (true/false) and `max_parallel` (integer). -### 1b. Configure TEA Options (Only When Explicitly Enabling TEA) +### 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. diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 2aa73f38..5982a2bb 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -10,7 +10,7 @@ from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper -from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_validate_state +from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_detect_workflow_track, cmd_validate_state from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper @@ -507,6 +507,51 @@ def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_ch self.assertTrue(any("not yet automated by story-automator" in note for note in payload["notes"])) self.assertTrue(any("out of scope for story-automator" in note for note in payload["notes"])) + def test_detect_workflow_track_recommends_tea_when_project_is_capable(self) -> None: + self._install_tea_skills() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["requiresConfirmation"]) + self.assertTrue(payload["teaCapable"]) + self.assertIn("Detected TEA support for this project", payload["prompt"]) + + def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: + _write_tea_assets(self.project_root) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["missingSkills"]) + + def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: + self._install_tea_skills() + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertFalse(payload["requiresConfirmation"]) + self.assertTrue(payload["explicitTeaPolicy"]) + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( From 76564702fddc08e6928b53559640f115ae2b0a04 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 23 May 2026 10:09:06 +0100 Subject: [PATCH 05/33] Fix TEA review flow regressions --- .../story_automator/commands/orchestrator.py | 99 +++++++++++++++++++ .../src/story_automator/commands/state.py | 6 +- .../steps-c/step-01b-continue.md | 2 +- .../steps-c/step-03-execute.md | 23 +++-- .../steps-c/step-03a-execute-review.md | 19 ++-- .../steps-c/step-03b-execute-finish.md | 11 ++- tests/test_state_policy_metadata.py | 53 ++++++++++ 7 files changed, 190 insertions(+), 23 deletions(-) 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 fe0be7bd..0fd012ca 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -62,6 +62,7 @@ def cmd_orchestrator_helper(args: list[str]) -> int: "state-latest-incomplete": _state_latest_incomplete, "state-summary": _state_summary, "state-update": _state_update, + "state-progress": _state_progress, "escalate": _escalate, "commit-ready": _commit_ready, "normalize-key": _normalize_key, @@ -100,6 +101,7 @@ def _usage(code: int) -> int: print(" state-latest-incomplete ", file=target) print(" state-summary ", file=target) print(" state-update --set k=v", file=target) + print(" state-progress --story ID --set step=value", file=target) print(" escalate ", file=target) print(" commit-ready ", file=target) print(" normalize-key [--to id|key|prefix|json]", file=target) @@ -475,6 +477,103 @@ def _verify_step(args: list[str]) -> int: return exit_code +def _normalize_progress_key(value: str) -> str: + key = str(value or "").strip().lower().replace("_", "-") + aliases = { + "create": "create-story", + "dev": "dev-story", + "auto": "automate", + "review": "code-review", + "test-automate": "test-automate", + "test-review": "test-review", + "git_commit": "git-commit", + "git-commit": "git-commit", + "status": "status", + "story": "story", + "create-story": "create-story", + "dev-story": "dev-story", + "automate": "automate", + "code-review": "code-review", + "atdd": "atdd", + "nfr": "nfr", + "trace": "trace", + } + return aliases.get(key, key) + + +def _parse_markdown_cells(line: str) -> list[str]: + parts = [part.strip() for part in line.split("|")] + return [part for part in parts[1:-1]] + + +def _render_markdown_row(cells: list[str]) -> str: + return "| " + " | ".join(cells) + " |" + + +def _state_progress(args: list[str]) -> int: + if not args or not file_exists(args[0]): + print_json({"ok": False, "error": "file_not_found"}) + return 1 + state_file = args[0] + 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): + key, value = args[idx + 1].split("=", 1) + updates[_normalize_progress_key(key)] = value + idx += 2 + continue + idx += 1 + if not story_id or not updates: + print_json({"ok": False, "error": "missing_story_or_updates"}) + return 1 + + lines = read_text(state_file).splitlines() + header_idx = -1 + story_idx = -1 + headers: list[str] = [] + story_cells: list[str] = [] + for i, line in enumerate(lines): + if line.startswith("| Story "): + header_idx = i + headers = [_normalize_progress_key(cell) for cell in _parse_markdown_cells(line)] + continue + if header_idx >= 0 and line.startswith(f"| {story_id} |"): + story_idx = i + story_cells = _parse_markdown_cells(line) + break + if header_idx < 0 or not headers: + print_json({"ok": False, "error": "progress_table_not_found"}) + return 1 + if story_idx < 0 or not story_cells: + print_json({"ok": False, "error": "story_row_not_found"}) + return 1 + if len(story_cells) != len(headers): + print_json({"ok": False, "error": "progress_row_misaligned"}) + return 1 + + header_map = {name: pos for pos, name in enumerate(headers)} + applied: list[str] = [] + for key, value in updates.items(): + pos = header_map.get(key) + if pos is None: + continue + story_cells[pos] = value + applied.append(key) + if not applied: + print_json({"ok": False, "error": "progress_columns_not_found"}) + return 1 + lines[story_idx] = _render_markdown_row(story_cells) + Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") + print_json({"ok": True, "story": story_id, "updated": applied}) + return 0 + + def _parse_context_int(context: str, key: str) -> int: match = re.search(rf"{re.escape(key)}=(\d+)", context) return int(match.group(1)) if match else 0 diff --git a/skills/bmad-story-automator/src/story_automator/commands/state.py b/skills/bmad-story-automator/src/story_automator/commands/state.py index d8d155fc..592aff0e 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -520,9 +520,11 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: requires_confirmation = False tea_capable = bool(signals) and assets_ok and not missing_skills - if explicit_policy: + if explicit_policy and assets_ok and not missing_skills: 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.") elif tea_capable: recommended_track = "tea" requires_confirmation = True @@ -544,7 +546,7 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: "requiresConfirmation": requires_confirmation, "prompt": prompt, "teaDetected": explicit_policy or bool(signals), - "teaCapable": explicit_policy or tea_capable, + "teaCapable": (assets_ok and not missing_skills) if explicit_policy else tea_capable, "explicitTeaPolicy": explicit_policy, "signals": signals, "availableSkills": available_skills, 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 3d9a19b3..d9151249 100644 --- a/skills/bmad-story-automator/steps-c/step-01b-continue.md +++ b/skills/bmad-story-automator/steps-c/step-01b-continue.md @@ -137,7 +137,7 @@ Active sessions: {count or 'None'} - INITIALIZING → `{preflightConfigStep}` - IN_PROGRESS / PAUSED → route by `currentStep`: - `step-03-execute` or `create` or `atdd` or `dev` → `{executeStep}` - - `step-03a-execute-review` or `auto` or `test_automate` or `test_review` or `trace` or `review` → `{executeReviewStep}` + - `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-03-execute.md b/skills/bmad-story-automator/steps-c/step-03-execute.md index 74742f21..b76cc38d 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -102,12 +102,10 @@ Do not silently switch to TEA because TEA skills are installed. Only follow TEA --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$state_file" -# Initialize Story Progress row -tmp_state=$(mktemp) -awk -v row="| {story_id} | - | - | - | - | - | in-progress |" ' - /^$/ { print row } - { print } -' "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file" +# Mark the current story row in progress using the rendered table headers +"$scripts" orchestrator-helper state-progress "$state_file" \ + --story "{story_id}" \ + --set status=in-progress ``` Display: "**Story {N}/{total}: {title}**" @@ -149,8 +147,10 @@ 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}`) @@ -205,8 +205,11 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') - If `next_action == "proceed"`: ```bash # Update Story Progress: mark dev-story done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file" + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set create=done \ + --set dev=done \ + --set status=in-progress ``` → proceed to C (next step) - If `next_action == "retry"` OR `result.final_state == "crashed"`: 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 3dd4df20..c7f1abc0 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 @@ -57,16 +57,20 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") - SUCCESS: ```bash # Update Story Progress: mark automate done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "$scripts" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set auto=done \ + --set status=in-progress ``` Display: `[story {N}/{total}] automate -> done` → proceed to D - FAILURE → retry up to 3 attempts (non-blocking, so fewer retries), then log warning: ```bash # Update Story Progress: mark automate skipped - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | skip | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "$scripts" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set auto=skip \ + --set status=in-progress ``` Display: `[story {N}/{total}] automate -> skip (non-blocking)` → proceed to D @@ -89,6 +93,7 @@ parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | - If `next_action == "proceed"` → continue to the next policy-defined step - If `next_action == "retry"` or the session crashes → apply the retry/fallback pattern - TEA v1 success for these steps means session execution completed successfully +- When a TEA quality step completes, update only that named progress column via `state-progress` rather than rewriting the whole row ### D. Code Review Loop @@ -125,8 +130,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 ea9c5bc4..4b629dca 100644 --- a/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md +++ b/skills/bmad-story-automator/steps-c/step-03b-execute-finish.md @@ -27,8 +27,10 @@ ok=$(echo "$commit" | jq -r '.ok') - If `ok == true`: ```bash # Update Story Progress: mark git-commit done - tmp_state=$(mktemp) - sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" + "{scriptsDir}" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set git-commit=done \ + --set status=in-progress ``` → proceed to F - If `ok == false` → log warning and escalate @@ -60,8 +62,9 @@ Display: "**✅ Story {N} complete.**" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Story {story_id}: ✅ complete (commit + sprint-status verified)" >> "{outputFile}" # Update Story Progress: mark story done -tmp_state=$(mktemp) -sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | done |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}" +"{scriptsDir}" orchestrator-helper state-progress "{outputFile}" \ + --story "${story_id}" \ + --set status=done ``` Display: `[story {N}/{total}] finalize -> done` diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 5982a2bb..a00a3c99 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -552,6 +552,29 @@ def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: self.assertFalse(payload["requiresConfirmation"]) self.assertTrue(payload["explicitTeaPolicy"]) + def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(self) -> None: + _write_tea_assets(self.project_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["explicitTeaPolicy"]) + self.assertTrue(any("required TEA skills or assets are missing" in note for note in payload["reasons"])) + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( @@ -569,6 +592,36 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + def test_state_progress_updates_named_columns_in_tea_table(self) -> None: + self._install_tea_skills(include_nfr=True) + state_file = self._build_state( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + } + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "atdd=done", + "--set", + "nfr=done", + "--set", + "status=in-progress", + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertTrue(payload["ok"]) + text = state_file.read_text(encoding="utf-8") + self.assertIn("| 1.1 | ⏳ | done | ⏳ | ⏳ | ⏳ | done | ⏳ | ⏳ | ⏳ | in-progress |", text) + def test_build_state_doc_keeps_standard_summary_shape_unchanged(self) -> None: state_file = self._build_state() text = state_file.read_text(encoding="utf-8") From cc08957d52429c52c6cde146c25dea8c70576723 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 23 May 2026 15:34:28 +0100 Subject: [PATCH 06/33] Accept canonical TEA skill names --- .../src/story_automator/commands/state.py | 48 ++++++++++------ tests/test_state_policy_metadata.py | 55 +++++++++++++++++-- 2 files changed, 81 insertions(+), 22 deletions(-) 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 592aff0e..7e9ee2fe 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -12,12 +12,13 @@ STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] -TEA_REQUIRED_SKILLS = ( - "bmad-tea-testarch-atdd", - "bmad-tea-testarch-automate", - "bmad-tea-testarch-test-review", - "bmad-tea-testarch-trace", -) +TEA_SKILL_ALIASES = { + "atdd": ("bmad-testarch-atdd", "bmad-tea-testarch-atdd"), + "test_automate": ("bmad-testarch-automate", "bmad-tea-testarch-automate"), + "test_review": ("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), + "trace": ("bmad-testarch-trace", "bmad-tea-testarch-trace"), + "nfr": ("bmad-testarch-nfr", "bmad-tea-testarch-nfr"), +} STEP_DISPLAY_NAMES = { "create": "create-story", @@ -255,13 +256,25 @@ def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: return "_bmad/tea/story-automator" -def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any]: +def _resolve_tea_skill_name(project_root: Path, step: str) -> str: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return skill_name + return candidates[0] if candidates else "" + + +def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: root = assets_root.rstrip("/") steps: dict[str, Any] = { "atdd": { "label": "atdd", "assets": { - "skillName": "bmad-tea-testarch-atdd", + "skillName": _resolve_tea_skill_name(project_root, "atdd"), "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], @@ -275,7 +288,7 @@ def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any "test_automate": { "label": "test-automate", "assets": { - "skillName": "bmad-tea-testarch-automate", + "skillName": _resolve_tea_skill_name(project_root, "test_automate"), "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], @@ -289,7 +302,7 @@ def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any "test_review": { "label": "test-review", "assets": { - "skillName": "bmad-tea-testarch-test-review", + "skillName": _resolve_tea_skill_name(project_root, "test_review"), "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], @@ -303,7 +316,7 @@ def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any "trace": { "label": "trace", "assets": { - "skillName": "bmad-tea-testarch-trace", + "skillName": _resolve_tea_skill_name(project_root, "trace"), "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], @@ -319,7 +332,7 @@ def _tea_step_contracts(assets_root: str, *, include_nfr: bool) -> dict[str, Any steps["nfr"] = { "label": "nfr", "assets": { - "skillName": "bmad-tea-testarch-nfr", + "skillName": _resolve_tea_skill_name(project_root, "nfr"), "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], @@ -380,7 +393,7 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A sequence.append("retro") policy_override = { "workflow": {"sequence": sequence}, - "steps": _tea_step_contracts(assets_root, include_nfr=include_nfr), + "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("") @@ -495,16 +508,19 @@ def _tea_project_signals(project_root: Path) -> list[str]: def _tea_skill_availability(project_root: Path) -> tuple[list[str], list[str]]: available: list[str] = [] missing: list[str] = [] - for skill_name in TEA_REQUIRED_SKILLS: + for step in ("atdd", "test_automate", "test_review", "trace"): + skill_name = _resolve_tea_skill_name(project_root, step) + if not skill_name: + continue try: skill_dir = resolve_skill_dir(project_root, skill_name) except ValueError: - missing.append(skill_name) + missing.append(TEA_SKILL_ALIASES[step][0]) continue if file_exists(str(skill_dir / "SKILL.md")): available.append(skill_name) else: - missing.append(skill_name) + missing.append(TEA_SKILL_ALIASES[step][0]) return available, missing diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index a00a3c99..5f0fc738 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -519,6 +519,25 @@ def test_detect_workflow_track_recommends_tea_when_project_is_capable(self) -> N self.assertTrue(payload["teaCapable"]) self.assertIn("Detected TEA support for this project", payload["prompt"]) + def test_detect_workflow_track_accepts_canonical_tea_skill_names(self) -> None: + self._install_tea_skills(canonical=True) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual( + payload["availableSkills"], + [ + "bmad-testarch-atdd", + "bmad-testarch-automate", + "bmad-testarch-test-review", + "bmad-testarch-trace", + ], + ) + def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: _write_tea_assets(self.project_root) stdout = io.StringIO() @@ -592,6 +611,29 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: + self._install_tea_skills(include_nfr=True, 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", + "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") + def test_state_progress_updates_named_columns_in_tea_table(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( @@ -738,16 +780,17 @@ def _install_required_skills(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") (self.project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text("# checklist\n", encoding="utf-8") - def _install_tea_skills(self, *, include_nfr: bool = False) -> None: + def _install_tea_skills(self, *, include_nfr: bool = False, canonical: bool = False) -> None: _write_tea_assets(self.project_root) + prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" names = [ - "bmad-tea-testarch-atdd", - "bmad-tea-testarch-automate", - "bmad-tea-testarch-test-review", - "bmad-tea-testarch-trace", + f"{prefix}-atdd", + f"{prefix}-automate", + f"{prefix}-test-review", + f"{prefix}-trace", ] if include_nfr: - names.append("bmad-tea-testarch-nfr") + names.append(f"{prefix}-nfr") for name in names: skill_dir = self.project_root / ".claude" / "skills" / name skill_dir.mkdir(parents=True, exist_ok=True) From bbad902e459942fe73f668213bc23816f7b776f1 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 23 May 2026 16:16:42 +0100 Subject: [PATCH 07/33] Add minimal TEA adapter fallback --- .../tea-story-automator/parse/tea_step.json | 8 ++ .../tea-story-automator/prompts/tea_step.md | 7 ++ .../src/story_automator/commands/state.py | 95 +++++++++++++------ tests/test_state_policy_metadata.py | 23 ++++- 4 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json create mode 100644 skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md diff --git a/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json b/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json new file mode 100644 index 00000000..3b9ed5a9 --- /dev/null +++ b/skills/bmad-story-automator/data/tea-story-automator/parse/tea_step.json @@ -0,0 +1,8 @@ +{ + "requiredKeys": ["status", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "summary": "brief description", + "next_action": "proceed|retry|escalate" + } +} diff --git a/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md b/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md new file mode 100644 index 00000000..b66acda1 --- /dev/null +++ b/skills/bmad-story-automator/data/tea-story-automator/prompts/tea_step.md @@ -0,0 +1,7 @@ +Run the `{{label}}` TEA workflow for story `{{story_id}}`. + +{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}{{template_line}}Use the story context already prepared by story automator. + +Return a concise structured result that matches the configured parse schema. + +{{extra_instruction}} diff --git a/skills/bmad-story-automator/src/story_automator/commands/state.py b/skills/bmad-story-automator/src/story_automator/commands/state.py index 7e9ee2fe..d2db5120 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,7 +6,7 @@ from typing import Any from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter -from ..core.runtime_layout import resolve_skill_dir +from ..core.runtime_layout import bundled_story_skill_root, resolve_skill_dir from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json @@ -250,10 +250,43 @@ def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: configured = str(config.get("teaAssetsRoot") or "").strip() if configured: return configured.rstrip("/") + project_assets = project_root / "_bmad" / "tea" / "story-automator" + if project_assets.is_dir(): + return "_bmad/tea/story-automator" wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" if wrapper_assets.is_dir(): return "docs/plans/tea-story-automator/assets" - return "_bmad/tea/story-automator" + return "data/tea-story-automator" + + +def _tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: + raw = Path(assets_root) + candidates: list[Path] = [] + if raw.is_absolute(): + candidates.append(raw.resolve()) + else: + candidates.append((project_root / raw).resolve()) + try: + bundle_root = bundled_story_skill_root(project_root) + candidates.append((bundle_root / raw).resolve()) + except FileNotFoundError: + pass + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] if candidates else None + + +def _tea_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: @@ -270,6 +303,10 @@ def _resolve_tea_skill_name(project_root: Path, step: str) -> str: def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: root = assets_root.rstrip("/") + atdd_prompt, atdd_schema = _tea_contract_files(project_root, assets_root, "atdd") + automate_prompt, automate_schema = _tea_contract_files(project_root, assets_root, "test_automate") + review_prompt, review_schema = _tea_contract_files(project_root, assets_root, "test_review") + trace_prompt, trace_schema = _tea_contract_files(project_root, assets_root, "trace") steps: dict[str, Any] = { "atdd": { "label": "atdd", @@ -281,8 +318,8 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": f"{root}/prompts/atdd.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{root}/parse/atdd.json"}, + "prompt": {"templateFile": atdd_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": atdd_schema}, "success": {"verifier": "session_exit"}, }, "test_automate": { @@ -295,8 +332,8 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": f"{root}/prompts/test_automate.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{root}/parse/test_automate.json"}, + "prompt": {"templateFile": automate_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": automate_schema}, "success": {"verifier": "session_exit"}, }, "test_review": { @@ -309,8 +346,8 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": f"{root}/prompts/test_review.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{root}/parse/test_review.json"}, + "prompt": {"templateFile": review_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": review_schema}, "success": {"verifier": "session_exit"}, }, "trace": { @@ -323,12 +360,13 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": f"{root}/prompts/trace.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{root}/parse/trace.json"}, + "prompt": {"templateFile": trace_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": trace_schema}, "success": {"verifier": "session_exit"}, }, } if include_nfr: + nfr_prompt, nfr_schema = _tea_contract_files(project_root, assets_root, "nfr") steps["nfr"] = { "label": "nfr", "assets": { @@ -339,8 +377,8 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": f"{root}/prompts/nfr.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{root}/parse/nfr.json"}, + "prompt": {"templateFile": nfr_prompt, "interactionMode": "autonomous"}, + "parse": {"schemaFile": nfr_schema}, "success": {"verifier": "session_exit"}, } return steps @@ -463,31 +501,34 @@ def _has_explicit_tea_policy(project_root: Path) -> bool: def _tea_detection_assets_root(project_root: Path) -> str: - wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" - if wrapper_assets.is_dir(): - return "docs/plans/tea-story-automator/assets" project_assets = project_root / "_bmad" / "tea" / "story-automator" if project_assets.is_dir(): return "_bmad/tea/story-automator" - return "" + wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" + if wrapper_assets.is_dir(): + return "docs/plans/tea-story-automator/assets" + return "data/tea-story-automator" 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"] - prompt_dir = project_root / assets_root / "prompts" - parse_dir = project_root / assets_root / "parse" + 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 (base / "prompts" / "tea_step.md").is_file() and (base / "parse" / "tea_step.json").is_file(): + return True, [] required = [ - prompt_dir / "atdd.md", - prompt_dir / "test_automate.md", - prompt_dir / "test_review.md", - prompt_dir / "trace.md", - parse_dir / "atdd.json", - parse_dir / "test_automate.json", - parse_dir / "test_review.json", - parse_dir / "trace.json", + 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.relative_to(project_root)) for path in required if not path.is_file()] + missing = [str(path) for path in required if not path.is_file()] return not missing, missing diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 5f0fc738..c3429476 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -538,6 +538,18 @@ def test_detect_workflow_track_accepts_canonical_tea_skill_names(self) -> None: ], ) + def test_detect_workflow_track_uses_bundled_tea_adapter_assets(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") + self.assertEqual(payload["missingAssets"], []) + def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: _write_tea_assets(self.project_root) stdout = io.StringIO() @@ -612,7 +624,7 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: - self._install_tea_skills(include_nfr=True, canonical=True) + self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_build_run_policy( @@ -633,6 +645,8 @@ def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> 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_state_progress_updates_named_columns_in_tea_table(self) -> None: self._install_tea_skills(include_nfr=True) @@ -780,8 +794,11 @@ def _install_required_skills(self) -> None: (self.project_root / ".claude" / "skills" / "bmad-dev-story" / "checklist.md").write_text("# checklist\n", encoding="utf-8") (self.project_root / ".claude" / "skills" / "bmad-qa-generate-e2e-tests" / "checklist.md").write_text("# checklist\n", encoding="utf-8") - def _install_tea_skills(self, *, include_nfr: bool = False, canonical: bool = False) -> None: - _write_tea_assets(self.project_root) + def _install_tea_skills(self, *, include_nfr: bool = False, canonical: bool = False, write_assets: bool = True) -> None: + if write_assets: + _write_tea_assets(self.project_root) + else: + (self.project_root / "_bmad" / "tea" / "workflows" / "testarch").mkdir(parents=True, exist_ok=True) prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" names = [ f"{prefix}-atdd", From e3859e954e70fe13c0dd29651a1a05bb93aae5d9 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 23 May 2026 20:41:57 +0100 Subject: [PATCH 08/33] Tighten TEA fallback and NFR gating --- .../src/story_automator/commands/state.py | 74 +++++++++++++++---- tests/test_state_policy_metadata.py | 40 ++++++++++ 2 files changed, 98 insertions(+), 16 deletions(-) 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 d2db5120..4e97127c 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -250,13 +250,20 @@ def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: configured = str(config.get("teaAssetsRoot") or "").strip() if configured: return configured.rstrip("/") - project_assets = project_root / "_bmad" / "tea" / "story-automator" - if project_assets.is_dir(): - return "_bmad/tea/story-automator" - wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" - if wrapper_assets.is_dir(): - return "docs/plans/tea-story-automator/assets" - return "data/tea-story-automator" + 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: @@ -277,6 +284,31 @@ def _tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: 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("/") @@ -301,6 +333,18 @@ def _resolve_tea_skill_name(project_root: Path, step: str) -> str: return candidates[0] if candidates else "" +def _tea_skill_installed(project_root: Path, step: str) -> bool: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return True + return False + + def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: root = assets_root.rstrip("/") atdd_prompt, atdd_schema = _tea_contract_files(project_root, assets_root, "atdd") @@ -418,6 +462,10 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A if track == "tea": assets_root = _tea_assets_root(project_root, config) include_nfr = "nfr" in selected + if include_nfr and not _tea_skill_installed(project_root, "nfr"): + notes.append("nfr was requested on the TEA track, but the TEA NFR skill is not installed, so it was ignored.") + include_nfr = False + selected.discard("nfr") include_retro = "retro" in selected if "validate-create-story" in selected: notes.append("validate-create-story remains an advisory pre-dev quality check and is not yet automated by story-automator.") @@ -501,13 +549,7 @@ def _has_explicit_tea_policy(project_root: Path) -> bool: def _tea_detection_assets_root(project_root: Path) -> str: - project_assets = project_root / "_bmad" / "tea" / "story-automator" - if project_assets.is_dir(): - return "_bmad/tea/story-automator" - wrapper_assets = project_root / "docs" / "plans" / "tea-story-automator" / "assets" - if wrapper_assets.is_dir(): - return "docs/plans/tea-story-automator/assets" - return "data/tea-story-automator" + return _tea_detected_assets_root(project_root) def _tea_assets_complete(project_root: Path, assets_root: str) -> tuple[bool, list[str]]: @@ -516,7 +558,7 @@ def _tea_assets_complete(project_root: Path, assets_root: str) -> tuple[bool, li 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 (base / "prompts" / "tea_step.md").is_file() and (base / "parse" / "tea_step.json").is_file(): + if _tea_assets_complete_for_base(base): return True, [] required = [ base / "prompts" / "atdd.md", @@ -529,7 +571,7 @@ def _tea_assets_complete(project_root: Path, assets_root: str) -> tuple[bool, li base / "parse" / "trace.json", ] missing = [str(path) for path in required if not path.is_file()] - return not missing, missing + return False, missing def _tea_project_signals(project_root: Path) -> list[str]: diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index c3429476..cca917b1 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -480,6 +480,7 @@ def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) - self.assertIn("| 1.1 | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |", text) def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_checkpoint(self) -> None: + self._install_tea_skills(include_nfr=True) stdout = io.StringIO() with patch_env(self.project_root), redirect_stdout(stdout): code = cmd_build_run_policy( @@ -550,6 +551,20 @@ def test_detect_workflow_track_uses_bundled_tea_adapter_assets(self) -> None: self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") self.assertEqual(payload["missingAssets"], []) + def test_detect_workflow_track_falls_back_when_project_tea_assets_are_incomplete(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + incomplete_dir = self.project_root / "_bmad" / "tea" / "story-automator" / "prompts" + incomplete_dir.mkdir(parents=True, exist_ok=True) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") + self.assertEqual(payload["missingAssets"], []) + def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: _write_tea_assets(self.project_root) stdout = io.StringIO() @@ -648,6 +663,31 @@ def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> 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_drops_nfr_when_nfr_skill_is_missing(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "tea", + "selectedOptionalSteps": ["nfr"], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], + ) + self.assertNotIn("nfr", payload["policyOverride"]["steps"]) + self.assertEqual(payload["selectedOptionalSteps"], []) + self.assertTrue(any("TEA NFR skill is not installed" in note for note in payload["notes"])) + def test_state_progress_updates_named_columns_in_tea_table(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( From 8026802f3c858735fec3c424191f484659159229 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sat, 23 May 2026 21:29:32 +0100 Subject: [PATCH 09/33] Isolate standard mode from TEA overrides --- .../src/story_automator/commands/state.py | 28 +++++++++---- .../story_automator/core/runtime_policy.py | 17 ++++++++ tests/test_state_policy_metadata.py | 41 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) 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 4e97127c..800b9e50 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -536,16 +536,26 @@ def cmd_build_run_policy(args: list[str]) -> int: return 0 -def _has_explicit_tea_policy(project_root: Path) -> bool: +def _explicit_policy_payload(project_root: Path) -> dict[str, Any]: override_path = project_root / "_bmad" / "bmm" / "story-automator.policy.json" if not override_path.is_file(): - return False + return {} try: payload = json.loads(read_text(override_path)) except (OSError, json.JSONDecodeError): - return False + return {} + return payload if isinstance(payload, dict) else {} + + +def _explicit_tea_steps(project_root: Path) -> list[str]: + payload = _explicit_policy_payload(project_root) sequence = ((payload.get("workflow") or {}).get("sequence")) or [] - return any(step in {"atdd", "test_automate", "test_review", "trace", "nfr"} for step in sequence if isinstance(step, str)) + tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} + return [step for step in sequence if isinstance(step, str) and step in tea_steps] + + +def _has_explicit_tea_policy(project_root: Path) -> bool: + return bool(_explicit_tea_steps(project_root)) def _tea_detection_assets_root(project_root: Path) -> str: @@ -588,12 +598,13 @@ def _tea_project_signals(project_root: Path) -> list[str]: return signals -def _tea_skill_availability(project_root: Path) -> tuple[list[str], list[str]]: +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 ("atdd", "test_automate", "test_review", "trace"): + for step in (required_steps or ["atdd", "test_automate", "test_review", "trace"]): skill_name = _resolve_tea_skill_name(project_root, step) if not skill_name: + missing.append(TEA_SKILL_ALIASES[step][0]) continue try: skill_dir = resolve_skill_dir(project_root, skill_name) @@ -609,10 +620,11 @@ def _tea_skill_availability(project_root: Path) -> tuple[list[str], list[str]]: def _detect_workflow_track(project_root: Path) -> dict[str, Any]: signals = _tea_project_signals(project_root) - explicit_policy = _has_explicit_tea_policy(project_root) + explicit_steps = _explicit_tea_steps(project_root) + explicit_policy = bool(explicit_steps) assets_root = _tea_detection_assets_root(project_root) assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) - available_skills, missing_skills = _tea_skill_availability(project_root) + available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) reasons: list[str] = [] prompt = "" recommended_track = "standard" 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 f4117a3e..97adb19f 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 @@ -21,6 +21,7 @@ def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool bundle_root = bundled_skill_root(root) policy = _read_json(bundle_root / "data" / "orchestration-policy.json") _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundle_root) else: @@ -45,6 +46,7 @@ def load_effective_policy( policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) _clear_resolved_fields(policy) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root)) @@ -115,6 +117,7 @@ def load_policy_snapshot( except json.JSONDecodeError as exc: raise PolicyError(f"policy json invalid: {path}") from exc _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root)) else: @@ -272,6 +275,20 @@ def _clear_resolved_fields(policy: dict[str, Any]) -> None: success.pop("contractHash", None) +def _prune_unreferenced_steps(policy: dict[str, Any]) -> None: + steps = policy.get("steps") + workflow = policy.get("workflow") + if not isinstance(steps, dict) or not isinstance(workflow, dict): + return + sequence = workflow.get("sequence") or [] + if not isinstance(sequence, list): + return + referenced = {step for step in sequence if isinstance(step, str)} + if not referenced: + return + policy["steps"] = {name: contract for name, contract in steps.items() if name in referenced} + + def _apply_legacy_env(policy: dict[str, Any]) -> None: review_cycles = os.environ.get("MAX_REVIEW_CYCLES") crash_retries = os.environ.get("MAX_CRASH_RETRIES") diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index cca917b1..7875c075 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -621,6 +621,29 @@ def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(s self.assertTrue(payload["explicitTeaPolicy"]) self.assertTrue(any("required TEA skills or assets are missing" in note for note in payload["reasons"])) + def test_detect_workflow_track_rejects_explicit_tea_policy_when_nfr_skill_is_missing(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root, include_nfr=True), + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["explicitTeaPolicy"]) + self.assertIn("bmad-testarch-nfr", payload["missingSkills"]) + def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( @@ -638,6 +661,24 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) + def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root, include_nfr=True), + } + ), + encoding="utf-8", + ) + state_file = self._build_state({"workflowTrack": "standard"}) + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) stdout = io.StringIO() From 4c8b7b41c98e056ac851ebb551bb686589717b1b Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Sun, 24 May 2026 11:25:32 +0100 Subject: [PATCH 10/33] Keep standard mode isolated from TEA detection --- .../src/story_automator/commands/state.py | 21 +++++++-- tests/test_state_policy_metadata.py | 44 ++++++++++++++++++- 2 files changed, 59 insertions(+), 6 deletions(-) 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 800b9e50..32b8c608 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -7,7 +7,7 @@ from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter from ..core.runtime_layout import bundled_story_skill_root, resolve_skill_dir -from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy +from ..core.runtime_policy import PolicyError, load_effective_policy, load_policy_for_state, snapshot_effective_policy from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json @@ -444,7 +444,7 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A ) if not has_run_selection: return { - "policyOverride": {}, + "policyOverride": {"workflow": {"sequence": list(STANDARD_SEQUENCE)}}, "workflowTrack": "standard", "selectedOptionalSteps": [], "manualCheckpoints": [], @@ -558,6 +558,16 @@ def _has_explicit_tea_policy(project_root: Path) -> bool: return bool(_explicit_tea_steps(project_root)) +def _explicit_tea_policy_valid(project_root: Path) -> tuple[bool, str]: + if not _has_explicit_tea_policy(project_root): + return False, "" + try: + load_effective_policy(str(project_root), resolve_assets=True) + except (FileNotFoundError, PolicyError, ValueError) as exc: + return False, str(exc) + return True, "" + + def _tea_detection_assets_root(project_root: Path) -> str: return _tea_detected_assets_root(project_root) @@ -625,17 +635,20 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: assets_root = _tea_detection_assets_root(project_root) assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) + explicit_policy_valid, explicit_policy_error = _explicit_tea_policy_valid(project_root) 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 assets_ok and not missing_skills: + if explicit_policy and explicit_policy_valid and assets_ok and not missing_skills: recommended_track = "tea" reasons.append("Project already defines an explicit TEA story-automator policy override.") elif explicit_policy: reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") + if explicit_policy_error: + reasons.append(explicit_policy_error) elif tea_capable: recommended_track = "tea" requires_confirmation = True @@ -657,7 +670,7 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: "requiresConfirmation": requires_confirmation, "prompt": prompt, "teaDetected": explicit_policy or bool(signals), - "teaCapable": (assets_ok and not missing_skills) if explicit_policy else tea_capable, + "teaCapable": (explicit_policy_valid and assets_ok and not missing_skills) if explicit_policy else tea_capable, "explicitTeaPolicy": explicit_policy, "signals": signals, "availableSkills": available_skills, diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 7875c075..a1e6897c 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -474,7 +474,7 @@ def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) - ), encoding="utf-8", ) - state_file = self._build_state() + 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) @@ -598,6 +598,28 @@ def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: self.assertFalse(payload["requiresConfirmation"]) self.assertTrue(payload["explicitTeaPolicy"]) + def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {}, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(any("workflow.sequence references missing step: atdd" in note for note in payload["reasons"])) + def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(self) -> None: _write_tea_assets(self.project_root) override_dir = self.project_root / "_bmad" / "bmm" @@ -679,6 +701,24 @@ def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self self.assertNotIn("**TEA Configuration:**", text) self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + def test_build_state_doc_legacy_config_stays_standard_despite_explicit_tea_override(self) -> None: + self._install_tea_skills(canonical=True) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": _tea_steps_override(self.project_root), + } + ), + encoding="utf-8", + ) + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) stdout = io.StringIO() @@ -780,7 +820,7 @@ def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: ), encoding="utf-8", ) - state_file = self._build_state() + 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"}}]}), From 909b5724393dacfc68fc44f0e7325a1d772ef6d0 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 13:01:23 +0100 Subject: [PATCH 11/33] conflict resolution and PR comment resolution --- .../story_automator/commands/orchestrator.py | 6 +- .../src/story_automator/commands/state.py | 59 +++++++-- .../steps-c/step-02a-preflight-config.md | 4 +- .../steps-c/step-03-execute.md | 1 - tests/test_state_policy_metadata.py | 118 +++++++++++++++--- 5 files changed, 154 insertions(+), 34 deletions(-) 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 0fd012ca..dea78cf8 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -524,7 +524,11 @@ def _state_progress(args: list[str]) -> int: idx += 2 continue if args[idx] == "--set" and idx + 1 < len(args): - key, value = args[idx + 1].split("=", 1) + raw_update = args[idx + 1] + if "=" not in raw_update: + print_json({"ok": False, "error": "invalid_set_argument", "argument": raw_update}) + return 1 + key, value = raw_update.split("=", 1) updates[_normalize_progress_key(key)] = value idx += 2 continue 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 32b8c608..808b7715 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -431,9 +431,12 @@ def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bo def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: explicit_override = config.get("policyOverride") if isinstance(explicit_override, dict): + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" return { "policyOverride": explicit_override, - "workflowTrack": str(config.get("workflowTrack") or "standard"), + "workflowTrack": track, "selectedOptionalSteps": _normalize_string_list(config.get("selectedOptionalSteps")), "manualCheckpoints": _normalize_string_list(config.get("manualCheckpoints")), "notes": _normalize_string_list(config.get("policyNotes")), @@ -558,14 +561,18 @@ def _has_explicit_tea_policy(project_root: Path) -> bool: return bool(_explicit_tea_steps(project_root)) -def _explicit_tea_policy_valid(project_root: Path) -> tuple[bool, str]: +def _explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | None, str]: if not _has_explicit_tea_policy(project_root): - return False, "" + return None, "" try: - load_effective_policy(str(project_root), resolve_assets=True) + return load_effective_policy(str(project_root), resolve_assets=True), "" except (FileNotFoundError, PolicyError, ValueError) as exc: - return False, str(exc) - return True, "" + return None, str(exc) + + +def _explicit_tea_policy_valid(project_root: Path) -> tuple[bool, str]: + policy, error = _explicit_tea_policy_details(project_root) + return policy is not None, error def _tea_detection_assets_root(project_root: Path) -> str: @@ -628,21 +635,49 @@ def _tea_skill_availability(project_root: Path, required_steps: list[str] | None 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, asset_roots[0] if len(asset_roots) == 1 else "" + + def _detect_workflow_track(project_root: Path) -> dict[str, Any]: signals = _tea_project_signals(project_root) explicit_steps = _explicit_tea_steps(project_root) explicit_policy = bool(explicit_steps) - assets_root = _tea_detection_assets_root(project_root) - assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) - available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) - explicit_policy_valid, explicit_policy_error = _explicit_tea_policy_valid(project_root) + explicit_policy_resolved, explicit_policy_error = _explicit_tea_policy_details(project_root) + explicit_policy_valid = explicit_policy_resolved is not None + if explicit_policy and explicit_policy_valid: + available_skills, assets_root = _resolved_explicit_tea_status(explicit_policy_resolved, explicit_steps) + missing_skills = [] + missing_assets = [] + assets_ok = True + else: + assets_root = _tea_detection_assets_root(project_root) + assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) + available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) reasons: list[str] = [] prompt = "" recommended_track = "standard" requires_confirmation = False tea_capable = bool(signals) and assets_ok and not missing_skills - if explicit_policy and explicit_policy_valid 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: @@ -670,7 +705,7 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: "requiresConfirmation": requires_confirmation, "prompt": prompt, "teaDetected": explicit_policy or bool(signals), - "teaCapable": (explicit_policy_valid and assets_ok and not missing_skills) if explicit_policy else tea_capable, + "teaCapable": explicit_policy_valid if explicit_policy else tea_capable, "explicitTeaPolicy": explicit_policy, "signals": signals, "availableSkills": available_skills, 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 a2709cbc..57864469 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 @@ -83,9 +83,11 @@ For the TEA track, state clearly: - legacy `qa-generate-e2e-tests` is not added on the TEA track because `test_automate` supersedes it Collect: -- `selected_optional_steps` = zero or more of `retro`, `nfr`, `validate-create-story` +- `selected_optional_steps` = zero or more of `retro`, `nfr` - `workflow_track` = `tea` +If `validate-create-story` is referenced elsewhere while `workflow_track == tea`, record that only as intent in `selected_optional_steps` notes; it does not trigger any automated action in v1. + If TEA is not explicitly enabled: - `workflow_track` = `standard` - `selected_optional_steps` = `[]` 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 b76cc38d..78a2fce0 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -207,7 +207,6 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') # Update Story Progress: mark dev-story done "$scripts" orchestrator-helper state-progress "$state_file" \ --story "${story_id}" \ - --set create=done \ --set dev=done \ --set status=in-progress ``` diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index a1e6897c..dab66f16 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -598,6 +598,41 @@ def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: self.assertFalse(payload["requiresConfirmation"]) self.assertTrue(payload["explicitTeaPolicy"]) + def test_detect_workflow_track_trusts_valid_explicit_tea_policy_with_custom_asset_root(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + custom_root = self.project_root / "custom-tea-assets" + _write_tea_assets(self.project_root, root=custom_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override_steps = _tea_steps_override(self.project_root, canonical=True, assets_root="custom-tea-assets") + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": override_steps, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["assetsRoot"], "custom-tea-assets") + self.assertEqual(payload["missingAssets"], []) + self.assertEqual( + payload["availableSkills"], + [ + "bmad-testarch-atdd", + "bmad-testarch-automate", + "bmad-testarch-test-review", + "bmad-testarch-trace", + ], + ) + def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract(self) -> None: self._install_tea_skills(canonical=True) override_dir = self.project_root / "_bmad" / "bmm" @@ -744,6 +779,24 @@ def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> self.assertEqual(payload["policyOverride"]["steps"]["atdd"]["prompt"]["templateFile"], "data/tea-story-automator/prompts/tea_step.md") self.assertEqual(payload["policyOverride"]["steps"]["nfr"]["parse"]["schemaFile"], "data/tea-story-automator/parse/tea_step.json") + def test_build_run_policy_normalizes_workflow_track_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "policyOverride": {"workflow": {"sequence": ["create", "dev", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["workflowTrack"], "tea") + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: self._install_tea_skills(canonical=True, write_assets=False) stdout = io.StringIO() @@ -799,6 +852,25 @@ def test_state_progress_updates_named_columns_in_tea_table(self) -> None: 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_build_state_doc_keeps_standard_summary_shape_unchanged(self) -> None: state_file = self._build_state() text = state_file.read_text(encoding="utf-8") @@ -961,9 +1033,10 @@ def __exit__(self, exc_type, exc, tb) -> None: os.environ[key] = value -def _write_tea_assets(project_root: Path) -> None: - prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" - parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" +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") @@ -978,62 +1051,69 @@ def _write_tea_assets(project_root: Path) -> None: (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") -def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dict[str, object]: +def _tea_steps_override( + project_root: Path, + *, + include_nfr: bool = False, + canonical: bool = False, + assets_root: str = "_bmad/tea/story-automator", +) -> dict[str, object]: + prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" steps: dict[str, object] = { "atdd": { "label": "atdd", "assets": { - "skillName": "bmad-tea-testarch-atdd", + "skillName": f"{prefix}-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"}, + "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": "bmad-tea-testarch-automate", + "skillName": f"{prefix}-automate", "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_automate.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_automate.json"}, + "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": "bmad-tea-testarch-test-review", + "skillName": f"{prefix}-test-review", "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_review.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_review.json"}, + "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": "bmad-tea-testarch-trace", + "skillName": f"{prefix}-trace", "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/trace.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/trace.json"}, + "prompt": {"templateFile": f"{assets_root}/prompts/trace.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/trace.json"}, "success": {"verifier": "session_exit"}, }, } @@ -1041,15 +1121,15 @@ def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dic steps["nfr"] = { "label": "nfr", "assets": { - "skillName": "bmad-tea-testarch-nfr", + "skillName": f"{prefix}-nfr", "workflowCandidates": ["workflow.md", "workflow.yaml"], "instructionsCandidates": [], "checklistCandidates": ["checklist.md"], "templateCandidates": [], "required": ["skill"], }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/nfr.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/nfr.json"}, + "prompt": {"templateFile": f"{assets_root}/prompts/nfr.md", "interactionMode": "autonomous"}, + "parse": {"schemaFile": f"{assets_root}/parse/nfr.json"}, "success": {"verifier": "session_exit"}, } return steps From 703665057f0563da6c794724c2c837b3b72e7ea7 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 13:07:33 +0100 Subject: [PATCH 12/33] Tighten TEA PR comment follow-ups --- .../src/story_automator/commands/state.py | 2 +- .../steps-c/step-02a-preflight-config.md | 2 +- tests/test_state_policy_metadata.py | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) 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 808b7715..69322b02 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -653,7 +653,7 @@ def _resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[s root = str(Path(template_file).parent.parent).replace("\\", "/") if root and root not in asset_roots: asset_roots.append(root) - return available, asset_roots[0] if len(asset_roots) == 1 else "" + return available, ", ".join(asset_roots) def _detect_workflow_track(project_root: Path) -> dict[str, Any]: 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 57864469..93757ff2 100644 --- a/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md +++ b/skills/bmad-story-automator/steps-c/step-02a-preflight-config.md @@ -86,7 +86,7 @@ Collect: - `selected_optional_steps` = zero or more of `retro`, `nfr` - `workflow_track` = `tea` -If `validate-create-story` is referenced elsewhere while `workflow_track == tea`, record that only as intent in `selected_optional_steps` notes; it does not trigger any automated action in v1. +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` diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index dab66f16..98193409 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -633,6 +633,38 @@ def test_detect_workflow_track_trusts_valid_explicit_tea_policy_with_custom_asse ], ) + def test_detect_workflow_track_reports_multiple_asset_roots_for_valid_explicit_policy(self) -> None: + self._install_tea_skills(canonical=True, write_assets=False) + _write_tea_assets(self.project_root) + custom_root = self.project_root / "custom-tea-assets" + _write_tea_assets(self.project_root, root=custom_root) + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + override_steps = _tea_steps_override(self.project_root, canonical=True) + override_steps["trace"] = _tea_steps_override( + self.project_root, + canonical=True, + assets_root="custom-tea-assets", + )["trace"] + (override_dir / "story-automator.policy.json").write_text( + json.dumps( + { + "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, + "steps": override_steps, + } + ), + encoding="utf-8", + ) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "tea") + self.assertTrue(payload["teaCapable"]) + self.assertEqual(payload["missingAssets"], []) + self.assertEqual(payload["assetsRoot"], "_bmad/tea/story-automator, custom-tea-assets") + def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract(self) -> None: self._install_tea_skills(canonical=True) override_dir = self.project_root / "_bmad" / "bmm" From 162a450e545c815d886f844013c1ee87c7c759a4 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 13:33:35 +0100 Subject: [PATCH 13/33] fix: avoid asset resolution for agent task sequencing --- .../commands/orchestrator_epic_agents.py | 4 +- .../src/story_automator/core/agent_config.py | 4 +- .../story_automator/core/runtime_policy.py | 60 ++++++++++++++----- 3 files changed, 48 insertions(+), 20 deletions(-) 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 5346eb5d..a8384087 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py @@ -5,7 +5,7 @@ from pathlib import Path from story_automator.core.frontmatter import extract_frontmatter, find_frontmatter_value, parse_frontmatter -from story_automator.core.runtime_policy import load_policy_for_state, story_task_sequence +from story_automator.core.runtime_policy import load_policy_shape_for_state, story_task_sequence from story_automator.core.runtime_layout import runtime_provider from story_automator.core.sprint import sprint_status_epic from story_automator.core.story_keys import normalize_story_key @@ -117,7 +117,7 @@ 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"])) - policy = load_policy_for_state(options["state-file"]) + policy = load_policy_shape_for_state(options["state-file"]) tasks_in_scope = story_task_sequence(policy) stories = [] for story in complexity.get("stories", []): 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 c80adeb8..f9bded02 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,7 +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 load_policy_for_state, story_task_sequence +from .runtime_policy import load_policy_shape_for_state, story_task_sequence from .runtime_layout import runtime_provider @@ -194,7 +194,7 @@ 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)) - tasks_in_scope = story_task_sequence(load_policy_for_state(state_file)) + tasks_in_scope = story_task_sequence(load_policy_shape_for_state(state_file)) stories = [] for story in complexity_payload.get("stories", []): level = str(((story.get("complexity") or {}).get("level")) or "medium").strip().lower() or "medium" 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 97adb19f..4e9d63e6 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 @@ -19,9 +19,7 @@ def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]: root = Path(project_root or get_project_root()).resolve() bundle_root = bundled_skill_root(root) - policy = _read_json(bundle_root / "data" / "orchestration-policy.json") - _validate_policy_shape(policy) - _prune_unreferenced_steps(policy) + policy = _load_bundled_policy_shape(root) if resolve_assets: _resolve_policy_paths(policy, project_root=root, bundle_root=bundle_root) else: @@ -105,19 +103,7 @@ def load_policy_snapshot( path = _ensure_within(path, root, "policy snapshot") if not path.is_file(): raise PolicyError(f"policy snapshot missing: {path}") - 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) + 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: @@ -147,6 +133,22 @@ 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") + 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: @@ -233,6 +235,32 @@ def bundled_skill_root(project_root: str | Path | None = None) -> Path: raise PolicyError("bundled policy not found") from exc +def _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[str, Any]: + root = Path(project_root or get_project_root()).resolve() + bundle_root = bundled_skill_root(root) + policy = _read_json(bundle_root / "data" / "orchestration-policy.json") + _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) + return policy + + +def _load_policy_snapshot_shape(path: Path, *, expected_hash: str = "") -> dict[str, Any]: + try: + raw = read_text(path) + except OSError as exc: + raise PolicyError(f"policy snapshot unreadable: {path}") from exc + actual_hash = md5_hex8(raw) + if expected_hash and actual_hash != expected_hash: + raise PolicyError(f"policy snapshot hash mismatch: expected {expected_hash}, got {actual_hash}") + try: + policy = json.loads(raw) + except json.JSONDecodeError as exc: + raise PolicyError(f"policy json invalid: {path}") from exc + _validate_policy_shape(policy) + _prune_unreferenced_steps(policy) + return policy + + def _read_json(path: str | Path) -> dict[str, Any]: try: payload = json.loads(read_text(path)) From c1ff3e9958ea30585a926f77628bd616951164c0 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 15:06:18 +0100 Subject: [PATCH 14/33] fix: tighten PR review follow-ups --- .../commands/orchestrator_epic_agents.py | 10 ++- .../src/story_automator/commands/state.py | 23 +++--- .../src/story_automator/core/agent_config.py | 7 +- tests/test_agent_config_model.py | 50 +++++++++++++ tests/test_state_policy_metadata.py | 70 +++++++++++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) 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 a8384087..69e1cc53 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_epic_agents.py @@ -5,7 +5,7 @@ from pathlib import Path from story_automator.core.frontmatter import extract_frontmatter, find_frontmatter_value, parse_frontmatter -from story_automator.core.runtime_policy import load_policy_shape_for_state, story_task_sequence +from story_automator.core.runtime_policy import PolicyError, load_policy_shape_for_state, story_task_sequence from story_automator.core.runtime_layout import runtime_provider from story_automator.core.sprint import sprint_status_epic from story_automator.core.story_keys import normalize_story_key @@ -117,8 +117,12 @@ 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"])) - policy = load_policy_shape_for_state(options["state-file"]) - tasks_in_scope = story_task_sequence(policy) + 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" 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 071a112c..0301a5b1 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -88,7 +88,7 @@ def cmd_build_state_doc(args: list[str]) -> int: try: config = json.loads(config_json) except json.JSONDecodeError: - write_json({"ok": False, "error": "missing_config"}) + write_json({"ok": False, "error": "invalid_config_json"}) return 1 ensure_dir(output_folder) now = now_utc_z() @@ -247,12 +247,16 @@ def cmd_build_state_doc(args: list[str]) -> int: def _normalize_string_list(value: Any) -> list[str]: if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] + 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 @@ -453,12 +457,15 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A track = str(config.get("workflowTrack") or "standard").strip().lower() if track not in {"standard", "tea"}: track = "standard" + notes = _normalize_string_list(config.get("policyNotes")) + if _normalize_option_list(config.get("manualCheckpoints")): + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") return { "policyOverride": explicit_override, "workflowTrack": track, - "selectedOptionalSteps": _normalize_string_list(config.get("selectedOptionalSteps")), - "manualCheckpoints": _normalize_string_list(config.get("manualCheckpoints")), - "notes": _normalize_string_list(config.get("policyNotes")), + "selectedOptionalSteps": _normalize_option_list(config.get("selectedOptionalSteps")), + "manualCheckpoints": [], + "notes": notes, } has_run_selection = any( @@ -476,8 +483,8 @@ def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, A track = str(config.get("workflowTrack") or "standard").strip().lower() if track not in {"standard", "tea"}: track = "standard" - selected = set(_normalize_string_list(config.get("selectedOptionalSteps"))) - manual = set(_normalize_string_list(config.get("manualCheckpoints"))) + selected = set(_normalize_option_list(config.get("selectedOptionalSteps"))) + manual = set(_normalize_option_list(config.get("manualCheckpoints"))) notes: list[str] = [] policy_override: dict[str, Any] = {} @@ -551,7 +558,7 @@ def cmd_build_run_policy(args: list[str]) -> int: try: config = json.loads(config_json) except json.JSONDecodeError: - write_json({"ok": False, "error": "missing_config"}) + write_json({"ok": False, "error": "invalid_config_json"}) return 1 selection = _build_run_policy(Path(get_project_root()), config) write_json({"ok": True, **selection}) 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 f9bded02..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,7 +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 load_policy_shape_for_state, story_task_sequence +from .runtime_policy import PolicyError, load_policy_shape_for_state, story_task_sequence from .runtime_layout import runtime_provider @@ -194,7 +194,10 @@ 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)) - tasks_in_scope = story_task_sequence(load_policy_shape_for_state(state_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" diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 32f27469..a07a006f 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 unreadable:", 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 unreadable:", payload["reason"]) + self.assertTrue(payload["reason"].endswith("missing.json")) + class StateDocModelSerializationTests(unittest.TestCase): def setUp(self) -> None: diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 98193409..b5301a10 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -829,6 +829,53 @@ def test_build_run_policy_normalizes_workflow_track_for_explicit_override(self) payload = json.loads(stdout.getvalue()) self.assertEqual(payload["workflowTrack"], "tea") + def test_build_run_policy_normalizes_selected_optional_steps_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "selectedOptionalSteps": ["NFR", "Retro", None], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + + def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "manualCheckpoints": ["checkpoint-preview"], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["manualCheckpoints"], []) + self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) + + def test_build_run_policy_distinguishes_invalid_json_from_missing_config(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "{"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "invalid_config_json") + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: self._install_tea_skills(canonical=True, write_assets=False) stdout = io.StringIO() @@ -854,6 +901,29 @@ def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: self.assertEqual(payload["selectedOptionalSteps"], []) self.assertTrue(any("TEA NFR skill is not installed" in note for note in payload["notes"])) + def test_build_run_policy_normalizes_selected_optional_steps_on_tea_track(self) -> None: + self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "selectedOptionalSteps": ["NFR", "Retro", None], + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual( + payload["policyOverride"]["workflow"]["sequence"], + ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], + ) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + def test_state_progress_updates_named_columns_in_tea_table(self) -> None: self._install_tea_skills(include_nfr=True) state_file = self._build_state( From a0a5f62807c93c1a1cd8dc8847be4f2fbdd6fbdf Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 15:27:40 +0100 Subject: [PATCH 15/33] fix: address latest PR review comments --- .../story_automator/commands/orchestrator.py | 3 ++ .../src/story_automator/commands/state.py | 3 ++ .../story_automator/core/runtime_policy.py | 6 ++- tests/test_runtime_policy.py | 11 ++++ tests/test_state_policy_metadata.py | 50 ++++++++++++++++++- 5 files changed, 71 insertions(+), 2 deletions(-) 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 dea78cf8..1837cb0e 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -564,6 +564,9 @@ def _state_progress(args: list[str]) -> int: header_map = {name: pos for pos, name in enumerate(headers)} applied: list[str] = [] for key, value in updates.items(): + if key == "story": + print_json({"ok": False, "error": "story_column_immutable"}) + return 1 pos = header_map.get(key) if pos is None: continue 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 0301a5b1..b0bee112 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -802,6 +802,9 @@ def cmd_state_metrics(args: list[str]) -> int: parts = [part.strip() for part in line.split("|")] values = [part for part in parts[1:-1] if part] if len(values) >= 2: + first_cell = values[0] + if re.fullmatch(r"-+", first_cell): + continue total += 1 if any(token in values[-1].lower() for token in ("done", "complete", "completed")): completed += 1 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 4e9d63e6..3b28ebb6 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 @@ -238,7 +238,11 @@ def bundled_skill_root(project_root: str | Path | None = None) -> Path: def _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[str, Any]: root = Path(project_root or get_project_root()).resolve() bundle_root = bundled_skill_root(root) - policy = _read_json(bundle_root / "data" / "orchestration-policy.json") + policy_path = bundle_root / "data" / "orchestration-policy.json" + try: + policy = _read_json(policy_path) + except OSError as exc: + raise PolicyError(f"policy unreadable: {policy_path}") from exc _validate_policy_shape(policy) _prune_unreferenced_steps(policy) return policy diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 5e4f72d0..249ad605 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -141,6 +141,17 @@ def test_malformed_override_json_raises_policy_error(self) -> None: with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) + def test_bundled_policy_read_failure_is_wrapped_as_policy_error(self) -> None: + policy_path = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "data" / "orchestration-policy.json" + policy_path.unlink() + policy_path.mkdir() + with patch( + "story_automator.core.runtime_policy.bundled_skill_root", + return_value=self.project_root / ".claude" / "skills" / "bmad-story-automator", + ): + with self.assertRaisesRegex(PolicyError, r"policy unreadable: .*orchestration-policy\.json"): + load_effective_policy(str(self.project_root)) + def test_invalid_assets_type_rejected(self) -> None: self._write_override({"steps": {"review": {"assets": []}}}) with self.assertRaises(PolicyError): diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index b5301a10..612f8a5e 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -10,7 +10,13 @@ from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper -from story_automator.commands.state import cmd_build_run_policy, cmd_build_state_doc, cmd_detect_workflow_track, cmd_validate_state +from story_automator.commands.state import ( + cmd_build_run_policy, + cmd_build_state_doc, + cmd_detect_workflow_track, + cmd_state_metrics, + cmd_validate_state, +) from story_automator.commands.tmux import _build_cmd, cmd_tmux_wrapper @@ -973,6 +979,48 @@ def test_state_progress_rejects_invalid_set_argument(self) -> None: 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_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") From 05f3ff077f63a1817e4544101d8512bd94f00019 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Mon, 25 May 2026 15:41:03 +0100 Subject: [PATCH 16/33] fix: tighten explicit policy detection --- .../src/story_automator/commands/state.py | 32 ++++++++++++++----- tests/test_state_policy_metadata.py | 23 +++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) 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 b0bee112..afb08711 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -560,24 +560,35 @@ def cmd_build_run_policy(args: list[str]) -> int: except json.JSONDecodeError: write_json({"ok": False, "error": "invalid_config_json"}) return 1 + if not isinstance(config, dict): + write_json({"ok": False, "error": "config_must_be_object"}) + return 1 selection = _build_run_policy(Path(get_project_root()), config) write_json({"ok": True, **selection}) return 0 -def _explicit_policy_payload(project_root: Path) -> dict[str, Any]: - override_path = project_root / "_bmad" / "bmm" / "story-automator.policy.json" +def _explicit_policy_path(project_root: Path) -> Path: + return project_root / "_bmad" / "bmm" / "story-automator.policy.json" + + +def _explicit_policy_payload(project_root: Path) -> tuple[dict[str, Any], str]: + override_path = _explicit_policy_path(project_root) if not override_path.is_file(): - return {} + return {}, "" try: payload = json.loads(read_text(override_path)) - except (OSError, json.JSONDecodeError): - return {} - return payload if isinstance(payload, dict) else {} + except OSError as exc: + return {}, f"explicit story-automator policy unreadable: {exc}" + except json.JSONDecodeError as exc: + return {}, f"explicit story-automator policy invalid JSON: {exc}" + if not isinstance(payload, dict): + return {}, "explicit story-automator policy must be a JSON object" + return payload, "" def _explicit_tea_steps(project_root: Path) -> list[str]: - payload = _explicit_policy_payload(project_root) + payload, _ = _explicit_policy_payload(project_root) sequence = ((payload.get("workflow") or {}).get("sequence")) or [] tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} return [step for step in sequence if isinstance(step, str) and step in tea_steps] @@ -684,6 +695,8 @@ def _resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[s def _detect_workflow_track(project_root: Path) -> dict[str, Any]: signals = _tea_project_signals(project_root) + explicit_override_present = _explicit_policy_path(project_root).is_file() + _, explicit_override_error = _explicit_policy_payload(project_root) explicit_steps = _explicit_tea_steps(project_root) explicit_policy = bool(explicit_steps) explicit_policy_resolved, explicit_policy_error = _explicit_tea_policy_details(project_root) @@ -710,6 +723,9 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") if explicit_policy_error: reasons.append(explicit_policy_error) + elif explicit_override_present and explicit_override_error: + reasons.append("Project defines a story-automator policy override, but it is invalid.") + reasons.append(explicit_override_error) elif tea_capable: recommended_track = "tea" requires_confirmation = True @@ -730,7 +746,7 @@ def _detect_workflow_track(project_root: Path) -> dict[str, Any]: "recommendedTrack": recommended_track, "requiresConfirmation": requires_confirmation, "prompt": prompt, - "teaDetected": explicit_policy or bool(signals), + "teaDetected": explicit_policy or explicit_override_present or bool(signals), "teaCapable": explicit_policy_valid if explicit_policy else tea_capable, "explicitTeaPolicy": explicit_policy, "signals": signals, diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 612f8a5e..8fb26b4f 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -693,6 +693,21 @@ def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract self.assertFalse(payload["teaCapable"]) self.assertTrue(any("workflow.sequence references missing step: atdd" in note for note in payload["reasons"])) + def test_detect_workflow_track_reports_invalid_explicit_override_file(self) -> None: + override_dir = self.project_root / "_bmad" / "bmm" + override_dir.mkdir(parents=True, exist_ok=True) + (override_dir / "story-automator.policy.json").write_text("{bad json", encoding="utf-8") + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_detect_workflow_track([]) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["recommendedTrack"], "standard") + self.assertFalse(payload["teaCapable"]) + self.assertTrue(payload["teaDetected"]) + self.assertTrue(any("story-automator policy override, but it is invalid" in note for note in payload["reasons"])) + self.assertTrue(any("invalid JSON" in note for note in payload["reasons"])) + def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(self) -> None: _write_tea_assets(self.project_root) override_dir = self.project_root / "_bmad" / "bmm" @@ -882,6 +897,14 @@ def test_build_run_policy_distinguishes_invalid_json_from_missing_config(self) - payload = json.loads(stdout.getvalue()) self.assertEqual(payload["error"], "invalid_config_json") + def test_build_run_policy_rejects_non_object_json(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "[]"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "config_must_be_object") + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: self._install_tea_skills(canonical=True, write_assets=False) stdout = io.StringIO() From 4118637de50866c893dde8a42a81f1575101e59a Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 26 May 2026 08:56:07 +0100 Subject: [PATCH 17/33] fix: harden PR review follow-ups --- .../story_automator/commands/orchestrator.py | 21 ++++++- .../story_automator/core/runtime_policy.py | 17 +++++- tests/test_agent_config_model.py | 4 +- tests/test_runtime_policy.py | 58 +++++++++++++++++++ tests/test_state_policy_metadata.py | 58 +++++++++++++++++++ 5 files changed, 151 insertions(+), 7 deletions(-) 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 1837cb0e..07420321 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -511,10 +511,17 @@ def _render_markdown_row(cells: list[str]) -> str: def _state_progress(args: list[str]) -> int: - if not args or not file_exists(args[0]): + if not args: print_json({"ok": False, "error": "file_not_found"}) return 1 state_file = args[0] + try: + if not file_exists(state_file): + print_json({"ok": False, "error": "file_not_found"}) + return 1 + except OSError: + print_json({"ok": False, "error": "state_file_unreadable"}) + return 1 story_id = "" updates: dict[str, str] = {} idx = 1 @@ -537,7 +544,11 @@ def _state_progress(args: list[str]) -> int: print_json({"ok": False, "error": "missing_story_or_updates"}) return 1 - lines = read_text(state_file).splitlines() + try: + lines = read_text(state_file).splitlines() + except OSError: + print_json({"ok": False, "error": "state_file_unreadable"}) + return 1 header_idx = -1 story_idx = -1 headers: list[str] = [] @@ -576,7 +587,11 @@ def _state_progress(args: list[str]) -> int: print_json({"ok": False, "error": "progress_columns_not_found"}) return 1 lines[story_idx] = _render_markdown_row(story_cells) - Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") + try: + Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") + except OSError: + print_json({"ok": False, "error": "state_file_unwritable"}) + return 1 print_json({"ok": True, "story": story_id, "updated": applied}) return 0 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 3b28ebb6..224154e4 100644 --- a/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py @@ -40,7 +40,10 @@ def load_effective_policy( 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 {} + try: + override = _read_json(override_path) if override_path.is_file() else {} + except OSError as exc: + raise PolicyError(f"project override unreadable: {override_path}") from exc policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) _validate_policy_shape(policy) @@ -101,7 +104,11 @@ def load_policy_snapshot( if not path.is_absolute(): path = root / path path = _ensure_within(path, root, "policy snapshot") - if not path.is_file(): + 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}") policy = _load_policy_snapshot_shape(path, expected_hash=expected_hash) if resolve_assets: @@ -145,6 +152,12 @@ def load_policy_shape_for_state(state_file: str | Path, project_root: str | None 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) diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index a07a006f..c728c895 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -254,7 +254,7 @@ def test_build_agents_file_returns_structured_policy_error(self) -> None: 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 unreadable:", result["reason"]) + self.assertIn("policy snapshot missing:", result["reason"]) self.assertTrue(result["reason"].endswith("missing.json")) @@ -310,7 +310,7 @@ def test_agents_build_returns_json_for_invalid_policy(self) -> None: payload = json.loads(stdout.getvalue()) self.assertFalse(payload["ok"]) self.assertEqual(payload["error"], "policy_invalid") - self.assertIn("policy snapshot unreadable:", payload["reason"]) + self.assertIn("policy snapshot missing:", payload["reason"]) self.assertTrue(payload["reason"].endswith("missing.json")) diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 249ad605..7fba1164 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -10,6 +10,7 @@ from story_automator.core.runtime_policy import ( PolicyError, load_effective_policy, + load_policy_shape_for_state, load_policy_snapshot, load_runtime_policy, snapshot_effective_policy, @@ -141,6 +142,23 @@ 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" + 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) == override_path: + raise OSError("permission denied") + return original_read_json(path) + + with patch("story_automator.core.runtime_policy._read_json", side_effect=raising_read_json): + with self.assertRaisesRegex(PolicyError, r"project override unreadable: .*story-automator\.policy\.json"): + load_effective_policy(str(self.project_root)) + def test_bundled_policy_read_failure_is_wrapped_as_policy_error(self) -> None: policy_path = self.project_root / ".claude" / "skills" / "bmad-story-automator" / "data" / "orchestration-policy.json" policy_path.unlink() @@ -312,6 +330,46 @@ def test_explicit_directory_state_file_raises_policy_error(self) -> None: with self.assertRaisesRegex(PolicyError, "state file unreadable"): load_runtime_policy(str(self.project_root), state_file=str(self.project_root)) + def test_load_policy_shape_for_state_reports_missing_snapshot_precisely(self) -> None: + state_file = self.project_root / "orchestration-missing-snapshot.md" + state_file.write_text( + "---\npolicySnapshotFile: \"missing.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(PolicyError, r"policy snapshot missing: .*missing\.json"): + load_policy_shape_for_state(str(state_file), project_root=str(self.project_root)) + + def test_load_policy_shape_for_state_wraps_snapshot_stat_errors(self) -> None: + state_file = self.project_root / "orchestration-unreadable-snapshot.md" + state_file.write_text( + "---\npolicySnapshotFile: \"blocked.json\"\npolicySnapshotHash: \"deadbeef\"\n---\n", + encoding="utf-8", + ) + blocked_path = (self.project_root / "blocked.json").resolve() + original_is_file = Path.is_file + + def raising_is_file(path: Path) -> bool: + if path.resolve() == blocked_path: + raise PermissionError("permission denied") + return original_is_file(path) + + with patch("pathlib.Path.is_file", autospec=True, side_effect=raising_is_file): + with self.assertRaisesRegex(PolicyError, r"policy snapshot unreadable: .*blocked\.json"): + load_policy_shape_for_state(str(state_file), project_root=str(self.project_root)) + + def test_load_policy_snapshot_wraps_snapshot_stat_errors(self) -> None: + blocked_path = (self.project_root / "blocked.json").resolve() + original_is_file = Path.is_file + + def raising_is_file(path: Path) -> bool: + if path.resolve() == blocked_path: + raise PermissionError("permission denied") + return original_is_file(path) + + with patch("pathlib.Path.is_file", autospec=True, side_effect=raising_is_file): + with self.assertRaisesRegex(PolicyError, r"policy snapshot unreadable: .*blocked\.json"): + load_policy_snapshot("blocked.json", project_root=str(self.project_root), expected_hash="deadbeef") + def _install_bundle(self) -> None: source_skill = REPO_ROOT / "skills" / "bmad-story-automator" source_review = REPO_ROOT / "skills" / "bmad-story-automator-review" diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 8fb26b4f..d0ae9722 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -7,6 +7,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from pathlib import Path +from unittest.mock import patch from story_automator.commands.orchestrator_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper @@ -1020,6 +1021,63 @@ def test_state_progress_rejects_story_column_updates(self) -> None: payload = json.loads(stdout.getvalue()) self.assertEqual(payload["error"], "story_column_immutable") + def test_state_progress_returns_structured_error_when_state_file_is_unreadable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("story_automator.commands.orchestrator.read_text", side_effect=OSError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unreadable") + + def test_state_progress_returns_structured_error_when_state_file_stat_is_unreadable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("story_automator.commands.orchestrator.file_exists", side_effect=PermissionError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unreadable") + + def test_state_progress_returns_structured_error_when_state_file_is_unwritable(self) -> None: + state_file = self._build_state() + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + with patch("pathlib.Path.write_text", side_effect=OSError("permission denied")): + code = cmd_orchestrator_helper( + [ + "state-progress", + str(state_file), + "--story", + "1.1", + "--set", + "status=done", + ] + ) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "state_file_unwritable") + def test_state_metrics_skips_markdown_divider_row(self) -> None: state_file = self.project_root / "metrics-state.md" state_file.write_text( From e4fd9751f37bea07d204e2b485624382d2d7b6e4 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 26 May 2026 09:02:32 +0100 Subject: [PATCH 18/33] test: stabilize unreadable override regression --- tests/test_runtime_policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 7fba1164..33f66697 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -145,13 +145,13 @@ def test_malformed_override_json_raises_policy_error(self) -> None: 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" + 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) == override_path: + if Path(path).resolve() == override_path: raise OSError("permission denied") return original_read_json(path) From ecb5ec6951f56b8c6297cfc52719e7a061ff022d Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 26 May 2026 10:44:31 +0100 Subject: [PATCH 19/33] Refactor TEA policy and state document handling --- .../data/orchestration-policy.json | 2 +- .../story_automator/commands/orchestrator.py | 115 +-- .../src/story_automator/commands/state.py | 596 +---------- .../story_automator/core/runtime_policy.py | 35 +- .../story_automator/core/state_document.py | 156 +++ .../src/story_automator/core/tea_policy.py | 503 +++++++++ .../steps-c/step-02a-preflight-config.md | 3 +- .../steps-c/step-03-execute.md | 9 +- .../steps-c/step-03a-execute-review.md | 9 +- .../steps-c/step-03b-execute-finish.md | 4 + tests/tea_test_support.py | 258 +++++ tests/test_orchestrator_progress.py | 146 +++ tests/test_runtime_policy.py | 28 +- tests/test_state_policy_metadata.py | 962 +----------------- tests/test_tea_detection.py | 266 +++++ tests/test_tea_policy_flow.py | 167 +++ tests/test_tea_state_rendering.py | 434 ++++++++ 17 files changed, 2087 insertions(+), 1606 deletions(-) create mode 100644 skills/bmad-story-automator/src/story_automator/core/state_document.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/tea_policy.py create mode 100644 tests/tea_test_support.py create mode 100644 tests/test_orchestrator_progress.py create mode 100644 tests/test_tea_detection.py create mode 100644 tests/test_tea_policy_flow.py create mode 100644 tests/test_tea_state_rendering.py diff --git a/skills/bmad-story-automator/data/orchestration-policy.json b/skills/bmad-story-automator/data/orchestration-policy.json index aa3cd607..cada9bab 100644 --- a/skills/bmad-story-automator/data/orchestration-policy.json +++ b/skills/bmad-story-automator/data/orchestration-policy.json @@ -77,7 +77,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/src/story_automator/commands/orchestrator.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py index 07420321..0cb2107e 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,9 @@ load_runtime_policy, review_max_cycles, summarize_state_policy_fields, + workflow_sequence, ) +from story_automator.core.state_document import update_story_progress 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 from story_automator.core.success_verifiers import resolve_success_contract, run_success_verifier @@ -75,6 +77,7 @@ 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, } handler = dispatch.get(action) if handler is None: @@ -114,6 +117,7 @@ def _usage(code: int) -> int: 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 STEP_NAME", file=target) print(" retro-agent --state-file path", file=target) + print(" policy-sequence [--state-file path]", file=target) return code @@ -359,6 +363,28 @@ def _escalate(args: list[str]) -> int: return 0 +def _policy_sequence(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 _commit_ready(args: list[str]) -> int: if not args: print_json({"ready": False, "reason": "story_id required"}) @@ -477,39 +503,6 @@ def _verify_step(args: list[str]) -> int: return exit_code -def _normalize_progress_key(value: str) -> str: - key = str(value or "").strip().lower().replace("_", "-") - aliases = { - "create": "create-story", - "dev": "dev-story", - "auto": "automate", - "review": "code-review", - "test-automate": "test-automate", - "test-review": "test-review", - "git_commit": "git-commit", - "git-commit": "git-commit", - "status": "status", - "story": "story", - "create-story": "create-story", - "dev-story": "dev-story", - "automate": "automate", - "code-review": "code-review", - "atdd": "atdd", - "nfr": "nfr", - "trace": "trace", - } - return aliases.get(key, key) - - -def _parse_markdown_cells(line: str) -> list[str]: - parts = [part.strip() for part in line.split("|")] - return [part for part in parts[1:-1]] - - -def _render_markdown_row(cells: list[str]) -> str: - return "| " + " | ".join(cells) + " |" - - def _state_progress(args: list[str]) -> int: if not args: print_json({"ok": False, "error": "file_not_found"}) @@ -536,7 +529,7 @@ def _state_progress(args: list[str]) -> int: print_json({"ok": False, "error": "invalid_set_argument", "argument": raw_update}) return 1 key, value = raw_update.split("=", 1) - updates[_normalize_progress_key(key)] = value + updates[key] = value idx += 2 continue idx += 1 @@ -545,54 +538,14 @@ def _state_progress(args: list[str]) -> int: return 1 try: - lines = read_text(state_file).splitlines() - except OSError: - print_json({"ok": False, "error": "state_file_unreadable"}) - return 1 - header_idx = -1 - story_idx = -1 - headers: list[str] = [] - story_cells: list[str] = [] - for i, line in enumerate(lines): - if line.startswith("| Story "): - header_idx = i - headers = [_normalize_progress_key(cell) for cell in _parse_markdown_cells(line)] - continue - if header_idx >= 0 and line.startswith(f"| {story_id} |"): - story_idx = i - story_cells = _parse_markdown_cells(line) - break - if header_idx < 0 or not headers: - print_json({"ok": False, "error": "progress_table_not_found"}) - return 1 - if story_idx < 0 or not story_cells: - print_json({"ok": False, "error": "story_row_not_found"}) - return 1 - if len(story_cells) != len(headers): - print_json({"ok": False, "error": "progress_row_misaligned"}) - return 1 - - header_map = {name: pos for pos, name in enumerate(headers)} - applied: list[str] = [] - for key, value in updates.items(): - if key == "story": - print_json({"ok": False, "error": "story_column_immutable"}) - return 1 - pos = header_map.get(key) - if pos is None: - continue - story_cells[pos] = value - applied.append(key) - if not applied: - print_json({"ok": False, "error": "progress_columns_not_found"}) - return 1 - lines[story_idx] = _render_markdown_row(story_cells) - try: - Path(state_file).write_text("\n".join(lines) + "\n", encoding="utf-8") - except OSError: - print_json({"ok": False, "error": "state_file_unwritable"}) + policy = load_runtime_policy(get_project_root(), state_file=state_file, resolve_assets=False) + except (FileNotFoundError, PolicyError): + policy = None + ok, payload = update_story_progress(state_file, story_id, updates, policy=policy) + if not ok: + print_json(payload) return 1 - print_json({"ok": True, "story": story_id, "updated": applied}) + print_json(payload) return 0 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 afb08711..1da4416d 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,63 +6,13 @@ from typing import Any from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter -from ..core.runtime_layout import bundled_story_skill_root, resolve_skill_dir -from ..core.runtime_policy import PolicyError, load_effective_policy, load_policy_for_state, snapshot_effective_policy +from ..core.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 -STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] -TEA_SKILL_ALIASES = { - "atdd": ("bmad-testarch-atdd", "bmad-tea-testarch-atdd"), - "test_automate": ("bmad-testarch-automate", "bmad-tea-testarch-automate"), - "test_review": ("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), - "trace": ("bmad-testarch-trace", "bmad-tea-testarch-trace"), - "nfr": ("bmad-testarch-nfr", "bmad-tea-testarch-nfr"), -} - -STEP_DISPLAY_NAMES = { - "create": "create-story", - "dev": "dev-story", - "auto": "automate", - "review": "code-review", - "atdd": "atdd", - "test_automate": "test-automate", - "test_review": "test-review", - "nfr": "nfr", - "trace": "trace", -} - - -def _story_progress_steps(policy: dict[str, Any]) -> list[str]: - sequence = ((policy.get("workflow") or {}).get("sequence")) or [] - return [str(step) for step in sequence if isinstance(step, str) and step and step != "retro"] - - -def _progress_headers(steps: list[str]) -> list[str]: - headers = ["Story"] - headers.extend(STEP_DISPLAY_NAMES.get(step, step.replace("_", "-")) for step in steps) - headers.extend(["git-commit", "Status"]) - return headers - - -def _markdown_divider(width: int) -> list[str]: - return ["-------" if idx == 0 else "----------" for idx in range(width)] - - -def _progress_table_lines(policy: dict[str, Any], story_range: list[str]) -> tuple[str, str, str]: - steps = _story_progress_steps(policy) - headers = _progress_headers(steps) - divider = _markdown_divider(len(headers)) - pending_cells = ["⏳"] * len(steps) + ["⏳", "pending"] - rows = "\n".join("| " + " | ".join([story_id, *pending_cells]) + " |" for story_id in story_range) - return ( - "| " + " | ".join(headers) + " |", - "| " + " | ".join(divider) + " |", - rows, - ) - - def cmd_build_state_doc(args: list[str]) -> int: template = "" output_folder = "" @@ -81,7 +31,11 @@ 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 @@ -96,13 +50,13 @@ def cmd_build_state_doc(args: list[str]) -> int: epic = str(config.get("epic") or "epic") safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic" output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md" - policy_selection = _build_run_policy(Path(get_project_root()), config) try: + 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 - progress_header, progress_divider, progress_rows = _progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) + progress_header, progress_divider, progress_rows = progress_table_lines(snapshot["policy"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) text = read_text(template) replacements: dict[str, Any] = { "epic": config.get("epic", ""), @@ -245,303 +199,6 @@ def cmd_build_state_doc(args: list[str]) -> int: return 0 -def _normalize_string_list(value: Any) -> list[str]: - if isinstance(value, list): - return [str(item).strip() for item in value if item is not None and str(item).strip()] - if isinstance(value, str) and value.strip(): - return [part.strip() for part in value.split(",") if part.strip()] - return [] - - -def _normalize_option_list(value: Any) -> list[str]: - return [item.lower() for item in _normalize_string_list(value)] - - -def _as_bool(value: Any, default: bool = False) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in {"1", "true", "yes", "y", "on"}: - return True - if lowered in {"0", "false", "no", "n", "off"}: - return False - return default - - -def _tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: - configured = str(config.get("teaAssetsRoot") or "").strip() - if configured: - return configured.rstrip("/") - return _tea_detected_assets_root(project_root) - - -def _tea_asset_root_candidates(project_root: Path) -> list[str]: - candidates = [ - "_bmad/tea/story-automator", - "docs/plans/tea-story-automator/assets", - "data/tea-story-automator", - ] - unique: list[str] = [] - for candidate in candidates: - if candidate not in unique: - unique.append(candidate) - return unique - - -def _tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: - raw = Path(assets_root) - candidates: list[Path] = [] - if raw.is_absolute(): - candidates.append(raw.resolve()) - else: - candidates.append((project_root / raw).resolve()) - try: - bundle_root = bundled_story_skill_root(project_root) - candidates.append((bundle_root / raw).resolve()) - except FileNotFoundError: - pass - for candidate in candidates: - if candidate.exists(): - return candidate - return candidates[0] if candidates else None - - -def _tea_assets_complete_for_base(base: Path | None) -> bool: - if base is None or not base.exists(): - return False - if (base / "prompts" / "tea_step.md").is_file() and (base / "parse" / "tea_step.json").is_file(): - return True - required = [ - base / "prompts" / "atdd.md", - base / "prompts" / "test_automate.md", - base / "prompts" / "test_review.md", - base / "prompts" / "trace.md", - base / "parse" / "atdd.json", - base / "parse" / "test_automate.json", - base / "parse" / "test_review.json", - base / "parse" / "trace.json", - ] - return all(path.is_file() for path in required) - - -def _tea_detected_assets_root(project_root: Path) -> str: - for assets_root in _tea_asset_root_candidates(project_root): - if _tea_assets_complete_for_base(_tea_assets_base_path(project_root, assets_root)): - return assets_root - return "data/tea-story-automator" - - -def _tea_contract_files(project_root: Path, assets_root: str, step: str) -> tuple[str, str]: - base = _tea_assets_base_path(project_root, assets_root) - root = assets_root.rstrip("/") - generic_prompt = f"{root}/prompts/tea_step.md" - generic_schema = f"{root}/parse/tea_step.json" - if base is None: - return generic_prompt, generic_schema - if (base / "prompts" / f"{step}.md").is_file() and (base / "parse" / f"{step}.json").is_file(): - return f"{root}/prompts/{step}.md", f"{root}/parse/{step}.json" - return generic_prompt, generic_schema - - -def _resolve_tea_skill_name(project_root: Path, step: str) -> str: - candidates = TEA_SKILL_ALIASES.get(step, ()) - for skill_name in candidates: - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - continue - if file_exists(str(skill_dir / "SKILL.md")): - return skill_name - return candidates[0] if candidates else "" - - -def _tea_skill_installed(project_root: Path, step: str) -> bool: - candidates = TEA_SKILL_ALIASES.get(step, ()) - for skill_name in candidates: - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - continue - if file_exists(str(skill_dir / "SKILL.md")): - return True - return False - - -def _tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: - root = assets_root.rstrip("/") - atdd_prompt, atdd_schema = _tea_contract_files(project_root, assets_root, "atdd") - automate_prompt, automate_schema = _tea_contract_files(project_root, assets_root, "test_automate") - review_prompt, review_schema = _tea_contract_files(project_root, assets_root, "test_review") - trace_prompt, trace_schema = _tea_contract_files(project_root, assets_root, "trace") - steps: dict[str, Any] = { - "atdd": { - "label": "atdd", - "assets": { - "skillName": _resolve_tea_skill_name(project_root, "atdd"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": atdd_prompt, "interactionMode": "autonomous"}, - "parse": {"schemaFile": atdd_schema}, - "success": {"verifier": "session_exit"}, - }, - "test_automate": { - "label": "test-automate", - "assets": { - "skillName": _resolve_tea_skill_name(project_root, "test_automate"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": automate_prompt, "interactionMode": "autonomous"}, - "parse": {"schemaFile": automate_schema}, - "success": {"verifier": "session_exit"}, - }, - "test_review": { - "label": "test-review", - "assets": { - "skillName": _resolve_tea_skill_name(project_root, "test_review"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": review_prompt, "interactionMode": "autonomous"}, - "parse": {"schemaFile": review_schema}, - "success": {"verifier": "session_exit"}, - }, - "trace": { - "label": "trace", - "assets": { - "skillName": _resolve_tea_skill_name(project_root, "trace"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": trace_prompt, "interactionMode": "autonomous"}, - "parse": {"schemaFile": trace_schema}, - "success": {"verifier": "session_exit"}, - }, - } - if include_nfr: - nfr_prompt, nfr_schema = _tea_contract_files(project_root, assets_root, "nfr") - steps["nfr"] = { - "label": "nfr", - "assets": { - "skillName": _resolve_tea_skill_name(project_root, "nfr"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": nfr_prompt, "interactionMode": "autonomous"}, - "parse": {"schemaFile": nfr_schema}, - "success": {"verifier": "session_exit"}, - } - return steps - - -def _build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: - explicit_override = config.get("policyOverride") - if isinstance(explicit_override, dict): - track = str(config.get("workflowTrack") or "standard").strip().lower() - if track not in {"standard", "tea"}: - track = "standard" - notes = _normalize_string_list(config.get("policyNotes")) - if _normalize_option_list(config.get("manualCheckpoints")): - notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") - return { - "policyOverride": explicit_override, - "workflowTrack": track, - "selectedOptionalSteps": _normalize_option_list(config.get("selectedOptionalSteps")), - "manualCheckpoints": [], - "notes": notes, - } - - has_run_selection = any( - key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") - ) - if not has_run_selection: - return { - "policyOverride": {"workflow": {"sequence": list(STANDARD_SEQUENCE)}}, - "workflowTrack": "standard", - "selectedOptionalSteps": [], - "manualCheckpoints": [], - "notes": [], - } - - track = str(config.get("workflowTrack") or "standard").strip().lower() - if track not in {"standard", "tea"}: - track = "standard" - selected = set(_normalize_option_list(config.get("selectedOptionalSteps"))) - manual = set(_normalize_option_list(config.get("manualCheckpoints"))) - notes: list[str] = [] - policy_override: dict[str, Any] = {} - - if track == "tea": - assets_root = _tea_assets_root(project_root, config) - include_nfr = "nfr" in selected - if include_nfr and not _tea_skill_installed(project_root, "nfr"): - notes.append("nfr was requested on the TEA track, but the TEA NFR skill is not installed, so it was ignored.") - include_nfr = False - selected.discard("nfr") - include_retro = "retro" in selected - if "validate-create-story" in selected: - notes.append("validate-create-story remains an advisory pre-dev quality check and is not yet automated by story-automator.") - if "qa-generate-e2e-tests" in selected: - notes.append("qa-generate-e2e-tests is superseded by TEA test_automate on the TEA track and was ignored.") - sequence = ["create", "atdd", "dev", "test_automate", "test_review"] - if include_nfr: - sequence.append("nfr") - sequence.extend(["trace", "review"]) - if include_retro: - sequence.append("retro") - policy_override = { - "workflow": {"sequence": sequence}, - "steps": _tea_step_contracts(project_root, assets_root, include_nfr=include_nfr), - } - selected = {"nfr" if include_nfr else "", "retro" if include_retro else ""} - selected.discard("") - else: - include_retro = "retro" in selected if "retro" in selected else _as_bool(config.get("includeRetro"), True) - sequence = ["create", "dev", "auto", "review"] - if include_retro: - sequence.append("retro") - selected.add("retro") - else: - selected.discard("retro") - for unsupported in sorted(selected & {"nfr"}): - notes.append("nfr is only available on the TEA track and was ignored for the standard workflow.") - selected.discard(unsupported) - if "validate-create-story" in selected: - notes.append("validate-create-story is not yet an automated story-automator step and was recorded as advisory only.") - selected.discard("validate-create-story") - if "qa-generate-e2e-tests" in selected: - notes.append("qa-generate-e2e-tests is already represented by the standard auto step; use skipAutomate to disable it.") - selected.discard("qa-generate-e2e-tests") - policy_override = {"workflow": {"sequence": sequence}} - - if manual: - notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") - return { - "policyOverride": policy_override, - "workflowTrack": track, - "selectedOptionalSteps": sorted(selected), - "manualCheckpoints": [], - "notes": notes, - } - - def cmd_build_run_policy(args: list[str]) -> int: config_file = "" config_json = "" @@ -551,7 +208,11 @@ def cmd_build_run_policy(args: list[str]) -> int: elif arg == "--config-json" and idx + 1 < len(args): config_json = args[idx + 1] if config_file and file_exists(config_file): - config_json = read_text(config_file) + 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 @@ -563,207 +224,19 @@ def cmd_build_run_policy(args: list[str]) -> int: if not isinstance(config, dict): write_json({"ok": False, "error": "config_must_be_object"}) return 1 - selection = _build_run_policy(Path(get_project_root()), config) - write_json({"ok": True, **selection}) - return 0 - - -def _explicit_policy_path(project_root: Path) -> Path: - return project_root / "_bmad" / "bmm" / "story-automator.policy.json" - - -def _explicit_policy_payload(project_root: Path) -> tuple[dict[str, Any], str]: - override_path = _explicit_policy_path(project_root) - if not override_path.is_file(): - return {}, "" try: - payload = json.loads(read_text(override_path)) - except OSError as exc: - return {}, f"explicit story-automator policy unreadable: {exc}" - except json.JSONDecodeError as exc: - return {}, f"explicit story-automator policy invalid JSON: {exc}" - if not isinstance(payload, dict): - return {}, "explicit story-automator policy must be a JSON object" - return payload, "" - - -def _explicit_tea_steps(project_root: Path) -> list[str]: - payload, _ = _explicit_policy_payload(project_root) - sequence = ((payload.get("workflow") or {}).get("sequence")) or [] - tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} - return [step for step in sequence if isinstance(step, str) and step in tea_steps] - - -def _has_explicit_tea_policy(project_root: Path) -> bool: - return bool(_explicit_tea_steps(project_root)) - - -def _explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | None, str]: - if not _has_explicit_tea_policy(project_root): - return None, "" - try: - return load_effective_policy(str(project_root), resolve_assets=True), "" + selection = build_run_policy(Path(get_project_root()), config) except (FileNotFoundError, PolicyError, ValueError) as exc: - return None, str(exc) - - -def _explicit_tea_policy_valid(project_root: Path) -> tuple[bool, str]: - policy, error = _explicit_tea_policy_details(project_root) - return policy is not None, error - - -def _tea_detection_assets_root(project_root: Path) -> str: - return _tea_detected_assets_root(project_root) - - -def _tea_assets_complete(project_root: Path, assets_root: str) -> tuple[bool, list[str]]: - if not assets_root: - return False, ["missing TEA story-automator assets root"] - base = _tea_assets_base_path(project_root, assets_root) - if base is None or not base.exists(): - return False, ["missing TEA story-automator assets root"] - if _tea_assets_complete_for_base(base): - return True, [] - required = [ - base / "prompts" / "atdd.md", - base / "prompts" / "test_automate.md", - base / "prompts" / "test_review.md", - base / "prompts" / "trace.md", - base / "parse" / "atdd.json", - base / "parse" / "test_automate.json", - base / "parse" / "test_review.json", - base / "parse" / "trace.json", - ] - missing = [str(path) for path in required if not path.is_file()] - return False, missing - - -def _tea_project_signals(project_root: Path) -> list[str]: - signals: list[str] = [] - checks = { - "_bmad/tea/config.yaml": project_root / "_bmad" / "tea" / "config.yaml", - "_bmad/tea/module-help.csv": project_root / "_bmad" / "tea" / "module-help.csv", - "_bmad/tea/workflows/testarch": project_root / "_bmad" / "tea" / "workflows" / "testarch", - "_bmad/tea/story-automator": project_root / "_bmad" / "tea" / "story-automator", - } - for label, path in checks.items(): - if path.exists(): - signals.append(label) - return signals - - -def _tea_skill_availability(project_root: Path, required_steps: list[str] | None = None) -> tuple[list[str], list[str]]: - available: list[str] = [] - missing: list[str] = [] - for step in (required_steps or ["atdd", "test_automate", "test_review", "trace"]): - skill_name = _resolve_tea_skill_name(project_root, step) - if not skill_name: - missing.append(TEA_SKILL_ALIASES[step][0]) - continue - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - missing.append(TEA_SKILL_ALIASES[step][0]) - continue - if file_exists(str(skill_dir / "SKILL.md")): - available.append(skill_name) - else: - missing.append(TEA_SKILL_ALIASES[step][0]) - return available, missing - - -def _resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[str]) -> tuple[list[str], str]: - available: list[str] = [] - asset_roots: list[str] = [] - steps = policy.get("steps") or {} - for step in required_steps: - if not isinstance(steps.get(step), dict): - continue - contract = steps[step] - assets = contract.get("assets") or {} - skill_name = str(assets.get("skillName") or "").strip() - if skill_name: - available.append(skill_name) - prompt = contract.get("prompt") or {} - template_file = str(prompt.get("templateFile") or "").strip() - if template_file: - root = str(Path(template_file).parent.parent).replace("\\", "/") - if root and root not in asset_roots: - asset_roots.append(root) - return available, ", ".join(asset_roots) - - -def _detect_workflow_track(project_root: Path) -> dict[str, Any]: - signals = _tea_project_signals(project_root) - explicit_override_present = _explicit_policy_path(project_root).is_file() - _, explicit_override_error = _explicit_policy_payload(project_root) - explicit_steps = _explicit_tea_steps(project_root) - explicit_policy = bool(explicit_steps) - explicit_policy_resolved, explicit_policy_error = _explicit_tea_policy_details(project_root) - explicit_policy_valid = explicit_policy_resolved is not None - if explicit_policy and explicit_policy_valid: - available_skills, assets_root = _resolved_explicit_tea_status(explicit_policy_resolved, explicit_steps) - missing_skills = [] - missing_assets = [] - assets_ok = True - else: - assets_root = _tea_detection_assets_root(project_root) - assets_ok, missing_assets = _tea_assets_complete(project_root, assets_root) - available_skills, missing_skills = _tea_skill_availability(project_root, explicit_steps or None) - reasons: list[str] = [] - prompt = "" - recommended_track = "standard" - requires_confirmation = False - tea_capable = bool(signals) and assets_ok and not missing_skills - - if explicit_policy and explicit_policy_valid: - recommended_track = "tea" - reasons.append("Project already defines an explicit TEA story-automator policy override.") - elif explicit_policy: - reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") - if explicit_policy_error: - reasons.append(explicit_policy_error) - elif explicit_override_present and explicit_override_error: - reasons.append("Project defines a story-automator policy override, but it is invalid.") - reasons.append(explicit_override_error) - elif tea_capable: - recommended_track = "tea" - requires_confirmation = True - reasons.append("Detected TEA module files in the project.") - reasons.append("Required TEA skills are installed.") - reasons.append("TEA story-automator assets are available.") - prompt = "Detected TEA support for this project. Enable TEA automation for this run? [y/N]" - else: - if signals: - reasons.append("Detected TEA-related project files.") - if missing_skills: - reasons.append("Required TEA skills are missing, so TEA automation is not currently available.") - if missing_assets: - reasons.append("TEA story-automator assets are incomplete or missing.") - - return { - "ok": True, - "recommendedTrack": recommended_track, - "requiresConfirmation": requires_confirmation, - "prompt": prompt, - "teaDetected": explicit_policy or explicit_override_present or bool(signals), - "teaCapable": explicit_policy_valid if explicit_policy else tea_capable, - "explicitTeaPolicy": explicit_policy, - "signals": signals, - "availableSkills": available_skills, - "missingSkills": missing_skills, - "assetsRoot": assets_root, - "missingAssets": missing_assets, - "reasons": reasons, - } - - + write_json({"ok": False, "error": "policy_invalid", "reason": str(exc)}) + return 1 + write_json({"ok": True, **selection}) + return 0 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)) + write_json(detect_workflow_track(project_root)) return 0 @@ -805,34 +278,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("|")] - values = [part for part in parts[1:-1] if part] - if len(values) >= 2: - first_cell = values[0] - if re.fullmatch(r"-+", first_cell): - continue - total += 1 - if any(token in values[-1].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/runtime_policy.py b/skills/bmad-story-automator/src/story_automator/core/runtime_policy.py index 224154e4..32760c2f 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 @@ -41,13 +41,18 @@ def load_effective_policy( bundled = load_bundled_policy(str(root), resolve_assets=False) override_path = root / "_bmad" / "bmm" / "story-automator.policy.json" try: - override = _read_json(override_path) if override_path.is_file() else {} + 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 policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) - _validate_policy_shape(policy) + _validate_policy_step_references(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)) @@ -256,8 +261,9 @@ def _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[s policy = _read_json(policy_path) except OSError as exc: raise PolicyError(f"policy unreadable: {policy_path}") from exc - _validate_policy_shape(policy) + _validate_policy_step_references(policy) _prune_unreferenced_steps(policy) + _validate_policy_shape(policy) return policy @@ -273,8 +279,9 @@ def _load_policy_snapshot_shape(path: Path, *, expected_hash: str = "") -> dict[ policy = json.loads(raw) except json.JSONDecodeError as exc: raise PolicyError(f"policy json invalid: {path}") from exc - _validate_policy_shape(policy) + _validate_policy_step_references(policy) _prune_unreferenced_steps(policy) + _validate_policy_shape(policy) return policy @@ -288,6 +295,13 @@ def _read_json(path: str | Path) -> dict[str, Any]: 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) @@ -401,6 +415,19 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: raise PolicyError(f"invalid required assets for {name}") +def _validate_policy_step_references(policy: dict[str, Any]) -> None: + workflow = _expect_optional_dict(policy, "workflow") + steps = policy.get("steps") + if not isinstance(steps, dict): + raise PolicyError("steps must be an object") + 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") + for step in sequence: + if step not in steps: + raise PolicyError(f"workflow.sequence references missing step: {step}") + + 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", {}) 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..75ffadf5 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/state_document.py @@ -0,0 +1,156 @@ +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] = [] + 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: + continue + story_cells[pos] = sanitized + applied.append(normalized_key) + 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 = [part for part in parts[1:-1] if part] + if len(values) >= 2: + first_cell = values[0] + if re.fullmatch(r"-+", first_cell): + continue + total += 1 + if any(token in values[-1].lower() for token 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..db2f0f26 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .runtime_layout import bundled_story_skill_root, resolve_skill_dir +from .runtime_policy import PolicyError, load_effective_policy +from .utils import file_exists, read_text + + +STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] +TEA_SKILL_ALIASES = { + "atdd": ("bmad-testarch-atdd", "bmad-tea-testarch-atdd"), + "test_automate": ("bmad-testarch-automate", "bmad-tea-testarch-automate"), + "test_review": ("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), + "trace": ("bmad-testarch-trace", "bmad-tea-testarch-trace"), + "nfr": ("bmad-testarch-nfr", "bmad-tea-testarch-nfr"), +} +TEA_TRACK_DEFINITION = { + "coreSequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], + "optionalSteps": {"nfr", "retro"}, +} + + +def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: + explicit_override = config.get("policyOverride") + if isinstance(explicit_override, dict): + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" + notes = _normalize_string_list(config.get("policyNotes")) + if _normalize_option_list(config.get("manualCheckpoints")): + notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") + return { + "policyOverride": explicit_override, + "workflowTrack": track, + "selectedOptionalSteps": _normalize_option_list(config.get("selectedOptionalSteps")), + "manualCheckpoints": [], + "notes": notes, + } + + has_run_selection = any( + key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") + ) + if not has_run_selection: + return { + "policyOverride": {"workflow": {"sequence": list(STANDARD_SEQUENCE)}}, + "workflowTrack": "standard", + "selectedOptionalSteps": [], + "manualCheckpoints": [], + "notes": [], + } + + track = str(config.get("workflowTrack") or "standard").strip().lower() + if track not in {"standard", "tea"}: + track = "standard" + selected = set(_normalize_option_list(config.get("selectedOptionalSteps"))) + manual = set(_normalize_option_list(config.get("manualCheckpoints"))) + notes: list[str] = [] + policy_override: dict[str, Any] = {} + + if track == "tea": + explicit_policy_resolved, explicit_policy_error = explicit_tea_policy_details(project_root) + if explicit_policy_resolved is not None: + explicit_sequence = ((explicit_policy_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")): + 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, + } + if has_explicit_tea_policy(project_root): + raise PolicyError(explicit_policy_error or "explicit TEA story-automator policy is invalid") + + assets_root = tea_assets_root(project_root, config) + include_nfr = "nfr" in selected + if include_nfr and not tea_skill_installed(project_root, "nfr"): + notes.append("nfr was requested on the TEA track, but the TEA NFR skill is not installed, so it was ignored.") + include_nfr = False + selected.discard("nfr") + include_retro = "retro" in selected + if "validate-create-story" in selected: + notes.append("validate-create-story remains an advisory pre-dev quality check and is not yet automated by story-automator.") + if "qa-generate-e2e-tests" in selected: + notes.append("qa-generate-e2e-tests is superseded by TEA test_automate on the TEA track and was ignored.") + + sequence = list(TEA_TRACK_DEFINITION["coreSequence"][:-2]) + if include_nfr: + sequence.append("nfr") + sequence.extend(TEA_TRACK_DEFINITION["coreSequence"][-2:]) + if include_retro: + sequence.append("retro") + policy_override = { + "workflow": {"sequence": sequence}, + "steps": tea_step_contracts(project_root, assets_root, include_nfr=include_nfr), + } + selected = {"nfr" if include_nfr else "", "retro" if include_retro else ""} + selected.discard("") + else: + include_retro = "retro" in selected if "retro" in selected else _as_bool(config.get("includeRetro"), True) + sequence = 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=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_error = explicit_policy_payload(project_root) + explicit_steps = explicit_tea_steps(project_root) + explicit_policy = bool(explicit_steps) + explicit_policy_resolved, explicit_policy_error = explicit_tea_policy_details(project_root) + explicit_policy_valid = explicit_policy_resolved is not None + if explicit_policy and explicit_policy_valid: + available_skills, assets_root = resolved_explicit_tea_status(explicit_policy_resolved, explicit_steps) + missing_skills = [] + missing_assets = [] + assets_ok = True + else: + assets_root = tea_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_policy_error: + reasons.append(explicit_policy_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 selected_optional_steps_from_sequence(sequence: list[str]) -> list[str]: + selected: list[str] = [] + if "nfr" in sequence: + selected.append("nfr") + if "retro" in sequence: + selected.append("retro") + return selected + + +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_tea_steps(project_root: Path) -> list[str]: + payload, _ = explicit_policy_payload(project_root) + sequence = ((payload.get("workflow") or {}).get("sequence")) or [] + tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} + return [step for step in sequence if isinstance(step, str) and step in tea_steps] + + +def has_explicit_tea_policy(project_root: Path) -> bool: + return bool(explicit_tea_steps(project_root)) + + +def explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | None, str]: + if not has_explicit_tea_policy(project_root): + return None, "" + try: + return load_effective_policy(str(project_root), resolve_assets=True), "" + except (FileNotFoundError, PolicyError, ValueError) as exc: + return None, str(exc) + + +def tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: + configured = str(config.get("teaAssetsRoot") or "").strip() + if configured: + return configured.rstrip("/") + return tea_detected_assets_root(project_root) + + +def tea_asset_root_candidates(project_root: Path) -> list[str]: + candidates = [ + "_bmad/tea/story-automator", + "docs/plans/tea-story-automator/assets", + "data/tea-story-automator", + ] + unique: list[str] = [] + for candidate in candidates: + if candidate not in unique: + unique.append(candidate) + return unique + + +def tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: + raw = Path(assets_root) + candidates: list[Path] = [] + if raw.is_absolute(): + candidates.append(raw.resolve()) + else: + candidates.append((project_root / raw).resolve()) + try: + bundle_root = bundled_story_skill_root(project_root) + candidates.append((bundle_root / raw).resolve()) + except FileNotFoundError: + pass + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] if candidates else None + + +def tea_assets_complete_for_base(base: Path | None) -> bool: + if base is None or not base.exists(): + return False + if (base / "prompts" / "tea_step.md").is_file() and (base / "parse" / "tea_step.json").is_file(): + return True + required = [ + base / "prompts" / "atdd.md", + base / "prompts" / "test_automate.md", + base / "prompts" / "test_review.md", + base / "prompts" / "trace.md", + base / "parse" / "atdd.json", + base / "parse" / "test_automate.json", + base / "parse" / "test_review.json", + base / "parse" / "trace.json", + ] + return all(path.is_file() for path in required) + + +def tea_detected_assets_root(project_root: Path) -> str: + for assets_root in tea_asset_root_candidates(project_root): + if tea_assets_complete_for_base(tea_assets_base_path(project_root, assets_root)): + return assets_root + return "data/tea-story-automator" + + +def tea_contract_files(project_root: Path, assets_root: str, step: str) -> tuple[str, str]: + base = tea_assets_base_path(project_root, assets_root) + root = assets_root.rstrip("/") + generic_prompt = f"{root}/prompts/tea_step.md" + generic_schema = f"{root}/parse/tea_step.json" + if base is None: + return generic_prompt, generic_schema + if (base / "prompts" / f"{step}.md").is_file() and (base / "parse" / f"{step}.json").is_file(): + return f"{root}/prompts/{step}.md", f"{root}/parse/{step}.json" + return generic_prompt, generic_schema + + +def resolve_tea_skill_name(project_root: Path, step: str) -> str: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return skill_name + return candidates[0] if candidates else "" + + +def tea_skill_installed(project_root: Path, step: str) -> bool: + candidates = TEA_SKILL_ALIASES.get(step, ()) + for skill_name in candidates: + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + continue + if file_exists(str(skill_dir / "SKILL.md")): + return True + return False + + +def tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: + steps: dict[str, Any] = {} + for step, label in ( + ("atdd", "atdd"), + ("test_automate", "test-automate"), + ("test_review", "test-review"), + ("trace", "trace"), + ): + prompt, schema = tea_contract_files(project_root, assets_root, step) + steps[step] = { + "label": 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"}, + } + if include_nfr: + prompt, schema = tea_contract_files(project_root, assets_root, "nfr") + steps["nfr"] = { + "label": "nfr", + "assets": { + "skillName": resolve_tea_skill_name(project_root, "nfr"), + "workflowCandidates": ["workflow.md", "workflow.yaml"], + "instructionsCandidates": [], + "checklistCandidates": ["checklist.md"], + "templateCandidates": [], + "required": ["skill"], + }, + "prompt": {"templateFile": 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 ["atdd", "test_automate", "test_review", "trace"]): + skill_name = resolve_tea_skill_name(project_root, step) + if not skill_name: + missing.append(TEA_SKILL_ALIASES[step][0]) + continue + try: + skill_dir = resolve_skill_dir(project_root, skill_name) + except ValueError: + missing.append(TEA_SKILL_ALIASES[step][0]) + continue + if file_exists(str(skill_dir / "SKILL.md")): + available.append(skill_name) + else: + missing.append(TEA_SKILL_ALIASES[step][0]) + return available, missing + + +def resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[str]) -> tuple[list[str], str]: + available: list[str] = [] + asset_roots: list[str] = [] + steps = policy.get("steps") or {} + for step in required_steps: + if not isinstance(steps.get(step), dict): + continue + contract = steps[step] + assets = contract.get("assets") or {} + skill_name = str(assets.get("skillName") or "").strip() + if skill_name: + available.append(skill_name) + prompt = contract.get("prompt") or {} + template_file = str(prompt.get("templateFile") or "").strip() + if template_file: + root = str(Path(template_file).parent.parent).replace("\\", "/") + if root and root not in asset_roots: + asset_roots.append(root) + return available, ", ".join(asset_roots) + + +def _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 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 93757ff2..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 @@ -83,7 +83,8 @@ For the TEA track, state clearly: - legacy `qa-generate-e2e-tests` is not added on the TEA track because `test_automate` supersedes it Collect: -- `selected_optional_steps` = zero or more of `retro`, `nfr` +- `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. 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 a7d6eeca..22f313e7 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -171,12 +171,19 @@ validation=$("$scripts" orchestrator-helper verify-step create {story_id} --stat Use the same spawn/monitor/parse pattern as other session-exit steps: ```bash +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 "$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --state-file "$state_file")") + --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"` → continue to dev 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 89c568f1..68dcc5be 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 @@ -88,12 +88,19 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") For each enabled TEA step: ```bash +resolve_agent_for_task "{step}" "$state_file" "{story_id}" +if should_apply_primary_model "$current_agent"; then + built_cmd=$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") +else + built_cmd=$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --state-file "$state_file") +fi session=$("$scripts" tmux-wrapper spawn {step} {epic} {story_id} \ --agent "$current_agent" \ - --command "$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --state-file "$state_file")") + --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')" {step} --state-file "$state_file") +next_action=$(echo "$parsed" | jq -r '.next_action') ``` - If `next_action == "proceed"` → continue to the next policy-defined step 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 5b5c8f1a..fba20b29 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 @@ -111,6 +111,10 @@ 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 and (.sequence | index("retro"))' >/dev/null; then + trigger_retro=false +fi epic_complete=$(echo "$epic_status" | jq -r '.allStoriesDone') epic_ok=$(echo "$epic_status" | jq -r '.ok') diff --git a/tests/tea_test_support.py b/tests/tea_test_support.py new file mode 100644 index 00000000..8018fedb --- /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|escalate", + }, + } + ), + encoding="utf-8", + ) + (parse / "test_automate.json").write_text( + json.dumps( + { + "requiredKeys": ["status", "tests_added", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "tests_added": "integer", + "summary": "brief description", + "next_action": "proceed|retry|escalate", + }, + } + ), + encoding="utf-8", + ) + (parse / "test_review.json").write_text( + json.dumps( + { + "requiredKeys": ["status", "issues_found", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "issues_found": "integer", + "summary": "brief description", + "next_action": "proceed|retry|escalate", + }, + } + ), + encoding="utf-8", + ) + (parse / "nfr.json").write_text( + json.dumps( + { + "requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "nfr_report_created": "true|false", + "summary": "brief description", + "next_action": "proceed|retry|escalate", + }, + } + ), + encoding="utf-8", + ) + (parse / "trace.json").write_text( + json.dumps( + { + "requiredKeys": ["status", "trace_updated", "summary", "next_action"], + "schema": { + "status": "SUCCESS|FAILURE|AMBIGUOUS", + "trace_updated": "true|false", + "summary": "brief description", + "next_action": "proceed|retry|escalate", + }, + } + ), + encoding="utf-8", + ) + + +def tea_steps_override( + *, + 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_orchestrator_progress.py b/tests/test_orchestrator_progress.py new file mode 100644 index 00000000..e2caa243 --- /dev/null +++ b/tests/test_orchestrator_progress.py @@ -0,0 +1,146 @@ +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_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")).hexdigest()[:8] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 33f66697..5282ca93 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -56,7 +56,7 @@ def test_inline_override_deep_merges_after_project_override(self) -> None: self.assertEqual(policy["workflow"]["repeat"]["review"]["maxCycles"], 3) def test_invalid_step_name_rejected(self) -> None: - self._write_override({"steps": {"ship": {"success": {"verifier": "session_exit"}}}}) + self._write_override({"workflow": {"sequence": ["create", "ship"]}, "steps": {"ship": {"success": {"verifier": "session_exit"}}}}) with self.assertRaises(PolicyError): load_effective_policy(str(self.project_root)) @@ -159,6 +159,32 @@ def raising_read_json(path): 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_does_not_break_standard_inline_selection(self) -> None: + self._write_override( + { + "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, + "steps": {"atdd": {"assets": []}}, + } + ) + policy = load_effective_policy( + str(self.project_root), + inline_override={"workflow": {"sequence": ["create", "dev", "review"]}}, + ) + self.assertEqual(policy["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() diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index d0ae9722..df656a8d 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -2,26 +2,16 @@ import io import json -import shutil 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_epic_agents import parse_agent_config from story_automator.commands.orchestrator import cmd_orchestrator_helper -from story_automator.commands.state import ( - cmd_build_run_policy, - cmd_build_state_doc, - cmd_detect_workflow_track, - cmd_state_metrics, - cmd_validate_state, -) +from story_automator.commands.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): @@ -29,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() @@ -40,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"]) @@ -85,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"]) @@ -194,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)]) @@ -210,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)]) @@ -226,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)]) @@ -243,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): @@ -263,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)]) @@ -292,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)]) @@ -367,39 +325,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)]) @@ -411,12 +357,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: @@ -446,714 +390,18 @@ 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()) self.assertFalse(payload["ok"]) self.assertEqual(payload["error"], "policy_snapshot_failed") - def test_build_state_doc_renders_tea_progress_columns_from_pinned_policy(self) -> None: - self._install_tea_skills() - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root), - } - ), - encoding="utf-8", - ) - state_file = self._build_state({"workflowTrack": "tea"}) - text = state_file.read_text(encoding="utf-8") - self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | trace | code-review | git-commit | Status |", text) - self.assertIn("| 1.1 | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |", text) - - def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_checkpoint(self) -> None: - self._install_tea_skills(include_nfr=True) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "tea", - "selectedOptionalSteps": ["nfr", "retro", "qa-generate-e2e-tests", "validate-create-story"], - "manualCheckpoints": ["checkpoint-preview"], - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertTrue(payload["ok"]) - self.assertEqual( - payload["policyOverride"]["workflow"]["sequence"], - ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], - ) - self.assertEqual(payload["manualCheckpoints"], []) - self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) - self.assertTrue(any("superseded by TEA test_automate" in note for note in payload["notes"])) - self.assertTrue(any("not yet automated by story-automator" in note for note in payload["notes"])) - self.assertTrue(any("out of scope for story-automator" in note for note in payload["notes"])) - - def test_detect_workflow_track_recommends_tea_when_project_is_capable(self) -> None: - self._install_tea_skills() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["requiresConfirmation"]) - self.assertTrue(payload["teaCapable"]) - self.assertIn("Detected TEA support for this project", payload["prompt"]) - - def test_detect_workflow_track_accepts_canonical_tea_skill_names(self) -> None: - self._install_tea_skills(canonical=True) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["teaCapable"]) - self.assertEqual( - payload["availableSkills"], - [ - "bmad-testarch-atdd", - "bmad-testarch-automate", - "bmad-testarch-test-review", - "bmad-testarch-trace", - ], - ) - - def test_detect_workflow_track_uses_bundled_tea_adapter_assets(self) -> None: - self._install_tea_skills(canonical=True, write_assets=False) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["teaCapable"]) - self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") - self.assertEqual(payload["missingAssets"], []) - - def test_detect_workflow_track_falls_back_when_project_tea_assets_are_incomplete(self) -> None: - self._install_tea_skills(canonical=True, write_assets=False) - incomplete_dir = self.project_root / "_bmad" / "tea" / "story-automator" / "prompts" - incomplete_dir.mkdir(parents=True, exist_ok=True) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["teaCapable"]) - self.assertEqual(payload["assetsRoot"], "data/tea-story-automator") - self.assertEqual(payload["missingAssets"], []) - - def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> None: - _write_tea_assets(self.project_root) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "standard") - self.assertFalse(payload["teaCapable"]) - self.assertTrue(payload["missingSkills"]) - - def test_detect_workflow_track_honors_explicit_tea_policy(self) -> None: - self._install_tea_skills() - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root), - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertFalse(payload["requiresConfirmation"]) - self.assertTrue(payload["explicitTeaPolicy"]) - - def test_detect_workflow_track_trusts_valid_explicit_tea_policy_with_custom_asset_root(self) -> None: - self._install_tea_skills(canonical=True, write_assets=False) - custom_root = self.project_root / "custom-tea-assets" - _write_tea_assets(self.project_root, root=custom_root) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - override_steps = _tea_steps_override(self.project_root, canonical=True, assets_root="custom-tea-assets") - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": override_steps, - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["teaCapable"]) - self.assertEqual(payload["assetsRoot"], "custom-tea-assets") - self.assertEqual(payload["missingAssets"], []) - self.assertEqual( - payload["availableSkills"], - [ - "bmad-testarch-atdd", - "bmad-testarch-automate", - "bmad-testarch-test-review", - "bmad-testarch-trace", - ], - ) - - def test_detect_workflow_track_reports_multiple_asset_roots_for_valid_explicit_policy(self) -> None: - self._install_tea_skills(canonical=True, write_assets=False) - _write_tea_assets(self.project_root) - custom_root = self.project_root / "custom-tea-assets" - _write_tea_assets(self.project_root, root=custom_root) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - override_steps = _tea_steps_override(self.project_root, canonical=True) - override_steps["trace"] = _tea_steps_override( - self.project_root, - canonical=True, - assets_root="custom-tea-assets", - )["trace"] - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": override_steps, - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "tea") - self.assertTrue(payload["teaCapable"]) - self.assertEqual(payload["missingAssets"], []) - self.assertEqual(payload["assetsRoot"], "_bmad/tea/story-automator, custom-tea-assets") - - def test_detect_workflow_track_rejects_explicit_tea_policy_missing_step_contract(self) -> None: - self._install_tea_skills(canonical=True) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, - "steps": {}, - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "standard") - self.assertFalse(payload["teaCapable"]) - self.assertTrue(any("workflow.sequence references missing step: atdd" in note for note in payload["reasons"])) - - def test_detect_workflow_track_reports_invalid_explicit_override_file(self) -> None: - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text("{bad json", encoding="utf-8") - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "standard") - self.assertFalse(payload["teaCapable"]) - self.assertTrue(payload["teaDetected"]) - self.assertTrue(any("story-automator policy override, but it is invalid" in note for note in payload["reasons"])) - self.assertTrue(any("invalid JSON" in note for note in payload["reasons"])) - - def test_detect_workflow_track_rejects_explicit_tea_policy_when_skills_missing(self) -> None: - _write_tea_assets(self.project_root) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root), - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "standard") - self.assertFalse(payload["teaCapable"]) - self.assertTrue(payload["explicitTeaPolicy"]) - self.assertTrue(any("required TEA skills or assets are missing" in note for note in payload["reasons"])) - - def test_detect_workflow_track_rejects_explicit_tea_policy_when_nfr_skill_is_missing(self) -> None: - self._install_tea_skills(canonical=True) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root, include_nfr=True), - } - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_detect_workflow_track([]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["recommendedTrack"], "standard") - self.assertFalse(payload["teaCapable"]) - self.assertTrue(payload["explicitTeaPolicy"]) - self.assertIn("bmad-testarch-nfr", payload["missingSkills"]) - - def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(self) -> None: - self._install_tea_skills(include_nfr=True) - state_file = self._build_state( - { - "workflowTrack": "tea", - "selectedOptionalSteps": ["nfr"], - "manualCheckpoints": ["checkpoint-preview"], - } - ) - text = state_file.read_text(encoding="utf-8") - self.assertIn('workflowTrack: "tea"', text) - self.assertIn('selectedOptionalSteps: ["nfr"]', text) - self.assertIn('manualCheckpoints: []', text) - self.assertIn("**TEA Configuration:**", text) - self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) - self.assertIn("| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", text) - - def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self) -> None: - self._install_tea_skills(canonical=True) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root, include_nfr=True), - } - ), - encoding="utf-8", - ) - state_file = self._build_state({"workflowTrack": "standard"}) - text = state_file.read_text(encoding="utf-8") - self.assertNotIn("**TEA Configuration:**", text) - self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) - - def test_build_state_doc_legacy_config_stays_standard_despite_explicit_tea_override(self) -> None: - self._install_tea_skills(canonical=True) - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root), - } - ), - encoding="utf-8", - ) - state_file = self._build_state() - text = state_file.read_text(encoding="utf-8") - self.assertNotIn("**TEA Configuration:**", text) - self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) - - def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: - self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "tea", - "selectedOptionalSteps": ["nfr"], - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["policyOverride"]["steps"]["atdd"]["assets"]["skillName"], "bmad-testarch-atdd") - self.assertEqual(payload["policyOverride"]["steps"]["test_automate"]["assets"]["skillName"], "bmad-testarch-automate") - self.assertEqual(payload["policyOverride"]["steps"]["test_review"]["assets"]["skillName"], "bmad-testarch-test-review") - self.assertEqual(payload["policyOverride"]["steps"]["trace"]["assets"]["skillName"], "bmad-testarch-trace") - self.assertEqual(payload["policyOverride"]["steps"]["nfr"]["assets"]["skillName"], "bmad-testarch-nfr") - self.assertEqual(payload["policyOverride"]["steps"]["atdd"]["prompt"]["templateFile"], "data/tea-story-automator/prompts/tea_step.md") - self.assertEqual(payload["policyOverride"]["steps"]["nfr"]["parse"]["schemaFile"], "data/tea-story-automator/parse/tea_step.json") - - def test_build_run_policy_normalizes_workflow_track_for_explicit_override(self) -> None: - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "TEA", - "policyOverride": {"workflow": {"sequence": ["create", "dev", "review"]}}, - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["workflowTrack"], "tea") - - def test_build_run_policy_normalizes_selected_optional_steps_for_explicit_override(self) -> None: - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "TEA", - "selectedOptionalSteps": ["NFR", "Retro", None], - "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) - - def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) -> None: - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "TEA", - "manualCheckpoints": ["checkpoint-preview"], - "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["manualCheckpoints"], []) - self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) - - def test_build_run_policy_distinguishes_invalid_json_from_missing_config(self) -> None: - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy(["--config-json", "{"]) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "invalid_config_json") - - def test_build_run_policy_rejects_non_object_json(self) -> None: - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy(["--config-json", "[]"]) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "config_must_be_object") - - def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: - self._install_tea_skills(canonical=True, write_assets=False) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "tea", - "selectedOptionalSteps": ["nfr"], - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual( - payload["policyOverride"]["workflow"]["sequence"], - ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], - ) - self.assertNotIn("nfr", payload["policyOverride"]["steps"]) - self.assertEqual(payload["selectedOptionalSteps"], []) - self.assertTrue(any("TEA NFR skill is not installed" in note for note in payload["notes"])) - - def test_build_run_policy_normalizes_selected_optional_steps_on_tea_track(self) -> None: - self._install_tea_skills(include_nfr=True, canonical=True, write_assets=False) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_build_run_policy( - [ - "--config-json", - json.dumps( - { - "workflowTrack": "TEA", - "selectedOptionalSteps": ["NFR", "Retro", None], - } - ), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual( - payload["policyOverride"]["workflow"]["sequence"], - ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review", "retro"], - ) - self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) - - def test_state_progress_updates_named_columns_in_tea_table(self) -> None: - self._install_tea_skills(include_nfr=True) - state_file = self._build_state( - { - "workflowTrack": "tea", - "selectedOptionalSteps": ["nfr"], - } - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "atdd=done", - "--set", - "nfr=done", - "--set", - "status=in-progress", - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertTrue(payload["ok"]) - text = state_file.read_text(encoding="utf-8") - self.assertIn("| 1.1 | ⏳ | done | ⏳ | ⏳ | ⏳ | done | ⏳ | ⏳ | ⏳ | in-progress |", text) - - def test_state_progress_rejects_invalid_set_argument(self) -> None: - state_file = self._build_state() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "status", - ] - ) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "invalid_set_argument") - self.assertEqual(payload["argument"], "status") - - def test_state_progress_rejects_story_column_updates(self) -> None: - state_file = self._build_state() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "story=1.2", - ] - ) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "story_column_immutable") - - def test_state_progress_returns_structured_error_when_state_file_is_unreadable(self) -> None: - state_file = self._build_state() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - with patch("story_automator.commands.orchestrator.read_text", side_effect=OSError("permission denied")): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "status=done", - ] - ) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "state_file_unreadable") - - def test_state_progress_returns_structured_error_when_state_file_stat_is_unreadable(self) -> None: - state_file = self._build_state() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - with patch("story_automator.commands.orchestrator.file_exists", side_effect=PermissionError("permission denied")): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "status=done", - ] - ) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "state_file_unreadable") - - def test_state_progress_returns_structured_error_when_state_file_is_unwritable(self) -> None: - state_file = self._build_state() - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - with patch("pathlib.Path.write_text", side_effect=OSError("permission denied")): - code = cmd_orchestrator_helper( - [ - "state-progress", - str(state_file), - "--story", - "1.1", - "--set", - "status=done", - ] - ) - self.assertEqual(code, 1) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["error"], "state_file_unwritable") - - def test_state_metrics_skips_markdown_divider_row(self) -> None: - state_file = self.project_root / "metrics-state.md" - state_file.write_text( - "\n".join( - [ - "---", - "epic: 1", - "---", - "| Story | create-story | Status |", - "|-------\t|--------------|--------|", - "| 1.1 | done | pending |", - "", - ] - ), - encoding="utf-8", - ) - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_state_metrics(["--state", str(state_file)]) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["total"], 1) - self.assertEqual(payload["storiesCompleted"], 0) - - def test_build_state_doc_keeps_standard_summary_shape_unchanged(self) -> None: - state_file = self._build_state() - text = state_file.read_text(encoding="utf-8") - self.assertNotIn("**TEA Configuration:**", text) - self.assertNotIn("Workflow Track:", text) - self.assertNotIn("Optional Steps:", text) - self.assertNotIn("Manual Checkpoints:", text) - - def test_agents_build_uses_pinned_tea_story_sequence(self) -> None: - self._install_tea_skills() - override_dir = self.project_root / "_bmad" / "bmm" - override_dir.mkdir(parents=True, exist_ok=True) - (override_dir / "story-automator.policy.json").write_text( - json.dumps( - { - "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, - "steps": _tea_steps_override(self.project_root), - } - ), - encoding="utf-8", - ) - state_file = self._build_state({"workflowTrack": "tea"}) - complexity_file = self.project_root / "complexity.json" - complexity_file.write_text( - json.dumps({"stories": [{"storyId": "1.1", "title": "Story 1", "complexity": {"level": "medium"}}]}), - encoding="utf-8", - ) - agents_file = self.project_root / "agents.md" - stdout = io.StringIO() - with patch_env(self.project_root), redirect_stdout(stdout): - code = cmd_orchestrator_helper( - [ - "agents-build", - "--state-file", - str(state_file), - "--complexity-file", - str(complexity_file), - "--output", - str(agents_file), - "--config-json", - json.dumps({"defaultPrimary": "claude", "defaultFallback": False}), - ] - ) - self.assertEqual(code, 0) - payload = json.loads(stdout.getvalue()) - self.assertTrue(payload["ok"]) - text = agents_file.read_text(encoding="utf-8") - self.assertIn('"atdd"', text) - self.assertIn('"test_automate"', text) - self.assertIn('"test_review"', text) - self.assertIn('"trace"', text) - def test_build_cmd_rejects_unknown_step_via_policy(self) -> None: stderr = io.StringIO() with patch_env(self.project_root), redirect_stderr(stderr): @@ -1178,14 +426,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"]) @@ -1198,173 +439,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") - - def _install_tea_skills(self, *, include_nfr: bool = False, canonical: bool = False, write_assets: bool = True) -> None: - if write_assets: - _write_tea_assets(self.project_root) - else: - (self.project_root / "_bmad" / "tea" / "workflows" / "testarch").mkdir(parents=True, exist_ok=True) - prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" - names = [ - f"{prefix}-atdd", - f"{prefix}-automate", - f"{prefix}-test-review", - f"{prefix}-trace", - ] - if include_nfr: - names.append(f"{prefix}-nfr") - for name in names: - skill_dir = self.project_root / ".claude" / "skills" / name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") - (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") - - -class patch_env: - def __init__(self, project_root: Path, extra: dict[str, str] | None = None) -> None: - 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 _write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: - base = root or (project_root / "_bmad" / "tea" / "story-automator") - prompts = base / "prompts" - parse = base / "parse" - prompts.mkdir(parents=True, exist_ok=True) - parse.mkdir(parents=True, exist_ok=True) - (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") - (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") - (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") - (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") - (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") - (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - - -def _tea_steps_override( - project_root: Path, - *, - include_nfr: bool = False, - canonical: bool = False, - assets_root: str = "_bmad/tea/story-automator", -) -> dict[str, object]: - prefix = "bmad-testarch" if canonical else "bmad-tea-testarch" - steps: dict[str, object] = { - "atdd": { - "label": "atdd", - "assets": { - "skillName": f"{prefix}-atdd", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": f"{assets_root}/prompts/atdd.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{assets_root}/parse/atdd.json"}, - "success": {"verifier": "session_exit"}, - }, - "test_automate": { - "label": "test-automate", - "assets": { - "skillName": f"{prefix}-automate", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": f"{assets_root}/prompts/test_automate.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{assets_root}/parse/test_automate.json"}, - "success": {"verifier": "session_exit"}, - }, - "test_review": { - "label": "test-review", - "assets": { - "skillName": f"{prefix}-test-review", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": f"{assets_root}/prompts/test_review.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{assets_root}/parse/test_review.json"}, - "success": {"verifier": "session_exit"}, - }, - "trace": { - "label": "trace", - "assets": { - "skillName": f"{prefix}-trace", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": f"{assets_root}/prompts/trace.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{assets_root}/parse/trace.json"}, - "success": {"verifier": "session_exit"}, - }, - } - if include_nfr: - steps["nfr"] = { - "label": "nfr", - "assets": { - "skillName": f"{prefix}-nfr", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": f"{assets_root}/prompts/nfr.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": f"{assets_root}/parse/nfr.json"}, - "success": {"verifier": "session_exit"}, - } - return steps - if __name__ == "__main__": unittest.main() diff --git a/tests/test_tea_detection.py b/tests/test_tea_detection.py new file mode 100644 index 00000000..4f85f514 --- /dev/null +++ b/tests/test_tea_detection.py @@ -0,0 +1,266 @@ +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_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_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(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_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..10b4b305 --- /dev/null +++ b/tests/test_tea_policy_flow.py @@ -0,0 +1,167 @@ +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_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_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_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..a7e33ff1 --- /dev/null +++ b/tests/test_tea_state_rendering.py @@ -0,0 +1,434 @@ +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_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("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) + self.assertIn( + "| Story | create-story | atdd | dev-story | test-automate | test-review | nfr | trace | code-review | git-commit | Status |", + text, + ) + + def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self) -> None: + 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_stays_standard_despite_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(), + } + ) + state_file = self._build_state() + text = state_file.read_text(encoding="utf-8") + self.assertNotIn("**TEA Configuration:**", text) + self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + + def test_build_run_policy_uses_canonical_tea_skill_names_when_installed(self) -> None: + 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"], "tea") + + def test_build_run_policy_normalizes_selected_optional_steps_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "selectedOptionalSteps": ["NFR", "Retro", None], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + + def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy( + [ + "--config-json", + json.dumps( + { + "workflowTrack": "TEA", + "manualCheckpoints": ["checkpoint-preview"], + "policyOverride": {"workflow": {"sequence": ["create", "review"]}}, + } + ), + ] + ) + self.assertEqual(code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["manualCheckpoints"], []) + self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) + + def test_build_run_policy_distinguishes_invalid_json_from_missing_config(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "{"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "invalid_config_json") + + def test_build_run_policy_rejects_non_object_json(self) -> None: + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = cmd_build_run_policy(["--config-json", "[]"]) + self.assertEqual(code, 1) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["error"], "config_must_be_object") + + def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: + 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_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() From 8fd18573eed32eef6c72967509f73f4026677d12 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 26 May 2026 12:49:41 +0100 Subject: [PATCH 20/33] Fix explicit policy override regressions --- .../src/story_automator/commands/state.py | 15 +++++---- .../src/story_automator/core/tea_policy.py | 19 +++++++++--- tests/test_tea_policy_flow.py | 11 +++++++ tests/test_tea_state_rendering.py | 31 +++++++++++++++++-- 4 files changed, 63 insertions(+), 13 deletions(-) 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 1da4416d..1bdfb971 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -9,7 +9,7 @@ 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.tea_policy import build_run_policy, detect_workflow_track, selected_optional_steps_from_sequence, workflow_track_for_sequence from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json @@ -56,6 +56,9 @@ def cmd_build_state_doc(args: list[str]) -> int: 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"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) text = read_text(template) replacements: dict[str, Any] = { @@ -86,10 +89,10 @@ def cmd_build_state_doc(args: list[str]) -> int: ) custom_instructions = json.dumps(config.get("customInstructions", "")) text = re.sub(r"(?m)^customInstructions:.*$", lambda m: f"customInstructions: {custom_instructions}", text) - if policy_selection["workflowTrack"] == "tea": + if pinned_track == "tea": tea_frontmatter = ( - f'workflowTrack: {json.dumps(policy_selection["workflowTrack"])}\n' - f"selectedOptionalSteps: {json.dumps(policy_selection['selectedOptionalSteps'])}\n" + f'workflowTrack: {json.dumps(pinned_track)}\n' + f"selectedOptionalSteps: {json.dumps(pinned_optional_steps)}\n" f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}\n" f"policyNotes: {json.dumps(policy_selection['notes'])}\n" ) @@ -179,11 +182,11 @@ def cmd_build_state_doc(args: list[str]) -> int: "{{customInstructions}}": str(config.get("customInstructions", "")), } tea_block = "" - if policy_selection["workflowTrack"] == "tea": + if pinned_track == "tea": tea_block_lines = [ "**TEA Configuration:**", "- Mandatory TEA Core: atdd, test_automate, test_review, trace", - f"- Optional Automated Steps: {', '.join(policy_selection['selectedOptionalSteps']) or 'none'}", + f"- Optional Automated Steps: {', '.join(pinned_optional_steps) or 'none'}", f"- Policy Notes: {'; '.join(policy_selection['notes']) or 'none'}", "", ] 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 index db2f0f26..8e0b8ef5 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -26,16 +26,16 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, Any]: explicit_override = config.get("policyOverride") if isinstance(explicit_override, dict): - track = str(config.get("workflowTrack") or "standard").strip().lower() - if track not in {"standard", "tea"}: - track = "standard" + resolved = load_effective_policy(str(project_root), inline_override=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": _normalize_option_list(config.get("selectedOptionalSteps")), + "selectedOptionalSteps": selected_optional_steps_from_sequence(sequence), "manualCheckpoints": [], "notes": notes, } @@ -44,8 +44,12 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An key in config for key in ("workflowTrack", "selectedOptionalSteps", "manualCheckpoints", "teaAssetsRoot", "includeRetro") ) if not has_run_selection: + if has_explicit_tea_policy(project_root): + policy_override = {"workflow": {"sequence": list(STANDARD_SEQUENCE)}} + else: + policy_override = {} return { - "policyOverride": {"workflow": {"sequence": list(STANDARD_SEQUENCE)}}, + "policyOverride": policy_override, "workflowTrack": "standard", "selectedOptionalSteps": [], "manualCheckpoints": [], @@ -212,6 +216,11 @@ def selected_optional_steps_from_sequence(sequence: list[str]) -> list[str]: return selected +def workflow_track_for_sequence(sequence: list[str]) -> str: + unique_tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} + return "tea" if any(step in unique_tea_steps for step in sequence) else "standard" + + def explicit_policy_path(project_root: Path) -> Path: return project_root / "_bmad" / "bmm" / "story-automator.policy.json" diff --git a/tests/test_tea_policy_flow.py b/tests/test_tea_policy_flow.py index 10b4b305..3e611ef8 100644 --- a/tests/test_tea_policy_flow.py +++ b/tests/test_tea_policy_flow.py @@ -51,6 +51,17 @@ def test_detect_workflow_track_keeps_standard_override_out_of_tea_detection(self 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_project_tea_policy(self) -> None: install_tea_skills(self.project_root, canonical=True, write_assets=False) override_dir = self.project_root / "_bmad" / "bmm" diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index a7e33ff1..5f88b15d 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -139,6 +139,19 @@ def test_build_state_doc_legacy_config_stays_standard_despite_explicit_tea_overr self.assertNotIn("**TEA Configuration:**", text) self.assertIn("| Story | create-story | dev-story | automate | 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() @@ -175,7 +188,8 @@ def test_build_run_policy_normalizes_workflow_track_for_explicit_override(self) ) self.assertEqual(code, 0) payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["workflowTrack"], "tea") + 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() @@ -194,7 +208,7 @@ def test_build_run_policy_normalizes_selected_optional_steps_for_explicit_overri ) self.assertEqual(code, 0) payload = json.loads(stdout.getvalue()) - self.assertEqual(payload["selectedOptionalSteps"], ["nfr", "retro"]) + self.assertEqual(payload["selectedOptionalSteps"], []) def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) -> None: stdout = io.StringIO() @@ -216,6 +230,19 @@ def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) self.assertEqual(payload["manualCheckpoints"], []) self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) + 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): From cd12d4c092cf56805e53eb4f3f2a1ca9005bb6a2 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 26 May 2026 15:47:12 +0100 Subject: [PATCH 21/33] Fix TEA detection override precedence --- .../src/story_automator/core/tea_policy.py | 36 ++++++++++++++++++- tests/test_tea_detection.py | 24 +++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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 index 8e0b8ef5..fa251bb1 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -142,7 +142,11 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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_error = explicit_policy_payload(project_root) + explicit_override_payload, explicit_override_error = explicit_policy_payload(project_root) + explicit_override_sequence = [ + step for step in (((explicit_override_payload.get("workflow") or {}).get("sequence")) or []) if isinstance(step, str) + ] + explicit_override_track = workflow_track_for_sequence(explicit_override_sequence) if explicit_override_payload else "standard" explicit_steps = explicit_tea_steps(project_root) explicit_policy = bool(explicit_steps) explicit_policy_resolved, explicit_policy_error = explicit_tea_policy_details(project_root) @@ -152,6 +156,10 @@ def detect_workflow_track(project_root: Path) -> dict[str, Any]: 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) @@ -169,6 +177,8 @@ def detect_workflow_track(project_root: Path) -> dict[str, Any]: reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") if explicit_policy_error: reasons.append(explicit_policy_error) + elif explicit_override_payload and explicit_override_track == "standard": + reasons.append("Project already defines an explicit standard story-automator policy override.") elif explicit_override_stat_error: reasons.append("Project defines a story-automator policy override, but it is unreadable.") reasons.append(explicit_override_stat_error) @@ -488,6 +498,30 @@ def resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[st 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") or {} + 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 + + 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()] diff --git a/tests/test_tea_detection.py b/tests/test_tea_detection.py index 4f85f514..7395c05b 100644 --- a/tests/test_tea_detection.py +++ b/tests/test_tea_detection.py @@ -78,6 +78,15 @@ def test_detect_workflow_track_stays_standard_when_skills_are_missing(self) -> N 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_honors_explicit_tea_policy(self) -> None: install_tea_skills(self.project_root) self._write_policy_override( @@ -184,6 +193,21 @@ def test_detect_workflow_track_rejects_explicit_tea_policy_when_nfr_skill_is_mis 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( From 7874e8c3fe238ea7febae30a1ac77053c4345677 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Wed, 27 May 2026 15:08:15 +0100 Subject: [PATCH 22/33] Fix PR review follow-ups --- .../src/story_automator/commands/state.py | 11 +- .../story_automator/core/state_document.py | 6 +- .../steps-c/step-03b-execute-finish.md | 7 +- tests/test_orchestrator_progress.py | 2 +- tests/test_runtime_policy.py | 113 +----------------- tests/test_tea_state_rendering.py | 2 +- 6 files changed, 23 insertions(+), 118 deletions(-) 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 1bdfb971..ace5b0bb 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -44,6 +44,14 @@ def cmd_build_state_doc(args: list[str]) -> int: 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 + 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 + story_range = [item for item in (raw_story_range or []) if isinstance(item, str)] ensure_dir(output_folder) now = now_utc_z() stamp = now_utc().strftime("%Y%m%d-%H%M%S") @@ -59,7 +67,7 @@ def cmd_build_state_doc(args: list[str]) -> int: 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"], [item for item in config.get("storyRange", []) if isinstance(item, str)]) + 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", ""), @@ -171,7 +179,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)] body = { "{{epicName}}": str(config.get("epicName", "")), "{{epic}}": str(config.get("epic", "")), 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 index 75ffadf5..3512f5ee 100644 --- a/skills/bmad-story-automator/src/story_automator/core/state_document.py +++ b/skills/bmad-story-automator/src/story_automator/core/state_document.py @@ -142,13 +142,13 @@ def progress_metrics(text: str) -> dict[str, int]: continue if in_table and line.startswith("|"): parts = [part.strip() for part in line.split("|")] - values = [part for part in parts[1:-1] if part] + values = parts[1:-1] if len(values) >= 2: - first_cell = values[0] + first_cell = values[0].strip() if re.fullmatch(r"-+", first_cell): continue total += 1 - if any(token in values[-1].lower() for token in ("done", "complete", "completed")): + if values[-1].strip().lower() in {"done", "complete", "completed"}: completed += 1 continue if in_table and not line.startswith("|"): 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 fba20b29..de04a27a 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 @@ -112,15 +112,16 @@ 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 and (.sequence | index("retro"))' >/dev/null; then - trigger_retro=false +policy_has_retro=false +if echo "$policy_sequence" | jq -e '.ok == true and (.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/tests/test_orchestrator_progress.py b/tests/test_orchestrator_progress.py index e2caa243..8df8b3d8 100644 --- a/tests/test_orchestrator_progress.py +++ b/tests/test_orchestrator_progress.py @@ -139,7 +139,7 @@ def _review_step(skill_name: str) -> dict[str, object]: def _md5_hex8(text: str) -> str: import hashlib - return hashlib.md5(text.encode("utf-8")).hexdigest()[:8] + return hashlib.md5(text.encode("utf-8"), usedforsecurity=False).hexdigest()[:8] if __name__ == "__main__": diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 5282ca93..1d36f01b 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -15,6 +15,7 @@ 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] @@ -62,7 +63,7 @@ def test_invalid_step_name_rejected(self) -> None: def test_tea_steps_allowed_when_explicitly_configured_and_installed(self) -> None: self._install_tea_skills() - steps = _tea_steps_override(self.project_root) + steps = tea_steps_override() self._write_override( { "workflow": {"sequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"]}, @@ -87,7 +88,7 @@ def test_required_asset_missing_fails(self) -> None: load_effective_policy(str(self.project_root)) def test_tea_policy_fails_when_required_tea_skill_missing(self) -> None: - steps = _tea_steps_override(self.project_root) + steps = tea_steps_override() self._write_override( { "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, @@ -96,7 +97,7 @@ def test_tea_policy_fails_when_required_tea_skill_missing(self) -> None: }, } ) - with self.assertRaises(PolicyError): + 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: @@ -427,111 +428,7 @@ def _write_override(self, payload: dict[str, object]) -> None: (override_dir / "story-automator.policy.json").write_text(json.dumps(payload), encoding="utf-8") def _install_tea_skills(self) -> None: - _write_tea_assets(self.project_root) - for name in ( - "bmad-tea-testarch-atdd", - "bmad-tea-testarch-automate", - "bmad-tea-testarch-test-review", - "bmad-tea-testarch-trace", - ): - skill_dir = self.project_root / ".claude" / "skills" / name - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") - (skill_dir / "workflow.md").write_text(f"# {name}\n", encoding="utf-8") - - -def _write_tea_assets(project_root: Path) -> None: - prompts = project_root / "_bmad" / "tea" / "story-automator" / "prompts" - parse = project_root / "_bmad" / "tea" / "story-automator" / "parse" - prompts.mkdir(parents=True, exist_ok=True) - parse.mkdir(parents=True, exist_ok=True) - (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") - (prompts / "test_automate.md").write_text("TEST AUTOMATE {{story_id}}\n", encoding="utf-8") - (prompts / "test_review.md").write_text("TEST REVIEW {{story_id}}\n", encoding="utf-8") - (prompts / "nfr.md").write_text("NFR {{story_id}}\n", encoding="utf-8") - (prompts / "trace.md").write_text("TRACE {{story_id}}\n", encoding="utf-8") - (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "test_automate.json").write_text(json.dumps({"requiredKeys": ["status", "tests_added", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "test_review.json").write_text(json.dumps({"requiredKeys": ["status", "issues_found", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "nfr.json").write_text(json.dumps({"requiredKeys": ["status", "nfr_report_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - (parse / "trace.json").write_text(json.dumps({"requiredKeys": ["status", "trace_updated", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") - - -def _tea_steps_override(project_root: Path, *, include_nfr: bool = False) -> dict[str, object]: - steps: dict[str, object] = { - "atdd": { - "label": "atdd", - "assets": { - "skillName": "bmad-tea-testarch-atdd", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/atdd.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/atdd.json"}, - "success": {"verifier": "session_exit"}, - }, - "test_automate": { - "label": "test-automate", - "assets": { - "skillName": "bmad-tea-testarch-automate", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_automate.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_automate.json"}, - "success": {"verifier": "session_exit"}, - }, - "test_review": { - "label": "test-review", - "assets": { - "skillName": "bmad-tea-testarch-test-review", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/test_review.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/test_review.json"}, - "success": {"verifier": "session_exit"}, - }, - "trace": { - "label": "trace", - "assets": { - "skillName": "bmad-tea-testarch-trace", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/trace.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/trace.json"}, - "success": {"verifier": "session_exit"}, - }, - } - if include_nfr: - steps["nfr"] = { - "label": "nfr", - "assets": { - "skillName": "bmad-tea-testarch-nfr", - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": "_bmad/tea/story-automator/prompts/nfr.md", "interactionMode": "autonomous"}, - "parse": {"schemaFile": "_bmad/tea/story-automator/parse/nfr.json"}, - "success": {"verifier": "session_exit"}, - } - return steps + install_tea_skills(self.project_root, canonical=False, write_assets=True) if __name__ == "__main__": diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index 5f88b15d..0249a345 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -228,7 +228,7 @@ def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) self.assertEqual(code, 0) payload = json.loads(stdout.getvalue()) self.assertEqual(payload["manualCheckpoints"], []) - self.assertIn("checkpoint-preview is out of scope", payload["notes"][0]) + self.assertTrue(any("checkpoint-preview is out of scope" in note for note in payload["notes"])) def test_build_run_policy_rejects_invalid_explicit_override(self) -> None: stdout = io.StringIO() From c2a66640597d7aaf3c3affa59acb1587f1635a0d Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 28 May 2026 09:49:53 +0100 Subject: [PATCH 23/33] Validate storyRange item types --- .../src/story_automator/commands/state.py | 7 +++++-- tests/test_tea_state_rendering.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) 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 ace5b0bb..9e0b4e1e 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -51,7 +51,10 @@ def cmd_build_state_doc(args: list[str]) -> int: 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 - story_range = [item for item in (raw_story_range or []) if isinstance(item, str)] + 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 []) ensure_dir(output_folder) now = now_utc_z() stamp = now_utc().strftime("%Y%m%d-%H%M%S") @@ -72,7 +75,7 @@ def cmd_build_state_doc(args: list[str]) -> int: 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"), diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index 0249a345..bf05c5d9 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -259,6 +259,24 @@ def test_build_run_policy_rejects_non_object_json(self) -> None: 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_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() From e320c262cf6ea3fc2b7b55372d47d8de0f505f6a Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Tue, 2 Jun 2026 11:05:26 +0100 Subject: [PATCH 24/33] Address maintainer TEA workflow follow-ups --- .../tea-story-automator/parse/tea_step.json | 2 +- .../story_automator/commands/orchestrator.py | 5 +- .../src/story_automator/commands/state.py | 8 +- .../story_automator/core/state_document.py | 4 + .../src/story_automator/core/tea_policy.py | 80 +++++++++++---- .../steps-c/step-03-execute.md | 67 +++++++++---- .../steps-c/step-03a-execute-review.md | 12 ++- tests/tea_test_support.py | 10 +- tests/test_orchestrator_progress.py | 48 +++++++++ tests/test_tea_detection.py | 8 ++ tests/test_tea_policy_flow.py | 98 +++++++++++++++++++ tests/test_tea_state_rendering.py | 13 ++- 12 files changed, 304 insertions(+), 51 deletions(-) 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 index 3b9ed5a9..93693ac0 100644 --- 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 @@ -3,6 +3,6 @@ "schema": { "status": "SUCCESS|FAILURE|AMBIGUOUS", "summary": "brief description", - "next_action": "proceed|retry|escalate" + "next_action": "proceed|retry" } } 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 0cb2107e..aae69e07 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -539,8 +539,9 @@ def _state_progress(args: list[str]) -> int: try: policy = load_runtime_policy(get_project_root(), state_file=state_file, resolve_assets=False) - except (FileNotFoundError, PolicyError): - policy = None + 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) 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 9e0b4e1e..ed7def7c 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -13,6 +13,11 @@ from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json +def _tea_summary_steps(sequence: list[str]) -> list[str]: + tea_steps = {"atdd", "test_automate", "test_review", "nfr", "trace"} + return [step.replace("_", "-") for step in sequence if step in tea_steps] + + def cmd_build_state_doc(args: list[str]) -> int: template = "" output_folder = "" @@ -193,9 +198,10 @@ def cmd_build_state_doc(args: list[str]) -> int: } tea_block = "" if pinned_track == "tea": + pinned_tea_steps = _tea_summary_steps(pinned_sequence) tea_block_lines = [ "**TEA Configuration:**", - "- Mandatory TEA Core: atdd, test_automate, test_review, trace", + f"- Pinned TEA Steps: {', '.join(pinned_tea_steps) or 'none'}", f"- Optional Automated Steps: {', '.join(pinned_optional_steps) or 'none'}", f"- Policy Notes: {'; '.join(policy_selection['notes']) or 'none'}", "", 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 index 3512f5ee..26739885 100644 --- a/skills/bmad-story-automator/src/story_automator/core/state_document.py +++ b/skills/bmad-story-automator/src/story_automator/core/state_document.py @@ -108,6 +108,7 @@ def update_story_progress(state_file: str | Path, story_id: str, updates: dict[s 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": @@ -117,9 +118,12 @@ def update_story_progress(state_file: str | Path, story_id: str, updates: dict[s 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) 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 index fa251bb1..32a1290e 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -40,18 +40,32 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An "notes": notes, } + explicit_override_payload, explicit_override_error = explicit_policy_payload(project_root) + explicit_override_sequence = [ + step for step in (((explicit_override_payload.get("workflow") or {}).get("sequence")) or []) if isinstance(step, str) + ] + 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 has_explicit_tea_policy(project_root): - policy_override = {"workflow": {"sequence": list(STANDARD_SEQUENCE)}} - else: - policy_override = {} + 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": policy_override, - "workflowTrack": "standard", - "selectedOptionalSteps": [], + "policyOverride": {}, + "workflowTrack": workflow_track_for_sequence(sequence) if sequence else "standard", + "selectedOptionalSteps": selected_optional_steps_from_sequence(sequence), "manualCheckpoints": [], "notes": [], } @@ -65,9 +79,10 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An policy_override: dict[str, Any] = {} if track == "tea": - explicit_policy_resolved, explicit_policy_error = explicit_tea_policy_details(project_root) - if explicit_policy_resolved is not None: - explicit_sequence = ((explicit_policy_resolved.get("workflow") or {}).get("sequence")) or [] + 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")): notes.append("Per-run TEA optional-step selection was ignored because the project defines an explicit TEA story-automator policy.") @@ -80,8 +95,6 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An "manualCheckpoints": [], "notes": notes, } - if has_explicit_tea_policy(project_root): - raise PolicyError(explicit_policy_error or "explicit TEA story-automator policy is invalid") assets_root = tea_assets_root(project_root, config) include_nfr = "nfr" in selected @@ -108,6 +121,22 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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: @@ -147,12 +176,14 @@ def detect_workflow_track(project_root: Path) -> dict[str, Any]: step for step in (((explicit_override_payload.get("workflow") or {}).get("sequence")) or []) if isinstance(step, str) ] 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_resolved, explicit_policy_error = explicit_tea_policy_details(project_root) - explicit_policy_valid = explicit_policy_resolved is not None + 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_policy_resolved, explicit_steps) + available_skills, assets_root = resolved_explicit_tea_status(explicit_override_resolved, explicit_steps) missing_skills = [] missing_assets = [] assets_ok = True @@ -175,10 +206,14 @@ def detect_workflow_track(project_root: Path) -> dict[str, Any]: reasons.append("Project already defines an explicit TEA story-automator policy override.") elif explicit_policy: reasons.append("Project defines an explicit TEA story-automator policy override, but required TEA skills or assets are missing.") - if explicit_policy_error: - reasons.append(explicit_policy_error) - elif explicit_override_payload and explicit_override_track == "standard": + 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) @@ -280,6 +315,15 @@ def explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | No return None, str(exc) +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) + + def tea_assets_root(project_root: Path, config: dict[str, Any]) -> str: configured = str(config.get("teaAssetsRoot") or "").strip() if configured: 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 22f313e7..da193b19 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -106,6 +106,8 @@ echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$sta "$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}**" @@ -171,6 +173,9 @@ validation=$("$scripts" orchestrator-helper verify-step create {story_id} --stat Use the same spawn/monitor/parse pattern as other session-exit steps: ```bash +"$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") @@ -186,29 +191,50 @@ parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | next_action=$(echo "$parsed" | jq -r '.next_action') ``` -- If `next_action == "proceed"` → continue to dev +- If `next_action == "proceed"`: + ```bash + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set atdd=done \ + --set status=in-progress + ``` + → continue to the next policy-defined step - If `next_action == "retry"` or session crashed → retry with fallback pattern - Treat successful completion as execution completion only; TEA artifact verification is not part of v1 When updating progress, do not assume the standard fixed column order if TEA mode is active. ### B. Dev Story +*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") +dev_in_scope=false +if echo "$policy_sequence" | jq -e '.ok == true and (.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):** @@ -217,14 +243,19 @@ 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 "$scripts" orchestrator-helper state-progress "$state_file" \ @@ -233,7 +264,7 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []') --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) 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 68dcc5be..fada3faa 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 @@ -88,6 +88,9 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") For each enabled TEA step: ```bash +"$scripts" orchestrator-helper state-update "$state_file" \ + --set currentStep={step} \ + --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" resolve_agent_for_task "{step}" "$state_file" "{story_id}" if should_apply_primary_model "$current_agent"; then built_cmd=$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") @@ -103,7 +106,14 @@ parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | next_action=$(echo "$parsed" | jq -r '.next_action') ``` -- If `next_action == "proceed"` → continue to the next policy-defined step +- If `next_action == "proceed"`: + ```bash + "$scripts" orchestrator-helper state-progress "$state_file" \ + --story "${story_id}" \ + --set {step}=done \ + --set status=in-progress + ``` + → continue to the next policy-defined step - If `next_action == "retry"` or the session crashes → apply the retry/fallback pattern - TEA v1 success for these steps means session execution completed successfully - When a TEA quality step completes, update only that named progress column via `state-progress` rather than rewriting the whole row diff --git a/tests/tea_test_support.py b/tests/tea_test_support.py index 8018fedb..9cc7d176 100644 --- a/tests/tea_test_support.py +++ b/tests/tea_test_support.py @@ -111,7 +111,7 @@ def write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: "status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", - "next_action": "proceed|retry|escalate", + "next_action": "proceed|retry", }, } ), @@ -125,7 +125,7 @@ def write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: "status": "SUCCESS|FAILURE|AMBIGUOUS", "tests_added": "integer", "summary": "brief description", - "next_action": "proceed|retry|escalate", + "next_action": "proceed|retry", }, } ), @@ -139,7 +139,7 @@ def write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: "status": "SUCCESS|FAILURE|AMBIGUOUS", "issues_found": "integer", "summary": "brief description", - "next_action": "proceed|retry|escalate", + "next_action": "proceed|retry", }, } ), @@ -153,7 +153,7 @@ def write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: "status": "SUCCESS|FAILURE|AMBIGUOUS", "nfr_report_created": "true|false", "summary": "brief description", - "next_action": "proceed|retry|escalate", + "next_action": "proceed|retry", }, } ), @@ -167,7 +167,7 @@ def write_tea_assets(project_root: Path, *, root: Path | None = None) -> None: "status": "SUCCESS|FAILURE|AMBIGUOUS", "trace_updated": "true|false", "summary": "brief description", - "next_action": "proceed|retry|escalate", + "next_action": "proceed|retry", }, } ), diff --git a/tests/test_orchestrator_progress.py b/tests/test_orchestrator_progress.py index 8df8b3d8..cbf4a268 100644 --- a/tests/test_orchestrator_progress.py +++ b/tests/test_orchestrator_progress.py @@ -71,6 +71,54 @@ def test_state_progress_preserves_standard_aliases_without_policy(self) -> None: 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) diff --git a/tests/test_tea_detection.py b/tests/test_tea_detection.py index 7395c05b..62e07329 100644 --- a/tests/test_tea_detection.py +++ b/tests/test_tea_detection.py @@ -87,6 +87,14 @@ def test_detect_workflow_track_honors_explicit_standard_override_even_when_proje 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_honors_explicit_tea_policy(self) -> None: install_tea_skills(self.project_root) self._write_policy_override( diff --git a/tests/test_tea_policy_flow.py b/tests/test_tea_policy_flow.py index 3e611ef8..4f3025c8 100644 --- a/tests/test_tea_policy_flow.py +++ b/tests/test_tea_policy_flow.py @@ -62,6 +62,17 @@ def test_build_state_doc_preserves_explicit_standard_override_without_run_select 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" @@ -106,6 +117,93 @@ def test_build_state_doc_preserves_explicit_project_tea_policy(self) -> None: 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_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() diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index bf05c5d9..25c342bb 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -105,7 +105,7 @@ def test_build_state_doc_snapshots_generated_tea_policy_and_renders_nfr_column(s self.assertIn('selectedOptionalSteps: ["nfr"]', text) self.assertIn('manualCheckpoints: []', text) self.assertIn("**TEA Configuration:**", text) - self.assertIn("- Mandatory TEA Core: atdd, test_automate, test_review, trace", text) + self.assertIn("- 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, @@ -126,18 +126,21 @@ def test_build_state_doc_standard_track_ignores_explicit_tea_override_steps(self self.assertNotIn("**TEA Configuration:**", text) self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) - def test_build_state_doc_legacy_config_stays_standard_despite_explicit_tea_override(self) -> None: + 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(), + "steps": tea_steps_override(canonical=True), } ) state_file = self._build_state() text = state_file.read_text(encoding="utf-8") - self.assertNotIn("**TEA Configuration:**", text) - self.assertIn("| Story | create-story | dev-story | automate | code-review | git-commit | Status |", text) + 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( From 438cb9705acdf75c77e9877725dae127952f80d0 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Wed, 3 Jun 2026 17:29:05 +0100 Subject: [PATCH 25/33] Tighten TEA workflow policy guards --- .../src/story_automator/commands/state.py | 4 + .../story_automator/core/runtime_policy.py | 14 ++- .../src/story_automator/core/tea_policy.py | 48 ++++++++--- .../steps-c/step-03-execute.md | 43 ++++++---- .../steps-c/step-03a-execute-review.md | 86 ++++++++++++------- tests/test_runtime_policy.py | 47 ++++++++-- tests/test_tea_detection.py | 19 +++- tests/test_tea_policy_flow.py | 48 +++++++++++ 8 files changed, 239 insertions(+), 70 deletions(-) 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 ed7def7c..5342c50e 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -60,6 +60,10 @@ def cmd_build_state_doc(args: list[str]) -> int: 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 ensure_dir(output_folder) now = now_utc_z() stamp = now_utc().strftime("%Y%m%d-%H%M%S") 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 32760c2f..2c0504bf 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 @@ -50,7 +50,7 @@ def load_effective_policy( raise PolicyError(f"project override unreadable: {override_path}") from exc policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) _apply_legacy_env(policy) - _validate_policy_step_references(policy) + _validate_policy_shape(policy) _prune_unreferenced_steps(policy) _validate_policy_shape(policy) _clear_resolved_fields(policy) @@ -261,7 +261,7 @@ def _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[s policy = _read_json(policy_path) except OSError as exc: raise PolicyError(f"policy unreadable: {policy_path}") from exc - _validate_policy_step_references(policy) + _validate_policy_shape(policy) _prune_unreferenced_steps(policy) _validate_policy_shape(policy) return policy @@ -279,7 +279,7 @@ def _load_policy_snapshot_shape(path: Path, *, expected_hash: str = "") -> dict[ policy = json.loads(raw) except json.JSONDecodeError as exc: raise PolicyError(f"policy json invalid: {path}") from exc - _validate_policy_step_references(policy) + _validate_policy_shape(policy) _prune_unreferenced_steps(policy) _validate_policy_shape(policy) return policy @@ -393,6 +393,11 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: 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): @@ -410,6 +415,9 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: 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}") 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}") 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 index 32a1290e..63acdac3 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from copy import deepcopy from pathlib import Path from typing import Any @@ -26,7 +27,7 @@ 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=explicit_override) + 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")) @@ -41,9 +42,8 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An } explicit_override_payload, explicit_override_error = explicit_policy_payload(project_root) - explicit_override_sequence = [ - step for step in (((explicit_override_payload.get("workflow") or {}).get("sequence")) or []) if isinstance(step, str) - ] + 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 @@ -72,7 +72,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An track = str(config.get("workflowTrack") or "standard").strip().lower() if track not in {"standard", "tea"}: - track = "standard" + 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] = [] @@ -164,7 +164,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An "manualCheckpoints": [], "notes": notes, } - load_effective_policy(str(project_root), inline_override=selection["policyOverride"]) + load_effective_policy(str(project_root), inline_override=_policy_copy(selection["policyOverride"])) return selection @@ -172,9 +172,8 @@ 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 = [ - step for step in (((explicit_override_payload.get("workflow") or {}).get("sequence")) or []) if isinstance(step, str) - ] + 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 @@ -297,7 +296,7 @@ def explicit_policy_payload(project_root: Path) -> tuple[dict[str, Any], str]: def explicit_tea_steps(project_root: Path) -> list[str]: payload, _ = explicit_policy_payload(project_root) - sequence = ((payload.get("workflow") or {}).get("sequence")) or [] + sequence, _ = explicit_policy_sequence(payload) tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} return [step for step in sequence if isinstance(step, str) and step in tea_steps] @@ -356,9 +355,16 @@ def tea_assets_base_path(project_root: Path, assets_root: str) -> Path | None: candidates.append((bundle_root / raw).resolve()) except FileNotFoundError: pass + first_existing: Path | None = None for candidate in candidates: - if candidate.exists(): + 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 @@ -543,7 +549,7 @@ def resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[st def explicit_tea_assets_status(project_root: Path, payload: dict[str, Any], required_steps: list[str]) -> tuple[str, list[str]]: - steps = payload.get("steps") or {} + steps = payload.get("steps") if isinstance(payload.get("steps"), dict) else {} asset_roots: list[str] = [] missing_assets: list[str] = [] for step in required_steps: @@ -588,3 +594,21 @@ def _as_bool(value: Any, default: bool = False) -> bool: if lowered in {"0", "false", "no", "n", "off"}: return False return default + + +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 _policy_copy(payload: dict[str, Any]) -> dict[str, Any]: + return deepcopy(payload) 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 da193b19..17223786 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -173,22 +173,31 @@ validation=$("$scripts" orchestrator-helper verify-step create {story_id} --stat Use the same spawn/monitor/parse pattern as other session-exit steps: ```bash -"$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") +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') else - built_cmd=$("$scripts" tmux-wrapper build-cmd atdd {story_id} --agent "$current_agent" --state-file "$state_file") + echo "[story {N}/{total}] atdd -> skipped (not in policy sequence)" + next_action="proceed" 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"`: @@ -212,8 +221,12 @@ If `dev` is not present in the pinned sequence, skip this phase entirely and pro **Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents. ```bash +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 '.ok == true and (.sequence | index("dev"))' >/dev/null; then +if echo "$policy_sequence" | jq -e '.sequence | index("dev")' >/dev/null; then dev_in_scope=true else echo "[story {N}/{total}] dev -> skipped (not in policy sequence)" 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 fada3faa..8bd286cd 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 @@ -46,18 +46,27 @@ For TEA v1: **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 +if echo "$policy_sequence" | jq -e '.sequence | index("auto")' >/dev/null; 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 (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: @@ -88,33 +97,44 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") For each enabled TEA step: ```bash -"$scripts" orchestrator-helper state-update "$state_file" \ - --set currentStep={step} \ - --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -resolve_agent_for_task "{step}" "$state_file" "{story_id}" -if should_apply_primary_model "$current_agent"; then - built_cmd=$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") -else - built_cmd=$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --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 TEA quality-step scope." + exit 1 fi -session=$("$scripts" tmux-wrapper spawn {step} {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')" {step} --state-file "$state_file") -next_action=$(echo "$parsed" | jq -r '.next_action') +while IFS= read -r tea_step; do + [ -n "$tea_step" ] || continue + "$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" + parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" "$tea_step" --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 "$tea_step=done" \ + --set status=in-progress + else + break + fi +done < <(echo "$policy_sequence" | jq -r '.sequence[] | select(. == "test_automate" or . == "test_review" or . == "nfr" or . == "trace")') ``` -- If `next_action == "proceed"`: - ```bash - "$scripts" orchestrator-helper state-progress "$state_file" \ - --story "${story_id}" \ - --set {step}=done \ - --set status=in-progress - ``` +- If each concrete `tea_step` returns `next_action == "proceed"`: → continue to the next policy-defined step -- If `next_action == "retry"` or the session crashes → apply the retry/fallback pattern +- If any `tea_step` returns `next_action == "retry"` or the session crashes → apply the retry/fallback pattern for that concrete step before continuing - 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 diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 1d36f01b..8d139d81 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -61,6 +61,36 @@ def test_invalid_step_name_rejected(self) -> None: 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() @@ -82,6 +112,11 @@ def test_invalid_verifier_name_rejected(self) -> None: 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_required_asset_missing_fails(self) -> None: shutil.rmtree(self.project_root / ".claude" / "skills" / "bmad-create-story") with self.assertRaises(PolicyError): @@ -173,18 +208,18 @@ def test_unreadable_override_probe_is_wrapped_as_policy_error(self) -> None: 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_does_not_break_standard_inline_selection(self) -> None: + def test_unused_invalid_override_step_breaks_standard_inline_selection(self) -> None: self._write_override( { "workflow": {"sequence": ["create", "atdd", "dev", "review"]}, "steps": {"atdd": {"assets": []}}, } ) - policy = load_effective_policy( - str(self.project_root), - inline_override={"workflow": {"sequence": ["create", "dev", "review"]}}, - ) - self.assertEqual(policy["workflow"]["sequence"], ["create", "dev", "review"]) + 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" diff --git a/tests/test_tea_detection.py b/tests/test_tea_detection.py index 62e07329..7badd7ca 100644 --- a/tests/test_tea_detection.py +++ b/tests/test_tea_detection.py @@ -71,6 +71,16 @@ def test_detect_workflow_track_falls_back_when_project_tea_assets_are_incomplete 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() @@ -95,6 +105,13 @@ def test_detect_workflow_track_rejects_invalid_explicit_standard_override(self) 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( @@ -192,7 +209,7 @@ def test_detect_workflow_track_rejects_explicit_tea_policy_when_nfr_skill_is_mis "workflow": { "sequence": ["create", "atdd", "dev", "test_automate", "test_review", "nfr", "trace", "review"] }, - "steps": tea_steps_override(include_nfr=True), + "steps": tea_steps_override(canonical=True, include_nfr=True), } ) payload = self._detect() diff --git a/tests/test_tea_policy_flow.py b/tests/test_tea_policy_flow.py index 4f3025c8..a4422fc4 100644 --- a/tests/test_tea_policy_flow.py +++ b/tests/test_tea_policy_flow.py @@ -36,6 +36,28 @@ def test_build_run_policy_rejects_tea_track_when_core_skills_are_missing(self) - 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) @@ -204,6 +226,32 @@ def test_build_state_doc_renders_tea_summary_from_pinned_sequence(self) -> None: 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() From 4be658a26f28ff5be3ad3268062a788226ac80be Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 4 Jun 2026 09:27:13 +0100 Subject: [PATCH 26/33] Fix June maintainer workflow regressions --- .../src/story_automator/commands/state.py | 4 ++ .../story_automator/core/runtime_policy.py | 13 ++++ .../src/story_automator/core/tea_policy.py | 4 +- .../steps-c/step-03-execute.md | 17 ++--- .../steps-c/step-03a-execute-review.md | 57 ++++++++++++++-- .../steps-c/step-03b-execute-finish.md | 6 +- tests/test_runtime_policy.py | 10 +++ tests/test_tea_state_rendering.py | 67 +++++++++++++++++++ 8 files changed, 160 insertions(+), 18 deletions(-) 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 5342c50e..38d4eaf6 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -64,6 +64,10 @@ def cmd_build_state_doc(args: list[str]) -> int: 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() stamp = now_utc().strftime("%Y%m%d-%H%M%S") 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 2c0504bf..f0484d0c 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 @@ -14,6 +14,7 @@ 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"} def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]: @@ -402,6 +403,7 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: 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}") @@ -418,6 +420,13 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: 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}") @@ -663,3 +672,7 @@ def _expect_optional_nested_dict(payload: dict[str, Any], key: str, label: str) 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/tea_policy.py b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py index 63acdac3..2301991c 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -84,7 +84,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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")): + 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.") @@ -102,7 +102,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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 + 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: 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 17223786..22e71c17 100644 --- a/skills/bmad-story-automator/steps-c/step-03-execute.md +++ b/skills/bmad-story-automator/steps-c/step-03-execute.md @@ -194,21 +194,22 @@ if echo "$policy_sequence" | jq -e '.sequence | index("atdd")' >/dev/null; then "$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 `next_action == "proceed"`: - ```bash - "$scripts" orchestrator-helper state-progress "$state_file" \ - --story "${story_id}" \ - --set atdd=done \ - --set status=in-progress - ``` +- If ATDD ran and `next_action == "proceed"`: → continue to the next policy-defined step -- If `next_action == "retry"` or session crashed → retry with fallback pattern +- 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. 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 8bd286cd..c379b062 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 @@ -51,7 +51,12 @@ if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then echo "Pinned workflow sequence unavailable; cannot evaluate automate scope." exit 1 fi -if echo "$policy_sequence" | jq -e '.sequence | index("auto")' >/dev/null; then +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 @@ -65,11 +70,11 @@ if echo "$policy_sequence" | jq -e '.sequence | index("auto")' >/dev/null; then result=$("$scripts" monitor-session "$session" --json --agent "$current_agent") "$scripts" tmux-wrapper kill "$session" else - echo "[story {N}/{total}] automate -> skipped (not in policy sequence)" + echo "[story {N}/{total}] automate -> skipped (disabled or not in policy sequence)" fi ``` -- SUCCESS: +- If `auto_in_scope == "true"` and SUCCESS: ```bash # Update Story Progress: mark automate done "$scripts" orchestrator-helper state-progress "{outputFile}" \ @@ -79,7 +84,7 @@ fi ``` 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 "$scripts" orchestrator-helper state-progress "{outputFile}" \ @@ -89,6 +94,7 @@ fi ``` 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 @@ -102,8 +108,28 @@ 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 -while IFS= read -r tea_step; do +resume_summary=$("$scripts" orchestrator-helper state-summary "$state_file") +resume_step=$(echo "$resume_summary" | jq -r '.currentStep // ""') +resume_mode=false +skip_tea_quality_steps=false +case "$resume_step" in + test_automate|test_review|nfr|trace) resume_mode=true ;; + review) skip_tea_quality_steps=true ;; +esac +resume_gate_open=false +mapfile -t tea_steps < <(echo "$policy_sequence" | jq -r '.sequence[] | select(. == "test_automate" or . == "test_review" or . == "nfr" or . == "trace")') +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)" @@ -118,7 +144,10 @@ while IFS= read -r tea_step; do --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')" "$tea_step" --state-file "$state_file") + 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 @@ -126,15 +155,29 @@ while IFS= read -r tea_step; do --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 < <(echo "$policy_sequence" | jq -r '.sequence[] | select(. == "test_automate" or . == "test_review" or . == "nfr" or . == "trace")') +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 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 de04a27a..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 @@ -112,8 +112,12 @@ 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 '.ok == true and (.sequence | index("retro"))' >/dev/null; then +if echo "$policy_sequence" | jq -e '.sequence | index("retro")' >/dev/null; then policy_has_retro=true fi diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index 8d139d81..b2cbe1e7 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -117,6 +117,16 @@ def test_invalid_label_with_markdown_delimiter_rejected(self) -> None: 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): diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index 25c342bb..d02b26e5 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -77,6 +77,29 @@ def test_build_run_policy_generates_tea_sequence_with_optional_nfr_and_manual_ch 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" @@ -233,6 +256,31 @@ def test_build_run_policy_ignores_manual_checkpoints_for_explicit_override(self) 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): @@ -280,6 +328,25 @@ def test_build_state_doc_rejects_non_string_story_range_items(self) -> None: 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() From 521f726f448038f2c1ea9e615302177fc50b5c4f Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 4 Jun 2026 09:32:51 +0100 Subject: [PATCH 27/33] Add bounded maintainer review gate checks --- .../README.md | 55 +++++++++++ scripts/run-smoke-policy-invariants.py | 30 ++++++ scripts/run-smoke-resume-matrix.py | 29 ++++++ tests/test_policy_invariants.py | 66 +++++++++++++ tests/test_progress_invariants.py | 93 +++++++++++++++++++ tests/test_resume_matrix.py | 35 +++++++ 6 files changed, 308 insertions(+) create mode 100644 docs/plans/automator-maintainer-review-gate/README.md create mode 100644 scripts/run-smoke-policy-invariants.py create mode 100644 scripts/run-smoke-resume-matrix.py create mode 100644 tests/test_policy_invariants.py create mode 100644 tests/test_progress_invariants.py create mode 100644 tests/test_resume_matrix.py diff --git a/docs/plans/automator-maintainer-review-gate/README.md b/docs/plans/automator-maintainer-review-gate/README.md new file mode 100644 index 00000000..85c06777 --- /dev/null +++ b/docs/plans/automator-maintainer-review-gate/README.md @@ -0,0 +1,55 @@ +# Maintainer Review Gate + +This gate exists to catch the class of regressions that recent maintainer review +threads on the TEA workflow refactor exposed before asking for another PR pass. + +## Boundaries + +This is intentionally verification-only scope: + +- dedicated invariant tests in standalone files +- dedicated resume-matrix coverage in a standalone file +- dedicated smoke-style runners that can be called explicitly + +It should not expand product behavior by itself. + +## Required Invariants + +Before asking for a new maintainer review, check: + +1. Pinned policy is authoritative. +2. Invalid policy/state fails closed. +3. Skipped steps do not write progress. +4. Resume restarts at the persisted token, not an earlier TEA step. +5. Standard mode stays isolated from TEA-specific progress writes. +6. Markdown-rendered state rows and headers reject unsafe identifiers/labels. + +## Standalone Verification Files + +- `tests/test_policy_invariants.py` +- `tests/test_progress_invariants.py` +- `tests/test_resume_matrix.py` +- `scripts/run-smoke-policy-invariants.py` +- `scripts/run-smoke-resume-matrix.py` + +## Suggested Local Gate + +```bash +PYTHONPATH=skills/bmad-story-automator/src python3 -m unittest \ + tests.test_policy_invariants \ + tests.test_progress_invariants \ + tests.test_resume_matrix +``` + +Optional smoke-style wrappers: + +```bash +PYTHONPATH=skills/bmad-story-automator/src python3 scripts/run-smoke-policy-invariants.py +PYTHONPATH=skills/bmad-story-automator/src python3 scripts/run-smoke-resume-matrix.py +``` + +## Relationship To PR 32 + +PR 32 is the direction for stronger deterministic live-run validation. These +standalone files are a bounded follow-up that starts encoding maintainer repros +locally without widening the product implementation scope. diff --git a/scripts/run-smoke-policy-invariants.py b/scripts/run-smoke-policy-invariants.py new file mode 100644 index 00000000..4d3c64a4 --- /dev/null +++ b/scripts/run-smoke-policy-invariants.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +import sys + + +MODULES = [ + "tests.test_policy_invariants", + "tests.test_progress_invariants", +] + + +def main() -> int: + cmd = [sys.executable, "-m", "unittest", *MODULES] + completed = subprocess.run(cmd, text=True, capture_output=True) + 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..abd75f94 --- /dev/null +++ b/scripts/run-smoke-resume-matrix.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +import sys + + +MODULES = [ + "tests.test_resume_matrix", +] + + +def main() -> int: + cmd = [sys.executable, "-m", "unittest", *MODULES] + completed = subprocess.run(cmd, text=True, capture_output=True) + 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/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..827e6947 --- /dev/null +++ b/tests/test_resume_matrix.py @@ -0,0 +1,35 @@ +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('review) skip_tea_quality_steps=true ;;', 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() From affe950b000072f821d5fe0d8d63e65c98dd57ec Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 4 Jun 2026 10:04:01 +0100 Subject: [PATCH 28/33] Move review gate note out of automator repo --- .../README.md | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 docs/plans/automator-maintainer-review-gate/README.md diff --git a/docs/plans/automator-maintainer-review-gate/README.md b/docs/plans/automator-maintainer-review-gate/README.md deleted file mode 100644 index 85c06777..00000000 --- a/docs/plans/automator-maintainer-review-gate/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Maintainer Review Gate - -This gate exists to catch the class of regressions that recent maintainer review -threads on the TEA workflow refactor exposed before asking for another PR pass. - -## Boundaries - -This is intentionally verification-only scope: - -- dedicated invariant tests in standalone files -- dedicated resume-matrix coverage in a standalone file -- dedicated smoke-style runners that can be called explicitly - -It should not expand product behavior by itself. - -## Required Invariants - -Before asking for a new maintainer review, check: - -1. Pinned policy is authoritative. -2. Invalid policy/state fails closed. -3. Skipped steps do not write progress. -4. Resume restarts at the persisted token, not an earlier TEA step. -5. Standard mode stays isolated from TEA-specific progress writes. -6. Markdown-rendered state rows and headers reject unsafe identifiers/labels. - -## Standalone Verification Files - -- `tests/test_policy_invariants.py` -- `tests/test_progress_invariants.py` -- `tests/test_resume_matrix.py` -- `scripts/run-smoke-policy-invariants.py` -- `scripts/run-smoke-resume-matrix.py` - -## Suggested Local Gate - -```bash -PYTHONPATH=skills/bmad-story-automator/src python3 -m unittest \ - tests.test_policy_invariants \ - tests.test_progress_invariants \ - tests.test_resume_matrix -``` - -Optional smoke-style wrappers: - -```bash -PYTHONPATH=skills/bmad-story-automator/src python3 scripts/run-smoke-policy-invariants.py -PYTHONPATH=skills/bmad-story-automator/src python3 scripts/run-smoke-resume-matrix.py -``` - -## Relationship To PR 32 - -PR 32 is the direction for stronger deterministic live-run validation. These -standalone files are a bounded follow-up that starts encoding maintainer repros -locally without widening the product implementation scope. From 089740fe9b6de9f4bdd911ff23fd72ae4693b4e6 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 4 Jun 2026 11:11:13 +0100 Subject: [PATCH 29/33] Make review gate smoke wrappers standalone --- scripts/run-smoke-policy-invariants.py | 9 ++++++++- scripts/run-smoke-resume-matrix.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/run-smoke-policy-invariants.py b/scripts/run-smoke-policy-invariants.py index 4d3c64a4..e6553bec 100644 --- a/scripts/run-smoke-policy-invariants.py +++ b/scripts/run-smoke-policy-invariants.py @@ -2,8 +2,10 @@ from __future__ import annotations import json +import os import subprocess import sys +from pathlib import Path MODULES = [ @@ -13,8 +15,13 @@ 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] - completed = subprocess.run(cmd, text=True, capture_output=True) + completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env) payload = { "ok": completed.returncode == 0, "modules": MODULES, diff --git a/scripts/run-smoke-resume-matrix.py b/scripts/run-smoke-resume-matrix.py index abd75f94..4326985f 100644 --- a/scripts/run-smoke-resume-matrix.py +++ b/scripts/run-smoke-resume-matrix.py @@ -2,8 +2,10 @@ from __future__ import annotations import json +import os import subprocess import sys +from pathlib import Path MODULES = [ @@ -12,8 +14,13 @@ 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] - completed = subprocess.run(cmd, text=True, capture_output=True) + completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env) payload = { "ok": completed.returncode == 0, "modules": MODULES, From 5e9af12d2ab739ccedb4713503b5426c5f7632ae Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Thu, 4 Jun 2026 11:53:38 +0100 Subject: [PATCH 30/33] Harden standalone smoke wrapper timeouts --- scripts/run-smoke-policy-invariants.py | 23 ++++++++++++++++++++++- scripts/run-smoke-resume-matrix.py | 23 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/scripts/run-smoke-policy-invariants.py b/scripts/run-smoke-policy-invariants.py index e6553bec..6d5e205c 100644 --- a/scripts/run-smoke-policy-invariants.py +++ b/scripts/run-smoke-policy-invariants.py @@ -14,6 +14,14 @@ ] +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) @@ -21,7 +29,20 @@ def main() -> int: existing = env.get("PYTHONPATH", "").strip() env["PYTHONPATH"] = pythonpath if not existing else f"{pythonpath}{os.pathsep}{existing}" cmd = [sys.executable, "-m", "unittest", *MODULES] - completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env) + 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, diff --git a/scripts/run-smoke-resume-matrix.py b/scripts/run-smoke-resume-matrix.py index 4326985f..b5a5e2f9 100644 --- a/scripts/run-smoke-resume-matrix.py +++ b/scripts/run-smoke-resume-matrix.py @@ -13,6 +13,14 @@ ] +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) @@ -20,7 +28,20 @@ def main() -> int: existing = env.get("PYTHONPATH", "").strip() env["PYTHONPATH"] = pythonpath if not existing else f"{pythonpath}{os.pathsep}{existing}" cmd = [sys.executable, "-m", "unittest", *MODULES] - completed = subprocess.run(cmd, text=True, capture_output=True, cwd=repo_root, env=env) + 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, From 51afb46a6ae9d2df3d869d1223bfac0439d70d31 Mon Sep 17 00:00:00 2001 From: bmad Date: Fri, 12 Jun 2026 16:12:46 +0900 Subject: [PATCH 31/33] refactor: consolidate TEA policy boundaries --- .../story_automator/commands/orchestrator.py | 204 +------- .../commands/orchestrator_state.py | 222 ++++++++ .../src/story_automator/commands/state.py | 43 +- .../core/policy_state_rendering.py | 36 ++ .../story_automator/core/runtime_policy.py | 485 +----------------- .../core/runtime_policy_support.py | 468 +++++++++++++++++ .../src/story_automator/core/tea_policy.py | 427 ++------------- .../story_automator/core/tea_policy_assets.py | 237 +++++++++ .../story_automator/core/tea_policy_config.py | 79 +++ .../story_automator/core/workflow_steps.py | 84 +++ .../steps-c/step-03a-execute-review.md | 16 +- tests/test_resume_matrix.py | 4 +- 12 files changed, 1246 insertions(+), 1059 deletions(-) create mode 100644 skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/policy_state_rendering.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/tea_policy_assets.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/tea_policy_config.py create mode 100644 skills/bmad-story-automator/src/story_automator/core/workflow_steps.py 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 8c61b5d1..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,10 +18,7 @@ crash_max_retries, load_runtime_policy, review_max_cycles, - summarize_state_policy_fields, - workflow_sequence, ) -from story_automator.core.state_document import update_story_progress 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 from story_automator.core.success_verifiers import resolve_success_contract, run_success_verifier @@ -48,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: @@ -60,12 +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-progress": _state_progress, + "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, @@ -78,7 +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, + "policy-sequence": policy_sequence_action, + "policy-steps": policy_steps_action, } handler = dispatch.get(action) if handler is None: @@ -86,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) @@ -119,6 +131,7 @@ def _usage(code: int) -> int: 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 @@ -226,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 "" @@ -375,28 +286,6 @@ def _escalate(args: list[str]) -> int: return 0 -def _policy_sequence(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 _commit_ready(args: list[str]) -> int: if not args: print_json({"ready": False, "reason": "story_id required"}) @@ -527,53 +416,6 @@ def _verify_step(args: list[str]) -> int: return exit_code -def _state_progress(args: list[str]) -> int: - if not args: - print_json({"ok": False, "error": "file_not_found"}) - return 1 - state_file = args[0] - try: - if not file_exists(state_file): - print_json({"ok": False, "error": "file_not_found"}) - return 1 - except OSError: - print_json({"ok": False, "error": "state_file_unreadable"}) - return 1 - story_id = "" - updates: dict[str, str] = {} - idx = 1 - while idx < len(args): - if args[idx] == "--story" and idx + 1 < len(args): - story_id = args[idx + 1] - idx += 2 - continue - if args[idx] == "--set" and idx + 1 < len(args): - raw_update = args[idx + 1] - if "=" not in raw_update: - print_json({"ok": False, "error": "invalid_set_argument", "argument": raw_update}) - return 1 - key, value = raw_update.split("=", 1) - updates[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 _parse_context_int(context: str, key: str) -> int: match = re.search(rf"{re.escape(key)}=(\d+)", context) return int(match.group(1)) if match else 0 diff --git a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py new file mode 100644 index 00000000..ae4f4c72 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py @@ -0,0 +1,222 @@ +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=file_exists) -> int: + if not args: + print_json({"ok": False, "error": "file_not_found"}) + return 1 + state_file = args[0] + try: + if not exists_fn(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 38d4eaf6..00708dad 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -6,16 +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, selected_optional_steps_from_sequence, workflow_track_for_sequence +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 - - -def _tea_summary_steps(sequence: list[str]) -> list[str]: - tea_steps = {"atdd", "test_automate", "test_review", "nfr", "trace"} - return [step.replace("_", "-") for step in sequence if step in tea_steps] +from ..core.workflow_steps import selected_optional_steps_from_sequence, workflow_track_for_sequence def cmd_build_state_doc(args: list[str]) -> int: @@ -113,14 +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) - if pinned_track == "tea": - tea_frontmatter = ( - f'workflowTrack: {json.dumps(pinned_track)}\n' - f"selectedOptionalSteps: {json.dumps(pinned_optional_steps)}\n" - f"manualCheckpoints: {json.dumps(policy_selection['manualCheckpoints'])}\n" - f"policyNotes: {json.dumps(policy_selection['notes'])}\n" - ) - text = text.replace("customInstructions: " + custom_instructions + "\n", "customInstructions: " + custom_instructions + "\n" + tea_frontmatter) + 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", {}) @@ -204,18 +201,12 @@ def cmd_build_state_doc(args: list[str]) -> int: "{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)), "{{customInstructions}}": str(config.get("customInstructions", "")), } - tea_block = "" - if pinned_track == "tea": - pinned_tea_steps = _tea_summary_steps(pinned_sequence) - tea_block_lines = [ - "**TEA Configuration:**", - f"- Pinned TEA Steps: {', '.join(pinned_tea_steps) or 'none'}", - f"- Optional Automated Steps: {', '.join(pinned_optional_steps) or 'none'}", - f"- Policy Notes: {'; '.join(policy_selection['notes']) or 'none'}", - "", - ] - tea_block = "\n".join(tea_block_lines) - body["{{teaConfigurationBlock}}"] = tea_block + 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) 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 f0484d0c..30007d95 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,39 @@ 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 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", "atdd", "test_automate", "test_review", "trace", "nfr"} -VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} -VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} -VALID_PARSER_PROVIDERS = {"claude"} -RESERVED_PROGRESS_LABELS = {"story", "status", "git-commit"} - +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 = _load_bundled_policy_shape(root) + 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,9 +41,6 @@ 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, @@ -229,450 +239,3 @@ def story_task_sequence(policy: dict[str, Any]) -> list[str]: 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 _load_bundled_policy_shape(project_root: str | Path | None = None) -> dict[str, Any]: - root = Path(project_root or get_project_root()).resolve() - bundle_root = bundled_skill_root(root) - policy_path = bundle_root / "data" / "orchestration-policy.json" - try: - policy = _read_json(policy_path) - except OSError as exc: - raise PolicyError(f"policy unreadable: {policy_path}") from exc - _validate_policy_shape(policy) - _prune_unreferenced_steps(policy) - _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 _validate_policy_step_references(policy: dict[str, Any]) -> None: - workflow = _expect_optional_dict(policy, "workflow") - steps = policy.get("steps") - if not isinstance(steps, dict): - raise PolicyError("steps must be an object") - 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") - for step in sequence: - if step not in steps: - raise PolicyError(f"workflow.sequence references missing step: {step}") - - -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/runtime_policy_support.py b/skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py new file mode 100644 index 00000000..293ae06a --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_policy_support.py @@ -0,0 +1,468 @@ +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 _validate_policy_step_references(policy: dict[str, Any]) -> None: + workflow = _expect_optional_dict(policy, "workflow") + steps = policy.get("steps") + if not isinstance(steps, dict): + raise PolicyError("steps must be an object") + 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") + for step in sequence: + if step not in steps: + raise PolicyError(f"workflow.sequence references missing step: {step}") + + +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/tea_policy.py b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py index 2301991c..ba42634e 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tea_policy.py +++ b/skills/bmad-story-automator/src/story_automator/core/tea_policy.py @@ -1,37 +1,47 @@ from __future__ import annotations -import json -from copy import deepcopy from pathlib import Path from typing import Any -from .runtime_layout import bundled_story_skill_root, resolve_skill_dir from .runtime_policy import PolicyError, load_effective_policy -from .utils import file_exists, read_text - - -STANDARD_SEQUENCE = ["create", "dev", "auto", "review", "retro"] -TEA_SKILL_ALIASES = { - "atdd": ("bmad-testarch-atdd", "bmad-tea-testarch-atdd"), - "test_automate": ("bmad-testarch-automate", "bmad-tea-testarch-automate"), - "test_review": ("bmad-testarch-test-review", "bmad-tea-testarch-test-review"), - "trace": ("bmad-testarch-trace", "bmad-tea-testarch-trace"), - "nfr": ("bmad-testarch-nfr", "bmad-tea-testarch-nfr"), -} -TEA_TRACK_DEFINITION = { - "coreSequence": ["create", "atdd", "dev", "test_automate", "test_review", "trace", "review"], - "optionalSteps": {"nfr", "retro"}, -} +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)) + 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 = 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, @@ -73,8 +83,8 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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"))) + selected = set(normalize_option_list(config.get("selectedOptionalSteps"))) + manual = set(normalize_option_list(config.get("manualCheckpoints"))) notes: list[str] = [] policy_override: dict[str, Any] = {} @@ -84,9 +94,9 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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: + 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")): + if normalize_option_list(config.get("manualCheckpoints")): notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") return { "policyOverride": {}, @@ -102,18 +112,13 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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) + 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 = list(TEA_TRACK_DEFINITION["coreSequence"][:-2]) - if include_nfr: - sequence.append("nfr") - sequence.extend(TEA_TRACK_DEFINITION["coreSequence"][-2:]) - if include_retro: - sequence.append("retro") + 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), @@ -126,9 +131,9 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An 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: + 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")): + if normalize_option_list(config.get("manualCheckpoints")): notes.append("checkpoint-preview is out of scope for story-automator and was ignored.") return { "policyOverride": {}, @@ -137,7 +142,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An "manualCheckpoints": [], "notes": notes, } - include_retro = "retro" in selected if "retro" in selected else _as_bool(config.get("includeRetro"), True) + 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") @@ -164,7 +169,7 @@ def build_run_policy(project_root: Path, config: dict[str, Any]) -> dict[str, An "manualCheckpoints": [], "notes": notes, } - load_effective_policy(str(project_root), inline_override=_policy_copy(selection["policyOverride"])) + load_effective_policy(str(project_root), inline_override=policy_copy(selection["policyOverride"])) return selection @@ -251,67 +256,10 @@ def detect_workflow_track(project_root: Path) -> dict[str, Any]: } -def selected_optional_steps_from_sequence(sequence: list[str]) -> list[str]: - selected: list[str] = [] - if "nfr" in sequence: - selected.append("nfr") - if "retro" in sequence: - selected.append("retro") - return selected - - -def workflow_track_for_sequence(sequence: list[str]) -> str: - unique_tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} - return "tea" if any(step in unique_tea_steps for step in sequence) else "standard" - - -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_tea_steps(project_root: Path) -> list[str]: payload, _ = explicit_policy_payload(project_root) sequence, _ = explicit_policy_sequence(payload) - tea_steps = {"atdd", "test_automate", "test_review", "trace", "nfr"} - return [step for step in sequence if isinstance(step, str) and step in tea_steps] - - -def has_explicit_tea_policy(project_root: Path) -> bool: - return bool(explicit_tea_steps(project_root)) - - -def explicit_tea_policy_details(project_root: Path) -> tuple[dict[str, Any] | None, str]: - if not has_explicit_tea_policy(project_root): - return None, "" - try: - return load_effective_policy(str(project_root), resolve_assets=True), "" - except (FileNotFoundError, PolicyError, ValueError) as exc: - return None, str(exc) + return tea_steps_from_sequence(sequence) def explicit_project_policy_details(project_root: Path, payload: dict[str, Any]) -> tuple[dict[str, Any] | None, str]: @@ -321,294 +269,3 @@ def explicit_project_policy_details(project_root: Path, payload: dict[str, Any]) return load_effective_policy(str(project_root), resolve_assets=True), "" except (FileNotFoundError, PolicyError, ValueError) as exc: return None, str(exc) - - -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.get(step, ()) - for skill_name in candidates: - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - continue - if file_exists(str(skill_dir / "SKILL.md")): - return skill_name - return candidates[0] if candidates else "" - - -def tea_skill_installed(project_root: Path, step: str) -> bool: - candidates = TEA_SKILL_ALIASES.get(step, ()) - for skill_name in candidates: - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - continue - if file_exists(str(skill_dir / "SKILL.md")): - return True - return False - - -def tea_step_contracts(project_root: Path, assets_root: str, *, include_nfr: bool) -> dict[str, Any]: - steps: dict[str, Any] = {} - for step, label in ( - ("atdd", "atdd"), - ("test_automate", "test-automate"), - ("test_review", "test-review"), - ("trace", "trace"), - ): - prompt, schema = tea_contract_files(project_root, assets_root, step) - steps[step] = { - "label": 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"}, - } - if include_nfr: - prompt, schema = tea_contract_files(project_root, assets_root, "nfr") - steps["nfr"] = { - "label": "nfr", - "assets": { - "skillName": resolve_tea_skill_name(project_root, "nfr"), - "workflowCandidates": ["workflow.md", "workflow.yaml"], - "instructionsCandidates": [], - "checklistCandidates": ["checklist.md"], - "templateCandidates": [], - "required": ["skill"], - }, - "prompt": {"templateFile": 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 ["atdd", "test_automate", "test_review", "trace"]): - skill_name = resolve_tea_skill_name(project_root, step) - if not skill_name: - missing.append(TEA_SKILL_ALIASES[step][0]) - continue - try: - skill_dir = resolve_skill_dir(project_root, skill_name) - except ValueError: - missing.append(TEA_SKILL_ALIASES[step][0]) - continue - if file_exists(str(skill_dir / "SKILL.md")): - available.append(skill_name) - else: - missing.append(TEA_SKILL_ALIASES[step][0]) - return available, missing - - -def resolved_explicit_tea_status(policy: dict[str, Any], required_steps: list[str]) -> tuple[list[str], str]: - available: list[str] = [] - asset_roots: list[str] = [] - steps = policy.get("steps") or {} - for step in required_steps: - if not isinstance(steps.get(step), dict): - continue - contract = steps[step] - assets = contract.get("assets") or {} - skill_name = str(assets.get("skillName") or "").strip() - if skill_name: - available.append(skill_name) - prompt = contract.get("prompt") or {} - template_file = str(prompt.get("templateFile") or "").strip() - if template_file: - root = str(Path(template_file).parent.parent).replace("\\", "/") - if root and root not in asset_roots: - asset_roots.append(root) - return available, ", ".join(asset_roots) - - -def 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 - - -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 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 _policy_copy(payload: dict[str, Any]) -> dict[str, Any]: - return deepcopy(payload) 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-03a-execute-review.md b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md index c379b062..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 @@ -110,14 +110,20 @@ if ! echo "$policy_sequence" | jq -e '.ok == true' >/dev/null; then 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 -case "$resume_step" in - test_automate|test_review|nfr|trace) resume_mode=true ;; - review) skip_tea_quality_steps=true ;; -esac +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 "$policy_sequence" | jq -r '.sequence[] | select(. == "test_automate" or . == "test_review" or . == "nfr" or . == "trace")') +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 diff --git a/tests/test_resume_matrix.py b/tests/test_resume_matrix.py index 827e6947..cee484c9 100644 --- a/tests/test_resume_matrix.py +++ b/tests/test_resume_matrix.py @@ -20,7 +20,9 @@ def test_step_01b_routes_review_and_tea_tokens_to_step_03a(self) -> None: 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('review) skip_tea_quality_steps=true ;;', 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) From 11fdd6f44ec46c1865433ca3f62e936a853cea3e Mon Sep 17 00:00:00 2001 From: bmad Date: Fri, 12 Jun 2026 16:23:53 +0900 Subject: [PATCH 32/33] fix: preserve state progress dependency patching --- .../src/story_automator/commands/orchestrator_state.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index ae4f4c72..2debfd82 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator_state.py @@ -168,13 +168,14 @@ def policy_steps_action(args: list[str]) -> int: -def state_progress_action(args: list[str], *, exists_fn=file_exists) -> int: +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_fn(state_file): + if not exists(state_file): print_json({"ok": False, "error": "file_not_found"}) return 1 except OSError: From 90fed7c76a1deb3f66b09c4e6261aebd3f338396 Mon Sep 17 00:00:00 2001 From: bmad Date: Fri, 12 Jun 2026 21:56:06 +0900 Subject: [PATCH 33/33] fix: address bot review feedback --- .../src/story_automator/commands/state.py | 24 +++++++++++++++++++ .../story_automator/core/runtime_policy.py | 4 +++- .../core/runtime_policy_support.py | 13 ---------- tests/test_orchestrator_parse.py | 10 +++++++- tests/test_runtime_policy.py | 15 ++++++++++++ tests/test_tea_state_rendering.py | 12 ++++++++++ 6 files changed, 63 insertions(+), 15 deletions(-) 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 00708dad..d2565cf5 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -247,8 +247,32 @@ def cmd_build_run_policy(args: list[str]) -> int: 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): 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 30007d95..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,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy import os from pathlib import Path from typing import Any @@ -59,7 +60,8 @@ def load_effective_policy( raise except OSError as exc: raise PolicyError(f"project override unreadable: {override_path}") from exc - policy = _deep_merge(_deep_merge(bundled, override), inline_override or {}) + 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) 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 index 293ae06a..a65ba61f 100644 --- 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 @@ -222,19 +222,6 @@ def _validate_policy_shape(policy: dict[str, Any]) -> None: raise PolicyError(f"invalid required assets for {name}") -def _validate_policy_step_references(policy: dict[str, Any]) -> None: - workflow = _expect_optional_dict(policy, "workflow") - steps = policy.get("steps") - if not isinstance(steps, dict): - raise PolicyError("steps must be an object") - 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") - for step in sequence: - if step not in steps: - raise PolicyError(f"workflow.sequence references missing step: {step}") - - 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", {}) diff --git a/tests/test_orchestrator_parse.py b/tests/test_orchestrator_parse.py index 75e04520..dd700890 100644 --- a/tests/test_orchestrator_parse.py +++ b/tests/test_orchestrator_parse.py @@ -234,7 +234,15 @@ def _write_tea_assets(project_root: Path) -> None: prompts.mkdir(parents=True, exist_ok=True) parse.mkdir(parents=True, exist_ok=True) (prompts / "atdd.md").write_text("ATDD {{story_id}}\n", encoding="utf-8") - (parse / "atdd.json").write_text(json.dumps({"requiredKeys": ["status", "failing_tests_created", "summary", "next_action"], "schema": {"status": "SUCCESS|FAILURE|AMBIGUOUS", "failing_tests_created": "true|false", "summary": "brief description", "next_action": "proceed|retry|escalate"}}), encoding="utf-8") + 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]: diff --git a/tests/test_runtime_policy.py b/tests/test_runtime_policy.py index b2cbe1e7..0098a02e 100644 --- a/tests/test_runtime_policy.py +++ b/tests/test_runtime_policy.py @@ -56,6 +56,21 @@ def test_inline_override_deep_merges_after_project_override(self) -> None: 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({"workflow": {"sequence": ["create", "ship"]}, "steps": {"ship": {"success": {"verifier": "session_exit"}}}}) with self.assertRaises(PolicyError): diff --git a/tests/test_tea_state_rendering.py b/tests/test_tea_state_rendering.py index d02b26e5..4283790b 100644 --- a/tests/test_tea_state_rendering.py +++ b/tests/test_tea_state_rendering.py @@ -362,6 +362,18 @@ def test_build_run_policy_drops_nfr_when_nfr_skill_is_missing(self) -> None: 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()