diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index e6ff9355..b33bf52a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -266,8 +266,8 @@ verify_common_install() { assert_contains 'build-cmd create {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file"' "$story_dir/steps-c/step-03-execute.md" assert_contains 'build-cmd dev {story_id} --agent "$current_agent" --state-file "$state_file"' "$story_dir/steps-c/step-03-execute.md" assert_contains 'build-cmd dev {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file"' "$story_dir/steps-c/step-03-execute.md" - assert_contains 'build-cmd auto {story_id} --agent "$current_agent" --state-file "$state_file"' "$story_dir/steps-c/step-03a-execute-review.md" - assert_contains 'build-cmd auto {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file"' "$story_dir/steps-c/step-03a-execute-review.md" + assert_contains 'build-cmd auto {story_id} --epic {epic} --agent "$current_agent" --state-file "$state_file"' "$story_dir/steps-c/step-03a-execute-review.md" + assert_contains 'build-cmd auto {story_id} --epic {epic} --agent "$current_agent" --model "$primary_model" --state-file "$state_file"' "$story_dir/steps-c/step-03a-execute-review.md" assert_contains 'should_apply_primary_model' "$story_dir/data/retry-fallback-strategy.md" assert_contains 'parse-output "$review_log" review --state-file "$state_file"' "$story_dir/steps-c/step-03a-execute-review.md" assert_contains 'validation_passed=$(echo "$validation" | jq -r '\''.verified'\'')' "$story_dir/data/retry-fallback-implementation.md" diff --git a/skills/bmad-story-automator/data/prompts/auto.md b/skills/bmad-story-automator/data/prompts/auto.md index f40b37ef..d9abe08a 100644 --- a/skills/bmad-story-automator/data/prompts/auto.md +++ b/skills/bmad-story-automator/data/prompts/auto.md @@ -1,4 +1,5 @@ Execute the BMAD {{label}} workflow for story {{story_id}}. -{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}Story file: `{{implementation_artifacts}}/{{story_prefix}}-*.md` +{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}Story file (use THIS exact file only): `{{implementation_artifacts}}/{{story_key}}.md` +Operate ONLY on that story file. Other epics may have a story with the same bare number (e.g. epic-a-1-2 vs epic-b-1-2) — do NOT match by number or touch any other epic's story; if the exact file above is missing, fall back to `{{implementation_artifacts}}/{{story_prefix}}-*.md` but still restrict to this epic. Auto-apply all discovered gaps in tests. diff --git a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py index 87d048c1..2b1fc6fe 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -24,7 +24,7 @@ from story_automator.core.runtime_layout import active_marker_path, active_marker_project_entry from story_automator.core.success_verifiers import resolve_success_contract, run_success_verifier from story_automator.core.sprint import sprint_status_epic, sprint_status_get -from story_automator.core.story_keys import normalize_story_key, sprint_status_file +from story_automator.core.story_keys import StoryKey, normalize_story_key, resolve_bare_story_for_epic, sprint_status_file from story_automator.core.utils import ( atomic_write, ensure_dir, @@ -87,7 +87,7 @@ def _usage(code: int) -> int: print("Usage: orchestrator-helper [args]", file=target) print("", file=target) print("Actions:", file=target) - print(" sprint-status get ", file=target) + print(" sprint-status get [--epic E] (--epic disambiguates a bare number across epics)", file=target) print(" sprint-status exists", file=target) print(" sprint-status check-epic ", file=target) print(" parse-output ", file=target) @@ -103,7 +103,7 @@ def _usage(code: int) -> int: print(" state-update --set k=v", file=target) print(" escalate ", file=target) print(" commit-ready ", file=target) - print(" normalize-key [--to id|key|prefix|json]", file=target) + print(" normalize-key [--to id|key|prefix|json] [--epic E] (--epic disambiguates a bare number across epics)", file=target) print(" story-file-status ", file=target) print(" verify-step [--state-file path] [--output-file path]", file=target) print(" verify-code-review ", file=target) @@ -124,9 +124,15 @@ def _sprint_status(args: list[str]) -> int: try: if args[0] == "get": if len(args) < 2: - print("Usage: orchestrator-helper sprint-status get ", file=__import__("sys").stderr) + print("Usage: orchestrator-helper sprint-status get [--epic E]", file=__import__("sys").stderr) return 1 - status = sprint_status_get(project_root, args[1]) + lookup = args[1] + epic = _flag_after(args, "--epic") + if epic: + sk = _resolve_with_epic(project_root, args[1], epic) + if sk is not None and sk.key: + lookup = sk.key + status = sprint_status_get(project_root, lookup) if not status.found and status.reason: print_json({"found": False, "status": status.status, "reason": status.reason}) return 0 @@ -390,15 +396,29 @@ def _commit_ready(args: list[str]) -> int: return 0 +def _resolve_with_epic(project_root: str, value: str, epic: str) -> StoryKey | None: + if epic: + sk = resolve_bare_story_for_epic(project_root, value, epic) + if sk is not None: + return sk + return normalize_story_key(project_root, value) + + +def _flag_after(args: list[str], flag: str) -> str: + for idx, a in enumerate(args): + if a == flag and idx + 1 < len(args): + return args[idx + 1] + return "" + + def _normalize_key(args: list[str]) -> int: if not args: print_json({"ok": False, "error": "input required"}) return 1 - fmt = "json" - if len(args) >= 3 and args[1] == "--to": - fmt = args[2] + fmt = _flag_after(args, "--to") or "json" + epic = _flag_after(args, "--epic") try: - result = normalize_story_key(get_project_root(), args[0]) + result = _resolve_with_epic(get_project_root(), args[0], epic) except (OSError, ValueError) as exc: print_json({"ok": False, "error": str(exc), "input": args[0]}) return 1 diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index 1d62e10c..02e38f57 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -8,6 +8,7 @@ from story_automator.core.prompt_rendering import render_step_prompt from story_automator.core.runtime_layout import runtime_provider from story_automator.core.runtime_policy import PolicyError, load_runtime_policy, step_contract +from story_automator.core.story_keys import resolve_bare_story_for_epic from story_automator.core.success_verifiers import resolve_success_contract, run_success_verifier from story_automator.core.tmux_runtime import ( agent_cli, @@ -169,6 +170,7 @@ def _build_cmd(args: list[str]) -> int: idx = 0 state_file = "" model = "" + epic = "" try: while idx < len(tail): if tail[idx] == "--agent": @@ -183,6 +185,10 @@ def _build_cmd(args: list[str]) -> int: state_file = _flag_value(tail, idx, "--state-file") idx += 2 continue + if tail[idx] == "--epic": + epic = _flag_value(tail, idx, "--epic") + idx += 2 + continue extra = f"{extra} {tail[idx]}".strip() idx += 1 except PolicyError as exc: @@ -192,10 +198,16 @@ def _build_cmd(args: list[str]) -> int: story_prefix = story_id.replace(".", "-") root = get_project_root() agent = _resolve_agent_selection(agent, root) + story_key = story_prefix + if epic: + sk = resolve_bare_story_for_epic(root, story_id, epic) + if sk is not None: + story_prefix = sk.prefix + story_key = sk.key or sk.prefix try: policy = load_runtime_policy(root, state_file=state_file) contract = step_contract(policy, step) - prompt = _render_step_prompt(contract, story_id, story_prefix, extra) + prompt = _render_step_prompt(contract, story_id, story_prefix, extra, story_key=story_key) except (OSError, PolicyError) as exc: print(str(exc), file=__import__("sys").stderr) return 1 @@ -223,7 +235,7 @@ def _build_cmd(args: list[str]) -> int: return 0 -def _render_step_prompt(contract: dict[str, object], story_id: str, story_prefix: str, extra_instruction: str) -> str: +def _render_step_prompt(contract: dict[str, object], story_id: str, story_prefix: str, extra_instruction: str, story_key: str = "") -> str: try: return render_step_prompt( contract, @@ -231,6 +243,7 @@ def _render_step_prompt(contract: dict[str, object], story_id: str, story_prefix story_id=story_id, story_prefix=story_prefix, extra_instruction=extra_instruction, + story_key=story_key, ) except (OSError, ValueError) as exc: raise PolicyError(str(exc)) from exc diff --git a/skills/bmad-story-automator/src/story_automator/core/prompt_rendering.py b/skills/bmad-story-automator/src/story_automator/core/prompt_rendering.py index 33c8d9c9..fcae6c65 100644 --- a/skills/bmad-story-automator/src/story_automator/core/prompt_rendering.py +++ b/skills/bmad-story-automator/src/story_automator/core/prompt_rendering.py @@ -13,6 +13,7 @@ def render_step_prompt( story_id: str, story_prefix: str, extra_instruction: str, + story_key: str = "", ) -> str: prompt_cfg = _dict_value(contract.get("prompt")) assets_cfg = _dict_value(contract.get("assets")) @@ -21,6 +22,7 @@ def render_step_prompt( replacements = { "{{story_id}}": story_id, "{{story_prefix}}": story_prefix, + "{{story_key}}": story_key or story_prefix, "{{label}}": str(contract.get("label") or ""), "{{implementation_artifacts}}": implementation_artifacts_relpath(project_root), "{{skill_line}}": _prompt_line("READ this skill first", str(assets.get("skill") or "")), diff --git a/skills/bmad-story-automator/src/story_automator/core/story_keys.py b/skills/bmad-story-automator/src/story_automator/core/story_keys.py index 2a9783e7..dd01062f 100644 --- a/skills/bmad-story-automator/src/story_automator/core/story_keys.py +++ b/skills/bmad-story-automator/src/story_automator/core/story_keys.py @@ -80,6 +80,11 @@ def normalize_story_key_for_epic(project_root: str, epic: str, value: str) -> St return normalize_story_key(project_root, value) +def resolve_bare_story_for_epic(project_root: str, value: str, epic: str) -> StoryKey | None: + num = re.split(r"[.\-]", value)[-1] + return normalize_story_key_for_epic(project_root, epic, f"{epic}-{num}") + + def _complete_story_key(project_root: str, story_id: str, prefix: str, key: str) -> StoryKey: artifacts = implementation_artifacts_dir(project_root) if not key: diff --git a/skills/bmad-story-automator/steps-c/step-03a-execute-review.md b/skills/bmad-story-automator/steps-c/step-03a-execute-review.md index e38bb421..bcf72635 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 @@ -35,9 +35,9 @@ Set: `scripts="{scriptsDir}"` # --command required (see Spawn Pattern in step-03) resolve_agent_for_task "auto" "$state_file" "{story_id}" if should_apply_primary_model "$current_agent"; then - built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") + built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --epic {epic} --agent "$current_agent" --model "$primary_model" --state-file "$state_file") else - built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --state-file "$state_file") + built_cmd=$("$scripts" tmux-wrapper build-cmd auto {story_id} --epic {epic} --agent "$current_agent" --state-file "$state_file") fi session=$("$scripts" tmux-wrapper spawn auto {epic} {story_id} \ --agent "$current_agent" \ diff --git a/tests/test_normalize_story_key.py b/tests/test_normalize_story_key.py index 9f3610fa..266fcc33 100644 --- a/tests/test_normalize_story_key.py +++ b/tests/test_normalize_story_key.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path +from story_automator.commands.orchestrator import _resolve_with_epic from story_automator.core.sprint import sprint_status_epic, sprint_status_get from story_automator.core.story_keys import normalize_story_key, normalize_story_key_for_epic @@ -660,6 +661,28 @@ def test_unrecognized_format_returns_none(self) -> None: # Leading digit is not a valid non-numeric epic prefix. self.assertIsNone(normalize_story_key(str(self.project_root), "9multi.1")) + # --- Epic-qualified resolution (_resolve_with_epic) --- + + def test_resolve_with_epic_disambiguates_bare_number_across_epics(self) -> None: + self._write_sprint_status( + """ + alpha-1-build-foo: ready-for-dev + beta-1-build-bar: ready-for-dev + """ + ) + alpha = _resolve_with_epic(str(self.project_root), "1", "alpha") + assert alpha is not None + self.assertEqual(alpha.key, "alpha-1-build-foo") + beta = _resolve_with_epic(str(self.project_root), "1", "beta") + assert beta is not None + self.assertEqual(beta.key, "beta-1-build-bar") + + def test_resolve_with_epic_without_epic_uses_plain_resolver(self) -> None: + self._write_sprint_status("alpha-1-build-foo: ready-for-dev\n") + result = _resolve_with_epic(str(self.project_root), "alpha-1-build-foo", "") + assert result is not None + self.assertEqual(result.key, "alpha-1-build-foo") + def _write_sprint_status(self, content: str) -> None: path = self.project_root / "_bmad-output" / "implementation-artifacts" / "sprint-status.yaml" path.write_text(textwrap.dedent(content), encoding="utf-8") diff --git a/tests/test_state_policy_metadata.py b/tests/test_state_policy_metadata.py index 802bf343..074e0498 100644 --- a/tests/test_state_policy_metadata.py +++ b/tests/test_state_policy_metadata.py @@ -343,6 +343,25 @@ def test_build_cmd_uses_legacy_ai_command_consistently_for_claude(self) -> None: rendered = stdout.getvalue() self.assertIn("unset CLAUDECODE && claude --print", rendered) + def test_build_cmd_epic_targets_the_matching_epics_story_file(self) -> None: + artifacts = self.project_root / "_bmad-output" / "implementation-artifacts" + artifacts.mkdir(parents=True, exist_ok=True) + (artifacts / "sprint-status.yaml").write_text( + "alpha-1-build-foo: ready-for-dev\nbeta-1-build-bar: ready-for-dev\n", + encoding="utf-8", + ) + for epic, expected, other in ( + ("alpha", "alpha-1-build-foo.md", "beta-1-build-bar.md"), + ("beta", "beta-1-build-bar.md", "alpha-1-build-foo.md"), + ): + stdout = io.StringIO() + with patch_env(self.project_root), redirect_stdout(stdout): + code = _build_cmd(["auto", "1", "--epic", epic]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn(expected, rendered) + self.assertNotIn(other, rendered) + def test_retro_agent_uses_per_task_override_from_state(self) -> None: state_file = self.project_root / "retro-state.md" state_file.write_text(