From 224082766786f84dc8d8390ebb690db0546c5340 Mon Sep 17 00:00:00 2001 From: Dicky Moore Date: Fri, 22 May 2026 14:57:19 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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)