Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion skills/bmad-story-automator/data/prompts/auto.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,7 +87,7 @@ def _usage(code: int) -> int:
print("Usage: orchestrator-helper <action> [args]", file=target)
print("", file=target)
print("Actions:", file=target)
print(" sprint-status get <story_key>", file=target)
print(" sprint-status get <story_key> [--epic E] (--epic disambiguates a bare number across epics)", file=target)
print(" sprint-status exists", file=target)
print(" sprint-status check-epic <epic>", file=target)
print(" parse-output <file> <step>", file=target)
Expand All @@ -103,7 +103,7 @@ def _usage(code: int) -> int:
print(" state-update <file> --set k=v", file=target)
print(" escalate <trigger> <context>", file=target)
print(" commit-ready <story_id>", file=target)
print(" normalize-key <input> [--to id|key|prefix|json]", file=target)
print(" normalize-key <input> [--to id|key|prefix|json] [--epic E] (--epic disambiguates a bare number across epics)", file=target)
print(" story-file-status <story>", file=target)
print(" verify-step <step> <story_or_epic> [--state-file path] [--output-file path]", file=target)
print(" verify-code-review <story>", file=target)
Expand All @@ -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 <story_key>", file=__import__("sys").stderr)
print("Usage: orchestrator-helper sprint-status get <story_key> [--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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -223,14 +235,15 @@ 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,
project_root=get_project_root(),
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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 "")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
23 changes: 23 additions & 0 deletions tests/test_normalize_story_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions tests/test_state_policy_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down