diff --git a/skills/bmad-story-automator/src/story_automator/commands/basic.py b/skills/bmad-story-automator/src/story_automator/commands/basic.py index 3869f8ea..2f8cb16a 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/basic.py +++ b/skills/bmad-story-automator/src/story_automator/commands/basic.py @@ -141,7 +141,7 @@ def cmd_ensure_stop_hook(args: list[str]) -> int: def cmd_stop_hook(_: list[str]) -> int: - sys.stdin.read() + raw = sys.stdin.read() if os.environ.get("STORY_AUTOMATOR_CHILD", "").lower() == "true": return 0 marker = active_marker_path() @@ -151,6 +151,16 @@ def cmd_stop_hook(_: list[str]) -> int: payload = json.loads(marker.read_text()) except json.JSONDecodeError: return 0 + owner = (payload.get("ownerSession") or "").strip() + if owner: + sid = "" + try: + if raw.strip(): + sid = (json.loads(raw) or {}).get("session_id", "") or "" + except json.JSONDecodeError: + sid = "" + if sid and sid != owner: + return 0 remaining = payload.get("storiesRemaining", 0) if isinstance(remaining, str) and remaining.isdigit(): remaining = int(remaining) 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..41938cf4 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -191,6 +191,7 @@ def _marker(args: list[str]) -> int: "heartbeat": options["heartbeat"] or iso_now(), "pid": int(options["pid"] or "0"), "projectSlug": options["project-slug"], + "ownerSession": os.environ.get("CLAUDE_CODE_SESSION_ID", ""), } atomic_write(marker_file, json.dumps(payload, indent=2) + "\n") print(f"Marker created: {marker_file}") diff --git a/tests/test_stop_hooks.py b/tests/test_stop_hooks.py index a27513b9..56a179b8 100644 --- a/tests/test_stop_hooks.py +++ b/tests/test_stop_hooks.py @@ -13,6 +13,7 @@ from unittest.mock import patch from story_automator.commands.basic import cmd_ensure_stop_hook, cmd_stop_hook +from story_automator.commands.orchestrator import cmd_orchestrator_helper REPO_ROOT = Path(__file__).resolve().parents[1] @@ -416,6 +417,56 @@ def test_stop_hook_uses_project_root_env_when_invoked_from_nested_directory(self payload = json.loads(stdout.getvalue()) self.assertEqual(payload["decision"], "block") + def test_stop_hook_blocks_the_owning_session(self) -> None: + self._install_bundle(".agents") + marker = self.project_root / ".agents" / ".story-automator-active" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(json.dumps({"storiesRemaining": 2, "ownerSession": "owner-123"}), encoding="utf-8") + stdout = io.StringIO() + with ( + patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root)}, clear=False), + patch("story_automator.commands.basic.sys.stdin", io.StringIO(json.dumps({"session_id": "owner-123"}))), + patch("os.getcwd", return_value=str(self.project_root)), + redirect_stdout(stdout), + ): + code = cmd_stop_hook([]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(stdout.getvalue())["decision"], "block") + + def test_stop_hook_allows_a_different_session_while_run_active(self) -> None: + self._install_bundle(".agents") + marker = self.project_root / ".agents" / ".story-automator-active" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(json.dumps({"storiesRemaining": 2, "ownerSession": "owner-123"}), encoding="utf-8") + stdout = io.StringIO() + with ( + patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root)}, clear=False), + patch("story_automator.commands.basic.sys.stdin", io.StringIO(json.dumps({"session_id": "other-session"}))), + patch("os.getcwd", return_value=str(self.project_root)), + redirect_stdout(stdout), + ): + code = cmd_stop_hook([]) + self.assertEqual(code, 0) + self.assertEqual(stdout.getvalue(), "") + + def test_marker_create_records_owner_session_from_env(self) -> None: + self._install_bundle(".agents") + stdout = io.StringIO() + with ( + patch.dict( + os.environ, + {"PROJECT_ROOT": str(self.project_root), "CLAUDE_CODE_SESSION_ID": "owner-xyz"}, + clear=False, + ), + patch("os.getcwd", return_value=str(self.project_root)), + redirect_stdout(stdout), + ): + code = cmd_orchestrator_helper(["marker", "create", "--epic", "e1", "--story", "e1-1", "--remaining", "2"]) + self.assertEqual(code, 0) + marker = self.project_root / ".agents" / ".story-automator-active" + payload = json.loads(marker.read_text(encoding="utf-8")) + self.assertEqual(payload["ownerSession"], "owner-xyz") + def test_ensure_stop_hook_codex_updates_dotted_features_toml(self) -> None: self._install_bundle(".agents") codex_dir = self.project_root / ".codex"