diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index 5121615d..3b5f967c 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -1,13 +1,14 @@ # Agents And Monitoring -This doc explains how Story Automator chooses child agents, builds child-session commands, and decides whether a tmux session is active, completed, stuck, or incomplete. +This doc explains how Story Automator chooses child agents, builds child-session commands, and decides whether a session is active, completed, stuck, or incomplete. ## Agent Model -There are two distinct agent layers: +There are three distinct agent layers: - the orchestrator itself, which runs from a supported top-level agent session -- child sessions, which can run Claude or Codex depending on the agent plan +- child sessions, which run Claude or Codex via tmux (for those harnesses) or OpenCode via native task dispatch +- OpenCode harness uses native task tool dispatch — no tmux, no heartbeat polling Agent selection is driven by: @@ -15,6 +16,19 @@ Agent selection is driven by: - per-task overrides - complexity-based overrides - retro-specific rule: retrospective uses the configured retro agent +- harness type: determines whether child sessions are spawned via tmux or native task tool + +## Harness Detection + +The automator detects the active harness by checking the project root for harness-specific directories and markers: + +| Harness | Detection Method | +|---------|-----------------| +| Claude | `.claude/` directory exists | +| Codex | `.codex/` directory exists | +| OpenCode | `.opencode/` directory exists | + +When multiple harnesses are detected, the priority order is: OpenCode > Codex > Claude. The `BMAD_RUNTIME_PROVIDER` or `STORY_AUTOMATOR_RUNTIME_PROVIDER` environment variable can override this (set to `claude`, `codex`, or `opencode`). ## Agent Resolution @@ -24,14 +38,17 @@ flowchart TD B --> C["Generate deterministic agents file"] C --> D["Resolve agent for story + task"] D --> E{"Task type"} - E -->|create/dev/auto/review| F["Claude or Codex"] + E -->|create/dev/auto/review| F["Claude or Codex via tmux"] E -->|retro| G["Configured retro agent"] + E -->|OpenCode harness| H["Native task tool dispatch"] ``` The generated agents file is a runtime artifact, not just display text. ## Child-Session Command Build +### Claude and Codex (tmux-based) + The helper CLI generates step-specific commands with `tmux-wrapper build-cmd`. Examples: @@ -49,7 +66,25 @@ Important behavior: - long commands are written to `/tmp/sa-cmd-.sh` - review and retro prompts are assembled from resolved sibling skill/workflow files -## tmux Lifecycle +### OpenCode (native task tool) + +When OpenCode is detected as the harness, the automator generates a JSON dispatch payload instead of a tmux command. + +The orchestrating OpenCode agent reads this payload and calls its native `task` tool with the rendered prompt. + +``` + +Key differences from tmux-based harnesses: + +- no tmux session is spawned +- no heartbeat polling — tasks are fire-and-forget +- no output capture files — progress is visible in the task tool's streaming output +- completion is detected via the task tool's return value +- stop hooks are not installed — OpenCode uses native lifecycle (session.idle / process teardown) + +## tmux Lifecycle (Claude and Codex) + +This lifecycle applies only to Claude and Codex child sessions. OpenCode uses native dispatch (see above). ```mermaid sequenceDiagram @@ -77,6 +112,58 @@ Environment details: - `AI_AGENT=` - Codex child sessions use isolated `CODEX_HOME` under `/tmp` +## OpenCode Native Lifecycle + +OpenCode tasks are fire-and-forget. The automator generates a dispatch payload, and the orchestrating agent passes it to the native task tool. + +```mermaid +sequenceDiagram + autonumber + participant O as Orchestrator + participant A as story-automator CLI + participant T as OpenCode Task Tool + + O->>A: opencode-dispatch + A->>A: Render step prompt from policy + A->>A: Resolve model from config.yaml + A-->>O: JSON dispatch payload + O->>T: task(prompt=payload.prompt, subagent_type=payload.subagent_type) + T-->>O: Task result (streaming output visible) + O->>O: Verify completion via task return value +``` + +Important characteristics: + +- no tmux session is created +- no heartbeat polling — the task tool handles lifecycle +- no output capture files — output streams through the task tool +- no stop hooks are installed — OpenCode manages process teardown +- the automator cannot kill or signal an OpenCode task — it must complete or fail on its own + +## OpenCode Model Configuration + +OpenCode supports per-step model overrides via `_bmad/bmm/config.yaml`: + +```yaml +opencode: + models: + orchestrator: "" # global default (empty = opencode default) + create: "" # model for create-story step + dev: "" # model for dev-story step + auto: "" # model for qa-generate-e2e-tests step + review: "" # model for code-review step + retro: "" # model for retrospective step + subagent_type: "coder" # default subagent type for task tool +``` + +Model resolution order: + +1. `--model` CLI flag (explicit override) +2. `opencode.models.` from config.yaml +3. Empty string = OpenCode uses its default model + +If the config block is absent or all values are empty, OpenCode uses whatever model is configured in its own settings. + ## Claude vs Codex Python Story Automator does support Codex child sessions. @@ -93,6 +180,8 @@ Important Codex-specific behavior: ## Monitoring States +### Claude and Codex + `monitor-session` polls helper status and collapses it into a small set of orchestration outcomes. ```mermaid @@ -116,6 +205,16 @@ Important distinctions: - `stuck` means no valid progress signal within the allowed window - `incomplete` is a review-specific result, not a generic session state +### OpenCode + +OpenCode tasks do not use tmux monitoring. The orchestrating agent tracks completion via: + +- the task tool's return value (success/failure) +- the task tool's streaming output (visible in real time) +- verification of sprint status or story file after task completion + +There is no heartbeat, no pane capture, and no crash recovery with auto-retry. If an OpenCode task fails, the orchestrating agent must decide whether to retry or escalate. + ## Review Verification Review sessions add extra verification: @@ -125,8 +224,12 @@ Review sessions add extra verification: This is what prevents false positives where a review session exits but the story was never marked done. +This applies to both tmux-based (Claude/Codex) and native (OpenCode) review tasks. The verification step is the same — only the dispatch mechanism differs. + ## Output Files And Scratch Data +### Claude and Codex + During monitoring, the runtime may write: - `/tmp/sa--output-.txt` @@ -135,6 +238,10 @@ During monitoring, the runtime may write: These are runtime scratch files. They are cleaned on normal session kill. +### OpenCode + +OpenCode tasks do not produce tmux scratch files. Output is captured via the task tool's return value. The dispatch payload is ephemeral — it is read by the orchestrating agent and not persisted. + ## Retry And Escalation ```mermaid @@ -149,11 +256,15 @@ flowchart TD Escalation is intentionally the last step, not the first response. +For OpenCode, retry logic is simpler — there is no tmux session to kill and respawn. The orchestrating agent re-dispatches via the task tool with the same or modified prompt. + ## Practical Operator Notes - if a child session looks done but review verification fails, treat it as incomplete, not complete - if a long command is involved, the child may be running through a temp shell script rather than directly - if monitor output is suspicious, re-check tmux and sprint-status directly +- OpenCode tasks are fire-and-forget — if a task appears stuck, check the task tool output, not tmux +- OpenCode cannot be killed by the automator — if a task hangs, the user must intervene at the OpenCode level ## Read Next diff --git a/install.sh b/install.sh index d8045cfa..9edd5aa4 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,7 @@ Supported skill roots: .agents/skills .claude/skills .codex/skills + .opencode/skills If more than one supported root is complete, all complete roots are updated. @@ -75,7 +76,7 @@ backup_legacy_story_automator_installs() { wrapper_points_to_skill_tree() { local shim="$1" - grep -Eq '\.(claude|agents|codex)/skills/' "$shim" + grep -Eq '\.(claude|agents|codex|opencode)/skills/' "$shim" } wrapper_points_to_legacy_target() { @@ -175,7 +176,7 @@ skill_root_has_any_required_asset() { collect_target_skills_roots() { local candidate - local candidates=(".agents/skills" ".claude/skills" ".codex/skills") + local candidates=(".agents/skills" ".claude/skills" ".codex/skills" ".opencode/skills") for candidate in "${candidates[@]}"; do if skill_root_has_required_entrypoints "$candidate"; then @@ -187,7 +188,7 @@ collect_target_skills_roots() { select_single_incomplete_diagnostic_root() { local candidate local found="" - local candidates=(".agents/skills" ".claude/skills" ".codex/skills") + local candidates=(".agents/skills" ".claude/skills" ".codex/skills" ".opencode/skills") for candidate in "${candidates[@]}"; do if skill_root_has_any_required_asset "$candidate"; then @@ -282,7 +283,7 @@ if [ "${#TARGET_SKILLS_RELS[@]}" -eq 0 ]; then resolve_required_skill "bmad-dev-story" >/dev/null resolve_required_skill "bmad-retrospective" >/dev/null fi - err "Required dependency skills not found under any supported skill root (.agents/skills, .claude/skills, .codex/skills). Install bmad-create-story, bmad-dev-story, and bmad-retrospective under at least one supported root before running this installer." + err "Required dependency skills not found under any supported skill root (.agents/skills, .claude/skills, .codex/skills, .opencode/skills). Install bmad-create-story, bmad-dev-story, and bmad-retrospective under at least one supported root before running this installer." fi backup_legacy_story_automator_installs diff --git a/skills/bmad-story-automator/src/story_automator/cli.py b/skills/bmad-story-automator/src/story_automator/cli.py index 5ef5a801..29c2909b 100644 --- a/skills/bmad-story-automator/src/story_automator/cli.py +++ b/skills/bmad-story-automator/src/story_automator/cli.py @@ -13,6 +13,7 @@ cmd_stop_hook, ) from .commands.orchestrator import cmd_orchestrator_helper +from .commands.opencode_dispatch import cmd_opencode_dispatch from .commands.state import 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 @@ -55,6 +56,7 @@ def main(argv: list[str] | None = None) -> int: "tmux-status-check": cmd_tmux_status_check, "monitor-session": cmd_monitor_session, "orchestrator-helper": cmd_orchestrator_helper, + "opencode-dispatch": cmd_opencode_dispatch, "agent-config": cmd_agent_config, } handler = commands.get(command) @@ -91,6 +93,7 @@ def _usage(stream: object) -> None: "tmux-status-check", "monitor-session", "orchestrator-helper", + "opencode-dispatch", "agent-config", ): print(f" {name}", file=stream) diff --git a/skills/bmad-story-automator/src/story_automator/commands/opencode_dispatch.py b/skills/bmad-story-automator/src/story_automator/commands/opencode_dispatch.py new file mode 100644 index 00000000..4c96a950 --- /dev/null +++ b/skills/bmad-story-automator/src/story_automator/commands/opencode_dispatch.py @@ -0,0 +1,251 @@ +"""OpenCode-native task dispatch command. + +When the automator detects an OpenCode harness, instead of spawning a tmux +session with a CLI command, it generates a task dispatch payload that the +orchestrating OpenCode agent reads and passes to its native task tool. + +Usage: + story-automator opencode-dispatch [--model MODEL] [--state-file PATH] [extra_instruction] + +Model resolution order: + 1. --model CLI flag (explicit override) + 2. opencode.models. from _bmad/bmm/config.yaml + 3. Empty string = OpenCode uses its default model +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +from story_automator.core.agent_config import normalize_model +from story_automator.core.prompt_rendering import render_step_prompt +from story_automator.core.runtime_policy import PolicyError, load_runtime_policy, step_contract +from story_automator.core.runtime_layout import runtime_provider +from story_automator.core.utils import ( + get_project_root, + print_json, + read_text, + strip_inline_yaml_comment, + unquote_scalar, +) + +# Default subagent type when not overridden by config +DEFAULT_SUBAGENT_TYPE = "coder" + + +def cmd_opencode_dispatch(args: list[str]) -> int: + """Generate an OpenCode task dispatch payload. + + Reads the step, story_id, optional model, state-file, and extra + instruction from args. Resolves model from: + 1. --model CLI flag + 2. opencode.models. from config.yaml + 3. Empty string = OpenCode uses its default model + + Outputs a JSON payload suitable for the OpenCode task tool. + """ + if not args or args[0] in {"--help", "-h"}: + _usage(0 if args and args[0] in {"--help", "-h"} else 1) + return 0 if args and args[0] in {"--help", "-h"} else 1 + + step = args[0] + story_id = args[1] if len(args) > 1 else "" + if not story_id: + print("story_id is required", file=sys.stderr) + return 1 + + model = "" + state_file = "" + extra = "" + tail = args[2:] + idx = 0 + while idx < len(tail): + if tail[idx] == "--model": + if idx + 1 >= len(tail): + print("--model requires a value", file=sys.stderr) + return 1 + model = tail[idx + 1] + idx += 2 + continue + if tail[idx] == "--state-file": + if idx + 1 >= len(tail): + print("--state-file requires a value", file=sys.stderr) + return 1 + state_file = tail[idx + 1] + idx += 2 + continue + extra = f"{extra} {tail[idx]}".strip() + idx += 1 + + # Resolve model: CLI flag > config.yaml > empty (opencode default) + if not model: + model = _resolve_model_from_config(step) + + root = get_project_root() + story_prefix = story_id.replace(".", "-") + + try: + policy = load_runtime_policy(root, state_file=state_file) + contract = step_contract(policy, step) + prompt = render_step_prompt( + contract, + project_root=root, + story_id=story_id, + story_prefix=story_prefix, + extra_instruction=extra, + ) + except (OSError, PolicyError) as exc: + print(str(exc), file=sys.stderr) + return 1 + + configured_subagent_type = _resolve_subagent_type_from_config() + if configured_subagent_type: + subagent_type = configured_subagent_type + else: + # Map step names to subagent_type for the task tool + subagent_map = { + "create": "coder", + "dev": "coder", + "auto": "coder", + "review": "reviewer", + "retro": "coder", + } + subagent_type = subagent_map.get(step, DEFAULT_SUBAGENT_TYPE) + + payload = { + "dispatch": "opencode_task", + "step": step, + "storyId": story_id, + "prompt": prompt, + "model": model, + "subagent_type": subagent_type, + } + print_json(payload) + return 0 + + +def _resolve_model_from_config(step: str) -> str: + """Read opencode.models. from _bmad/bmm/config.yaml. + + Uses the project's simple line-by-line YAML parser (no PyYAML dependency). + + Resolution order: + 1. opencode.models. (per-step override) + 2. opencode.models.orchestrator (global override) + 3. "" (empty = opencode uses its default) + + Returns normalized model ID or "" for opencode default. + """ + root = get_project_root() + config_path = Path(root) / "_bmad" / "bmm" / "config.yaml" + if not config_path.is_file(): + return "" + + try: + raw = read_text(config_path) + except (OSError, UnicodeDecodeError): + return "" + + # Simple parser: extract opencode.models. values + # Config structure: + # opencode: + # models: + # orchestrator: "model-name" + # create: "model-name" + # dev: "model-name" + # auto: "model-name" + # review: "model-name" + # retro: "model-name" + in_opencode = False + in_models = False + models: dict[str, str] = {} + + for raw_line in raw.splitlines(): + cleaned = strip_inline_yaml_comment(raw_line).rstrip() + line = cleaned.strip() + if not line or ":" not in line: + continue + + key, value = line.split(":", 1) + key = key.strip() + value = unquote_scalar(value.strip()) + + is_top_level = raw_line == raw_line.lstrip(" \t") + if is_top_level: + in_models = False + in_opencode = key == "opencode" + continue + + # Track nesting: opencode > models + if in_opencode and key == "models": + in_models = True + continue + + # Capture model values under opencode.models + if in_opencode and in_models and value: + models[key] = value + + # Per-step model override first + step_model = models.get(step) + if step_model: + return normalize_model(step_model) + + # Fallback to orchestrator-level model + orchestrator_model = models.get("orchestrator") + if orchestrator_model: + return normalize_model(orchestrator_model) + + return "" + + +def _resolve_subagent_type_from_config() -> str: + """Read opencode.subagent_type from _bmad/bmm/config.yaml.""" + root = get_project_root() + config_path = Path(root) / "_bmad" / "bmm" / "config.yaml" + if not config_path.is_file(): + return "" + + try: + raw = read_text(config_path) + except (OSError, UnicodeDecodeError): + return "" + + in_opencode_block = False + for raw_line in raw.splitlines(): + cleaned = strip_inline_yaml_comment(raw_line).rstrip() + line = cleaned.strip() + if not line: + continue + + is_top_level = raw_line == raw_line.lstrip(" \t") + if is_top_level and line == "opencode:": + in_opencode_block = True + continue + if is_top_level and in_opencode_block: + in_opencode_block = False + + if not in_opencode_block or ":" not in line: + continue + + key, value = line.split(":", 1) + if key.strip() == "subagent_type": + return unquote_scalar(value.strip()) + + return "" + + +def _usage(code: int) -> int: + target = sys.stderr if code else sys.stdout + print("Usage: opencode-dispatch [--model MODEL] [--state-file PATH] [extra_instruction]", file=target) + print("", file=target) + print("Generate an OpenCode task dispatch payload (JSON) for native execution.", file=target) + print("", file=target) + print("Steps: create, dev, auto, review, retro", file=target) + print("", file=target) + print("Model resolution:", file=target) + print(" 1. --model CLI flag (explicit override)", file=target) + print(" 2. opencode.models. from _bmad/bmm/config.yaml", file=target) + print(" 3. Empty string = OpenCode uses its default model", file=target) + return code 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..9161975f 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py +++ b/skills/bmad-story-automator/src/story_automator/commands/orchestrator.py @@ -75,6 +75,7 @@ def cmd_orchestrator_helper(args: list[str]) -> int: "agents-build": agents_build_action, "agents-resolve": agents_resolve_action, "retro-agent": retro_agent_action, + "opencode-status": _opencode_status, } handler = dispatch.get(action) if handler is None: @@ -113,6 +114,7 @@ def _usage(code: int) -> int: print(" agents-build --state-file path --complexity-file path --output path --config-json '{}'", file=target) print(" agents-resolve (--state-file path | --agents-file path) --story ID --task create|dev|auto|review", file=target) print(" retro-agent --state-file path", file=target) + print(" opencode-status [step] [story_id]", file=target) return code @@ -508,3 +510,31 @@ def _flag_value(args: list[str], idx: int, flag: str) -> str: if idx + 1 >= len(args) or not args[idx + 1].strip() or args[idx + 1].startswith("--"): raise PolicyError(f"{flag} requires a value") return args[idx + 1] + + +def _opencode_status(args: list[str]) -> int: + """Report OpenCode native dispatch status. + + Usage: orchestrator-helper opencode-status [step] [story_id] + + Since OpenCode tasks are fire-and-forget (no tmux heartbeat polling), + this command provides a simple status check for the orchestrating agent. + """ + step = args[0] if args else "" + story_id = args[1] if len(args) > 1 else "" + payload = { + "ok": True, + "provider": "opencode", + "dispatchMode": "native_task_tool", + "heartbeatSupported": False, + "outputCapture": "task_result_only", + "step": step, + "storyId": story_id, + "message": ( + "OpenCode uses native task dispatch. Tasks are fire-and-forget. " + "Live output streaming is not available in this harness. " + "Completion is detected via task tool return value." + ), + } + print_json(payload) + return 0 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..05bd6aff 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -202,6 +202,27 @@ def _build_cmd(args: list[str]) -> int: ai_command = os.environ.get("AI_COMMAND", "").strip() if ai_command and not os.environ.get("AI_AGENT"): cli = ai_command + elif agent == "opencode": + # OpenCode: generate task dispatch payload as JSON + import json as _json + subagent_map = { + "create": "coder", + "dev": "coder", + "auto": "coder", + "review": "reviewer", + "retro": "coder", + } + subagent_type = subagent_map.get(step, "coder") + payload = { + "dispatch": "opencode_task", + "step": step, + "storyId": story_id, + "prompt": prompt, + "model": model, + "subagent_type": subagent_type, + } + print(_json.dumps(payload)) + return 0 elif agent != "codex": cli = agent_cli(agent, model) else: @@ -474,7 +495,7 @@ def _raw_agent_selection() -> str: inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) if inferred: return inferred - return value if value in {"claude", "codex", "auto", "runtime"} else "auto" + return value if value in {"claude", "codex", "opencode", "auto", "runtime"} else "auto" def _resolve_agent_selection(agent: str, project_root: str) -> str: @@ -494,4 +515,6 @@ def _infer_agent_from_command(command: str) -> str: return "codex" if "claude" in executable: return "claude" + if "opencode" in executable: + return "opencode" return "" diff --git a/skills/bmad-story-automator/src/story_automator/core/runtime_layout.py b/skills/bmad-story-automator/src/story_automator/core/runtime_layout.py index 1496438c..1364e98c 100644 --- a/skills/bmad-story-automator/src/story_automator/core/runtime_layout.py +++ b/skills/bmad-story-automator/src/story_automator/core/runtime_layout.py @@ -25,7 +25,7 @@ def _current_skills_root() -> Path | None: for parent in Path(__file__).resolve().parents: if parent.name == STORY_SKILL_NAME and parent.parent.name == "skills": skills_root = parent.parent.resolve() - if skills_root.parent.name in {".agents", ".claude", ".codex"}: + if skills_root.parent.name in {".agents", ".claude", ".codex", ".opencode"}: return skills_root return None @@ -44,6 +44,7 @@ def candidate_skills_roots(project_root: str | Path | None = None) -> list[Path] root / ".agents" / "skills", root / ".claude" / "skills", root / ".codex" / "skills", + root / ".opencode" / "skills", Path.home() / ".codex" / "skills", Path.home() / ".claude" / "skills", ] @@ -115,6 +116,8 @@ def _infer_provider_from_root(skills_root: Path | None) -> str: parent = resolved.parent.name if parent == ".claude" or ".claude" in parts: return "claude" + if parent == ".opencode" or ".opencode" in parts: + return "opencode" if parent in {".agents", ".codex"} or ".agents" in parts or ".codex" in parts: return "codex" return "" @@ -126,7 +129,7 @@ def runtime_provider(project_root: str | Path | None = None) -> str: # follow the installed skill root in mixed or migrated workspaces. for name in ("BMAD_RUNTIME_PROVIDER", "STORY_AUTOMATOR_RUNTIME_PROVIDER"): raw = os.environ.get(name, "").strip().lower() - if raw in {"claude", "codex"}: + if raw in {"claude", "codex", "opencode"}: return raw inferred = _infer_provider_from_root(_configured_skills_root()) if inferred: @@ -161,13 +164,15 @@ def active_marker_path(project_root: str | Path | None = None) -> Path: has_story_skill = _skill_present(story_skill_dir) explicit_root = bool(explicit and skills_root.resolve() == explicit.resolve()) current_root = bool(current and skills_root.resolve() == current.resolve()) - if skills_root.parent.name in {".claude", ".agents", ".codex"} and (has_story_skill or explicit_root or current_root): + if skills_root.parent.name in {".claude", ".agents", ".codex", ".opencode"} and (has_story_skill or explicit_root or current_root): return (skills_root.parent / ACTIVE_MARKER_NAME).resolve() except ValueError: pass if provider == "codex": return (root / ".agents" / ACTIVE_MARKER_NAME).resolve() + if provider == "opencode": + return (root / ".opencode" / ACTIVE_MARKER_NAME).resolve() return (root / ".claude" / ACTIVE_MARKER_NAME).resolve() @@ -186,7 +191,7 @@ def resolve_portable_path(path_value: str, project_root: str | Path | None = Non for prefix in neutral_prefixes: if normalized.startswith(prefix): return _resolve_skill_relative_path(normalized[len(prefix) :], project_root) - for prefix in (".claude/skills/", ".agents/skills/", ".codex/skills/"): + for prefix in (".claude/skills/", ".agents/skills/", ".codex/skills/", ".opencode/skills/"): if not normalized.startswith(prefix): continue return _resolve_skill_relative_path(normalized[len(prefix) :], project_root) 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..68f9daac 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 @@ -13,7 +13,7 @@ VALID_STEP_NAMES = {"create", "dev", "auto", "review", "retro"} VALID_VERIFIERS = {"create_story_artifact", "session_exit", "review_completion", "epic_complete"} VALID_ASSET_NAMES = {"skill", "workflow", "instructions", "checklist", "template"} -VALID_PARSER_PROVIDERS = {"claude"} +VALID_PARSER_PROVIDERS = {"claude", "opencode"} def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]: diff --git a/skills/bmad-story-automator/src/story_automator/core/stop_hooks.py b/skills/bmad-story-automator/src/story_automator/core/stop_hooks.py index 16748dcf..0ca1e958 100644 --- a/skills/bmad-story-automator/src/story_automator/core/stop_hooks.py +++ b/skills/bmad-story-automator/src/story_automator/core/stop_hooks.py @@ -45,6 +45,16 @@ def ensure_stop_hook( command: str, timeout: int, ) -> dict[str, Any]: + if provider == "opencode": + # OpenCode uses native lifecycle (session.idle / process teardown). + # No stop-hook installation needed — fire-and-forget model. + return { + "changed": False, + "reason": "skipped_opencode_native_lifecycle", + "provider": "opencode", + "path": "", + "message": "Stop hooks skipped: OpenCode uses native lifecycle (session.idle / process teardown).", + } if provider == "codex": return ensure_codex_stop_hook(project_root=project_root, command=command, timeout=timeout) if not settings_path: diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 75dbe1a8..26e2715b 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -80,13 +80,17 @@ def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") def agent_type() -> str: value = os.environ.get("AI_AGENT", "").strip().lower() - if value in {"claude", "codex"}: + if value in {"claude", "codex", "opencode"}: return value return runtime_provider() def agent_cli(agent: str, model: str = "") -> str: model = (model or "").strip() + if agent == "opencode": + # OpenCode uses native task dispatch — no CLI command needed. + # The orchestrator handles dispatch via the task tool. + return "opencode-native-dispatch" if agent == "codex": base = "codex exec" else: @@ -97,7 +101,34 @@ def agent_cli(agent: str, model: str = "") -> str: def skill_prefix(agent: str) -> str: - return "none" if agent == "codex" else "bmad-" + if agent == "codex": + return "none" + if agent == "opencode": + # OpenCode uses @skills/ prefix (no bmad- prefix needed) + return "" + return "bmad-" + + +def opencode_task_dispatch( + step: str, + prompt: str, + *, + model: str = "", + subagent_type: str = "coder", +) -> dict[str, str]: + """Generate opencode task tool dispatch payload. + + Returns a JSON-serializable dict that the orchestrator reads and + passes to the opencode task tool. The task tool spawns a sub-agent + with the given prompt, eliminating tmux session management entirely. + """ + return { + "dispatch": "opencode_task", + "step": step, + "prompt": prompt, + "model": model, + "subagent_type": subagent_type, + } def _artifact_base_dir() -> Path: @@ -242,6 +273,10 @@ def spawn_session( project_root: str | None = None, mode: str | None = None, ) -> tuple[str, int]: + if selected_agent == "opencode": + # OpenCode uses native task dispatch — no tmux session needed. + # The orchestrator dispatches tasks via the task tool. + return ("opencode-native-dispatch", 0) resolved_mode = _resolve_spawn_mode(mode) if resolved_mode == "legacy": return _spawn_legacy(session, command, selected_agent, project_root) @@ -257,6 +292,10 @@ def heartbeat_check( ) -> tuple[str, float, str, str]: if not session: return ("error", 0.0, "", "no_session") + if selected_agent == "opencode": + # OpenCode tasks are fire-and-forget — no heartbeat polling. + # Task completion is detected via task tool return. + return ("native", 0.0, "", "opencode_task") resolved_mode = _status_mode(session, project_root, mode) if resolved_mode == "legacy": @@ -299,6 +338,16 @@ def session_status( project_root: str | None = None, mode: str | None = None, ) -> dict[str, str | int]: + if not codex and session.startswith("opencode-"): + # OpenCode uses native task dispatch — no tmux session to poll. + return { + "status": "native", + "todos_done": 0, + "todos_total": 0, + "active_task": "opencode_task", + "wait_estimate": 0, + "session_state": "native_dispatch", + } resolved_mode = _status_mode(session, project_root, mode) if resolved_mode == "legacy": return _legacy_session_status(session, full=full, codex=codex, project_root=project_root)