Skill: gostlightai/agent-step-sequencer (version 1.1.0)

--- name: agent-step-sequencer description: Multi-step scheduler for in-depth agent requests. Detects when user needs multiple steps, suggests plan and waits for confirmation, persists state, and runs heartbeat-aware flow. Use when requests have 3+ actions, sequential dependencies, output dependencies, or high scope/risk. metadata: {"openclaw":{"emoji":"🔗","requires":{"bins":["python3"],"env":["STEP_AGENT_CMD"]},"install":[{"id":"apt","kind":"apt","package":"python3","bins":["python3"],"label":"Install Python 3","os":["linux"]},{"id":"brew","kind":"brew","formula":"python@3","bins":["python3"],"label":"Install Python 3","os":["darwin"]}]}} --- # Agent Step Sequencer Multi-step scheduler for in-depth requests. Enables step-based actions with heartbeat integration—survives gateway reset mid-step. ## Core Pattern 1. **Interpret** when user request requires multiple steps 2. **Suggest** step plan, wait for confirmation 3. **Persist** state.json (with plan format) 4. **Agent invokes** `scripts/step-sequencer-check.py` immediately (no wait for heartbeat) 5. **Heartbeat** (e.g. every 5 min) also invokes the script—keeps sequencer aligned with email jobs and other heartbeat tasks **Critical:** If gateway resets mid-step, next heartbeat reads state and resumes correctly. --- ## Plan Format Agent builds a plan when user approves. During approval, agent asks: **Use 2-minute delay between steps?** Recommended for rate-limit–sensitive API calls. User chooses; agent sets `stepDelayMinutes` (0 or 2) in state. Each step has `title`, `instruction`, and optionally `requiredOutputs` (paths relative to workspace that must exist before the step is marked DONE): ```json { "plan": { "steps": { "step-1": { "title": "Research topic X", "instruction": "Research topic X and produce a concise summary", "requiredOutputs": ["study/summary.md"] }, "step-2": { "title": "Write paper", "instruction": "Using the summary from step 1, write a research paper..." } } }, "stepQueue": ["step-1", "step-2"], "currentStep": 0, "stepRuns": {}, "stepDelayMinutes": 0, "status": "IN_PROGRESS" } ``` - **title**: Human-readable label - **instruction**: Full instruction for the agent (research, summarize, pull X from Y, etc.) - **requiredOutputs** (optional): List of paths (relative to workspace). Runner marks step DONE only if agent exits 0 and all these paths exist; otherwise step is FAILED with "Missing required outputs: …". --- ## Roles - **Agent**: Builds plan, persists state; does not touch state during step execution. Takes prompts. - **Runner** (`step-sequencer-runner.py`): Invokes agent with step instruction, waits for exit, marks DONE/FAILED. Applies stepDelayMinutes. On retry, agent gets troubleshoot prompt. - **Check script** (`step-sequencer-check.py`): If work to do, invokes runner. Handles FAILED → retry (reset PENDING, invoke runner). - **Heartbeat**: Invokes check script on schedule. --- ## Step execution: autonomous recovery **Do not stop mid-step to ask the user.** When executing a step, if something fails (empty fetch, API error, source unavailable): 1. **Retry** once (same source/URL) if it might be transient. 2. **Try an alternative** (e.g. CoinGecko instead of CoinMarketCap, different endpoint or token) and complete the step with what you can. 3. **Document and exit** only if you truly cannot complete the step—then exit non-zero so the runner marks FAILED; the scheduler will retry with a troubleshoot prompt. Do not stop silently. If you cannot complete the step after retry and alternatives: **actively prompt the user**—post a short message that you hit a snag, what failed, and what you tried (e.g. "Step 2 (research Meteora) failed: CoinMarketCap fetch empty, tried CoinGecko—also empty. Need another source or skip this token."). Then exit non-zero so the runner marks FAILED and the scheduler can retry or add to blockers. Never just stop without telling the user. --- ## How Agent Determines Multi-Step **Agent must suggest before proceeding.** When MULTI_STEP, propose the step plan and wait for confirmation before executing. ``` MULTI_STEP = (action_count >= 3) OR has_sequential_language OR has_output_dependency OR high_scope_or_risk OR user_requests_steps OR contains_setup_keywords SINGLE_STEP = (action_count == 1) AND NOT has_output_dependency AND immediate_execution DECISION = IF MULTI_STEP THEN suggest_multi_step → wait for confirm → proceed ELSE single_step ``` **Definitions:** | Criterion | Meaning | |-----------|---------| | `action_count` | Number of distinct actions (file edits, commands, etc.) | | `has_sequential_language` | "then", "after", "first...then", "step 1" | | `has_output_dependency` | Step B needs output from step A | | `high_scope_or_risk` | Many files, destructive ops, migration | | `user_requests_steps` | "step by step", "break this down", "one at a time" | | `contains_setup_keywords` | "set up", "migrate", "implement from scratch", "full X", "complete Y" | --- ## State Schema See [references/state-schema.md](references/state-schema.md). Key fields: - `plan.steps`: step definitions (`title`, `instruction`, optional `requiredOutputs`) - `stepQueue`, `currentStep`, `stepRuns` - `stepDelayMinutes`: 0 = no delay; 2 = 2 min between steps - `blockers`, `lastHeartbeatIso`, `artifacts` --- ## Heartbeat Flow Heartbeat invokes `scripts/step-sequencer-check.py`. Agent also invokes it right after persisting state. 1. Read state.json 2. If no state or status=DONE → do nothing 3. If step FAILED → bump tries, reset to PENDING, invoke runner (immediate retry) 4. If step DONE → advance currentStep, invoke runner 5. If step PENDING or IN_PROGRESS → invoke runner 6. Update lastHeartbeatIso Runner invokes agent (configurable via `STEP_AGENT_CMD`). Runner applies stepDelayMinutes. --- ## Failure Flow 1. Runner marks step FAILED, stores error in stepRuns 2. Runner invokes check script immediately (no heartbeat wait) 3. Check script bumps tries, resets status to PENDING, invokes runner 4. Runner invokes agent with troubleshoot prompt: "Step X failed (tries: N). Previous run ended with: [error]. Please troubleshoot and retry: [instruction]" 5. Repeats until DONE or max retries / blockers --- ## Flow Diagrams ### Check script → Runner ```mermaid flowchart TD A[Heartbeat or Agent] --> B[step-sequencer-check.py] B --> C{Work to do?} C -->|No| D[Do nothing] C -->|Yes| E[Invoke runner] E --> F[step-sequencer-runner.py] F --> G[Invoke agent with instruction] G --> H{Agent exit} H -->|Success| I[Mark DONE] H -->|Fail| J[Mark FAILED, invoke check script] I --> K[Check advances or done] J --> B ``` ### User flow (propose + persist) ```mermaid flowchart TD U[User Request] --> V{Complex enough?} V -->|No| W[Execute directly] V -->|Yes| X[Propose step plan] X --> Y[User confirms] Y --> Z[Persist state.json with plan] Z --> AA[Agent invokes step-sequencer-check] AA --> AB[Runner invokes agent - step 1] AB --> AC[Heartbeat also invokes on schedule] ``` --- ## Configuration | Env | Description | |-----|-------------| | `STEP_AGENT_CMD` | **Required.** Command to invoke agent (space-separated). Prompt appended as last arg. Example: `openclaw agent --message` | | `STEP_RUNNER` | Path to step-sequencer-runner.py (optional) | | `STEP_MAX_RETRIES` | Max retries on FAILED before adding to blockers. Default: 3 | OpenClaw: Wire `STEP_AGENT_CMD` to OpenClaw's agent invocation (e.g. `openclaw agent --message`). **Security:** Set `STEP_AGENT_CMD` only to your trusted agent binary. Do not use shell interpreters (`bash`, `sh`, etc.) or `-c`/`-e`—the runner rejects these to prevent command injection. The instruction from state.json is passed as a single argument; it is never executed by a shell. --- ## Final Deliverables Step When all steps complete: - Confirm all requirements of the steps are met - Produce summary with links or paths to any files created/written - Mark state DONE → on subsequent heartbeats, scheduler does nothing --- ## Installation ```bash clawhub install agent-step-sequencer ``` Manual copy: ```bash cp -r agent-step-sequencer ~/.openclaw/skills/agent-step-sequencer ``` **Heartbeat integration** — Add this to your heartbeat (or have the agent add it): ```bash # Agent Step Sequencer check (add to heartbeat cycle) python3 ~/.openclaw/skills/agent-step-sequencer/scripts/step-sequencer-check.py ~/.openclaw/workspace/state.json ``` Or if skill is in workspace: `python3 ~/.openclaw/workspace/skills/agent-step-sequencer/scripts/step-sequencer-check.py ~/.openclaw/workspace/state.json` Set `STEP_AGENT_CMD` to your agent invocation before running. Agent should invoke the check script immediately after persisting state.

path: CHANGELOG.md
content: # Changelog

## 1.1.0 — 2026-02-10

This release fixes the core execution failures encountered in production and addresses the OpenClaw security scan findings. The sequencer was silently marking steps as completed without performing any real work, and the documented CLI command did not exist. Both issues are now resolved.

### Breaking

- **STEP_AGENT_CMD is now required.**
  Previously defaulted to `echo`, which caused steps to silently pass without invoking the agent. The runner now exits with code 2 and a clear error if this variable is unset.

### Fixed

- **Corrected CLI command references.**
  All documentation and error messages referenced `openclaw ask`, which does not exist. Replaced with the correct `openclaw agent --message` across SKILL.md, README.md, scripts/README.md, and runner validation output.

- **Added binary validation on startup.**
  The runner now checks that the STEP_AGENT_CMD binary exists on PATH before executing any steps. Previously a bad command would silently fail and burn through all retries before surfacing the issue.

- **Step delay now applies between different steps.**
  `stepDelayMinutes` previously only triggered on retries of the same step. It now also applies when advancing to a subsequent step, matching the intended rate-limiting behavior described in the docs.

### Added

- **OpenClaw-compliant metadata in SKILL.md frontmatter.**
  Declares `python3` as a required binary and `STEP_AGENT_CMD` as a required environment variable, with install blocks for apt and brew. The skill will now gate properly during installation and surface missing dependencies.

- **Agent stdout captured in step runs.**
  `stepRuns[step_id].stdout` now stores the first 500 characters of agent output on success. Previously stdout was captured but discarded, making it impossible to observe what a step actually produced.

- **Three new tests added (12 total, all passing).**
  Covers: unset STEP_AGENT_CMD error, nonexistent binary error, and stdout capture on success.

### Security

- **Resolves OpenClaw security scan findings.**
  Python runtime and STEP_AGENT_CMD are now declared in skill metadata. Existing protections (command injection blocking, path traversal prevention, error truncation) are unchanged.

## 1.0.5 — 2026-02-08

Initial public release.

- Required outputs gate: runner marks step DONE only when all listed paths exist.
- Auto-advance: runner invokes check script after step DONE so next step runs immediately.
- Command injection prevention: shell interpreters blocked in STEP_AGENT_CMD.
- Autonomous recovery: agent retries with troubleshoot prompt, alerts user on persistent failure.


path: README.md
content: # Agent Step Sequencer

Multi-step scheduler for in-depth agent requests. Detects when a task needs multiple steps, suggests a plan, waits for confirmation, persists state, and runs a heartbeat-aware flow.

**Core pattern:** Agent proposes plan → user approves → persist state.json → check script invokes runner → runner invokes agent per step → heartbeat keeps it aligned.

---

## Why Agent Step Sequencer?

Tasks like "research X, summarize it, then write a paper" or "pull data from 5 sources and merge" need sequential execution. A single agent run stops when it finishes one step. 

Agent Step Sequencer:

- **Persists state** — survives gateway resets mid-step
- **Schedules steps** — check script invokes runner; runner invokes agent with each step instruction
- **Retries on failure** — troubleshoot prompt, immediate retry (no heartbeat wait)
- **Heartbeat-aware** — runs alongside email jobs and other heartbeat tasks

---

## Critical Rules

- **No cron** — Check script invokes runner directly; heartbeat drives the schedule
- **Delay as approval** — Agent asks during approval: "2-min delay between steps?" (for rate-limit–sensitive calls); user choice sets `stepDelayMinutes`
- **Agent suggests before executing** — When MULTI_STEP, propose plan and wait for confirmation
- **Check script invokes runner** — Check script never runs work; it invokes the runner
- **Runner invokes agent** — Runner passes step instruction to agent; agent executes
- **State is source of truth** — On heartbeat reset, check script reads state and resumes

---

## Flow

### Check script → Runner

```mermaid
flowchart TD
    A[Heartbeat or Agent] --> B[step-sequencer-check.py]
    B --> C{Work to do?}
    C -->|No| D[Do nothing]
    C -->|Yes| E[Invoke runner]
    E --> F[step-sequencer-runner.py]
    F --> G[Invoke agent with instruction]
    G --> H{Agent exit}
    H -->|Success| I[Mark DONE]
    H -->|Fail| J[Mark FAILED, invoke check script]
    I --> K[Check advances or done]
    J --> B
```

### User flow (propose + persist)

```mermaid
flowchart TD
    U[User Request] --> V{Complex enough?}
    V -->|No| W[Execute directly]
    V -->|Yes| X[Propose step plan]
    X --> Y[User confirms]
    Y --> Z[Persist state.json with plan]
    Z --> AA[Agent invokes step-sequencer-check]
    AA --> AB[Runner invokes agent - step 1]
    AB --> AC[Heartbeat also invokes on schedule]
```

---

## File Structure

```
agent-step-sequencer/
├── SKILL.md
├── README.md
├── references/
│   └── state-schema.md
├── scripts/
│   ├── step-sequencer-check.py   # Invoked by heartbeat
│   ├── step-sequencer-runner.py  # Invokes agent per step
│   └── README.md
└── test/
    └── test_step_sequencer.py
```

---

## Quick Start

**Install (ClawHub):**

```bash
clawhub install agent-step-sequencer
```

**Test:**

```bash
python3 test/test_step_sequencer.py
```

**Wire:**

1. Add to heartbeat:
   ```bash
   python3 ~/.openclaw/skills/agent-step-sequencer/scripts/step-sequencer-check.py ~/.openclaw/workspace/state.json
   ```
   (Use `~/.openclaw/workspace/skills/agent-step-sequencer/scripts/...` if skill lives in workspace.)
2. Agent invokes it immediately after persisting state
3. Set `STEP_AGENT_CMD` to your agent invocation (e.g. `openclaw agent --message`)

---

## Configuration

| Env | Description |
|-----|-------------|
| `STEP_AGENT_CMD` | **Required.** Command to invoke agent (space-separated). Prompt appended. Example: `openclaw agent --message` |
| `STEP_MAX_RETRIES` | Max retries on FAILED before adding to blockers. Default: 3 |

**Security:** Use only your trusted agent binary (e.g. `openclaw agent --message`). Do not use `bash`, `sh`, or `-c`—the runner rejects these to prevent command injection.

---

See [SKILL.md](SKILL.md) for full documentation, plan format, and multi-step decision rules.


path: references/state-schema.md
content: # Agent Step Sequencer State Schema

Minimal schema for persisted state. Survives gateway resets mid-step.

## Fields

| Field | Type | Description |
|-------|------|--------------|
| `plan` | object | Step definitions. `plan.steps`: object keyed by stepId, each `{ title, instruction, requiredOutputs? }`. Optional `requiredOutputs`: paths (relative to workspace) that must exist before the step is marked DONE. |
| `stepQueue` | string[] | Ordered step IDs |
| `currentStep` | number | Index/cursor into stepQueue |
| `stepRuns` | object | Keyed by stepId. Each value: `{ status, tries, lastRunIso, stdout?, error? }` |
| `stepDelayMinutes` | number | 0 = no delay between steps; 2 = 2 min delay. Default 0. |
| `blockers` | string[] | If stuck (optional) |
| `lastHeartbeatIso` | string | ISO timestamp when last check ran |
| `artifacts` | string[] | Paths to files created—for final summary |
| `status` | string | `IN_PROGRESS` or `DONE` (no active task) |

## Example

```json
{
  "plan": {
    "steps": {
      "step-1": {
        "title": "Research topic X",
        "instruction": "Research topic X and produce a concise summary",
        "requiredOutputs": ["study/summary.md"]
      },
      "step-2": {
        "title": "Write research paper",
        "instruction": "Using the summary from step 1, write a research paper..."
      }
    }
  },
  "stepQueue": ["step-1", "step-2"],
  "currentStep": 0,
  "stepRuns": {
    "step-1": { "status": "DONE", "tries": 1, "lastRunIso": "2025-02-07T12:00:00Z" }
  },
  "stepDelayMinutes": 0,
  "blockers": [],
  "lastHeartbeatIso": "2025-02-07T13:00:00Z",
  "artifacts": ["/path/to/file1.txt"],
  "status": "IN_PROGRESS"
}
```

## Status Values

- `IN_PROGRESS`: step runner currently executing or queued
- `DONE`: step completed
- `FAILED`: step failed (runner marks this); `error` field used for troubleshoot prompt

## Per-Step Fields

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `PENDING`, `IN_PROGRESS`, `DONE`, or `FAILED` |
| `tries` | number | Execution attempt count |
| `lastRunIso` | string | ISO 8601 timestamp of last execution |
| `stdout` | string | Agent stdout output (first 500 chars). Present on success. |
| `error` | string | Agent stderr output (first 500 chars). Present on failure. |
- No active task: `status` = DONE → heartbeat does nothing


path: scripts/README.md
content: # Scripts

## step-sequencer-check.py

Invoked by heartbeat or agent. Reads `state.json`, invokes runner when work exists.

**Usage:**

```bash
python3 scripts/step-sequencer-check.py [state.json path]
```

**Behavior:**

- No state or status=DONE → do nothing
- Step FAILED → reset to PENDING, invoke runner (immediate retry)
- Step DONE → advance currentStep, invoke runner
- Step PENDING or IN_PROGRESS → invoke runner

---

## step-sequencer-runner.py

Invoked by check script. Runs current step: invokes agent with instruction, waits for exit, marks DONE/FAILED.

**Usage:**

```bash
python3 scripts/step-sequencer-runner.py [state.json path]
```

**Behavior:**

- Reads state, gets current step instruction from plan.steps
- Applies stepDelayMinutes (0 = no delay, 2 = 2 min)
- Invokes agent via STEP_AGENT_CMD
- On retry: uses troubleshoot prompt with previous error
- On FAILED: invokes check script immediately

---

## Configuration

| Env | Description |
|-----|-------------|
| `STEP_AGENT_CMD` | **Required.** Command to invoke agent (space-separated). Prompt appended. Example: `openclaw agent --message` |
| `STEP_RUNNER` | Path to runner script (optional) |
| `STEP_MAX_RETRIES` | Max retries on FAILED before adding to blockers. Default: 3 |

**Security:** Do not set `STEP_AGENT_CMD` to `bash`, `sh`, or `-c`—the runner rejects these to prevent command injection.

---

## Wire

1. Heartbeat calls step-sequencer-check.py on each cycle
2. Agent invokes step-sequencer-check.py immediately after persisting state

---

## Tests

```bash
python3 test/test_step_sequencer.py
```


path: scripts/step-sequencer-check.py
content: #!/usr/bin/env python3
"""
Step-sequencer heartbeat check. Invoked by heartbeat cycle or agent.
Reads state.json, invokes runner when work exists. Does NOT execute work—invokes runner.
"""

import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


def load_state(state_path: Path) -> dict | None:
    if not state_path.exists():
        return None
    with open(state_path) as f:
        return json.load(f)


def save_state(state_path: Path, state: dict) -> None:
    state_path.parent.mkdir(parents=True, exist_ok=True)
    with open(state_path, "w") as f:
        json.dump(state, f, indent=2)


def get_runner_path(scripts_dir: Path) -> Path:
    return Path(os.environ.get("STEP_RUNNER", str(scripts_dir / "step-sequencer-runner.py")))


def invoke_runner(state_path: Path, scripts_dir: Path) -> None:
    runner = get_runner_path(scripts_dir)
    if runner.exists():
        subprocess.run(
            [sys.executable, str(runner), str(state_path)],
            cwd=state_path.parent,
        )


def check(state_path: Path) -> int:
    """
    Run heartbeat check. Returns 0 on success.
    1. Read state.json
    2. If no state or status=DONE → do nothing
    3. If step FAILED → bump tries, reset to PENDING, invoke runner
    4. If step DONE → advance currentStep, invoke runner
    5. If step PENDING or IN_PROGRESS → invoke runner
    6. Update lastHeartbeatIso
    """
    state = load_state(state_path)
    if state is None:
        return 0

    now = datetime.now(timezone.utc).isoformat()
    state["lastHeartbeatIso"] = now

    status = state.get("status", "IN_PROGRESS")
    if status == "DONE":
        save_state(state_path, state)
        return 0

    step_queue = state.get("stepQueue", [])
    current_step = state.get("currentStep", 0)
    step_runs = state.get("stepRuns", {})
    scripts_dir = Path(__file__).resolve().parent

    if not step_queue:
        state["status"] = "DONE"
        save_state(state_path, state)
        return 0

    if current_step >= len(step_queue):
        state["status"] = "DONE"
        save_state(state_path, state)
        return 0

    step_id = step_queue[current_step]
    step_info = step_runs.get(step_id, {"status": "PENDING", "tries": 0})
    step_status = step_info.get("status")

    if step_status == "DONE":
        state["currentStep"] = current_step + 1
        save_state(state_path, state)
        invoke_runner(state_path, scripts_dir)
        return 0

    if step_status == "FAILED":
        tries = step_info.get("tries", 0)
        max_retries = int(os.environ.get("STEP_MAX_RETRIES", "3"))
        if tries >= max_retries:
            state.setdefault("blockers", []).append(f"{step_id}: max retries ({tries}) exceeded")
            save_state(state_path, state)
            return 0
        step_runs[step_id] = {
            "status": "PENDING",
            "tries": tries,
            "error": step_info.get("error", "unknown"),
            "lastRunIso": step_info.get("lastRunIso", now),
        }
        state["stepRuns"] = step_runs
        save_state(state_path, state)
        invoke_runner(state_path, scripts_dir)
        return 0

    # PENDING or IN_PROGRESS: invoke runner
    save_state(state_path, state)
    invoke_runner(state_path, scripts_dir)
    return 0


def main() -> int:
    state_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("state.json")
    if not state_path.is_absolute():
        state_path = Path.cwd() / state_path
    return check(state_path)


if __name__ == "__main__":
    sys.exit(main())


path: scripts/step-sequencer-runner.py
content: #!/usr/bin/env python3
"""
Step-sequencer runner. Invoked by check script or after failure.
Reads state, gets current step instruction, invokes agent, marks DONE/FAILED.
On FAILED: invokes check script immediately for retry.
"""

import json
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path


def load_state(state_path: Path) -> dict | None:
    if not state_path.exists():
        return None
    with open(state_path) as f:
        return json.load(f)


def save_state(state_path: Path, state: dict) -> None:
    state_path.parent.mkdir(parents=True, exist_ok=True)
    with open(state_path, "w") as f:
        json.dump(state, f, indent=2)


# Blocked to prevent command injection: instruction is appended and must not be executed by a shell
_BLOCKED_BASES = frozenset({"bash", "sh", "dash", "zsh", "ksh", "eval", "exec"})
_BLOCKED_ARGS = frozenset({"-c", "-e"})


def _validate_agent_cmd(tokens: list[str]) -> None:
    """Reject STEP_AGENT_CMD values that could execute instruction as shell code."""
    if not tokens:
        return
    base = Path(tokens[0]).name.lower()
    if base in _BLOCKED_BASES:
        raise ValueError(
            f"STEP_AGENT_CMD cannot use shell interpreter '{tokens[0]}'. "
            "Use your agent binary (e.g. openclaw agent --message)."
        )
    if any(a in _BLOCKED_ARGS for a in tokens[1:]):
        raise ValueError(
            "STEP_AGENT_CMD cannot include -c or -e (shell command flags). "
            "Use your agent binary (e.g. openclaw agent --message)."
        )


def get_agent_cmd() -> list[str]:
    """How to invoke the agent. Env STEP_AGENT_CMD (space-separated). Required."""
    cmd = os.environ.get("STEP_AGENT_CMD", "").strip()
    if not cmd:
        raise ValueError(
            "STEP_AGENT_CMD is not set. Set it to your agent command "
            "(e.g. export STEP_AGENT_CMD='openclaw agent --message')."
        )
    tokens = cmd.split()
    _validate_agent_cmd(tokens)
    binary = tokens[0]
    if not shutil.which(binary):
        raise ValueError(
            f"STEP_AGENT_CMD binary '{binary}' not found on PATH. "
            "Ensure it is installed and accessible."
        )
    return tokens


def get_check_script_path(scripts_dir: Path) -> Path:
    return scripts_dir / "step-sequencer-check.py"


def _check_required_outputs(step_def: dict, workspace_root: Path) -> tuple[bool, list[str]]:
    """If step has requiredOutputs, verify each path exists under workspace_root. Return (all_exist, missing_list)."""
    required = step_def.get("requiredOutputs") or step_def.get("required_outputs")
    if not required or not isinstance(required, list):
        return True, []
    root = workspace_root.resolve()
    missing = []
    for rel in required:
        if not isinstance(rel, str):
            continue
        full = (root / rel).resolve()
        try:
            if root not in full.parents and full != root:
                missing.append(rel)  # path escape
            elif not full.is_file() and not full.is_dir():
                missing.append(rel)
        except (OSError, ValueError):
            missing.append(rel)
    return (len(missing) == 0, missing)


def run(state_path: Path) -> int:
    """
    Run current step. Returns 0 on success, 1 on failure.
    1. Read state
    2. Get current step instruction from plan.steps
    3. If stepDelayMinutes > 0 and not first run of step, sleep
    4. Invoke agent (configurable via STEP_AGENT_CMD)
    5. Mark DONE or FAILED in stepRuns
    6. If FAILED: invoke check script immediately
    """
    state = load_state(state_path)
    if state is None:
        return 0

    plan = state.get("plan", {})
    steps = plan.get("steps", {})
    step_queue = state.get("stepQueue", [])
    current_step = state.get("currentStep", 0)
    step_runs = state.get("stepRuns", {})
    step_delay = state.get("stepDelayMinutes", 0)
    scripts_dir = Path(__file__).resolve().parent

    if not step_queue or current_step >= len(step_queue):
        return 0

    step_id = step_queue[current_step]
    step_def = steps.get(step_id, {})
    instruction = step_def.get("instruction", "")
    step_info = step_runs.get(step_id, {"status": "PENDING", "tries": 0})
    tries = step_info.get("tries", 0)

    # Troubleshoot prompt on retry (tries > 0 means we've failed before)
    if tries > 0 and step_info.get("error"):
        error = step_info.get("error", "unknown")
        prompt = (
            f"Step {step_id} failed (tries: {tries}). "
            f"Previous run ended with: {error}. "
            f"Please troubleshoot and retry: {instruction}"
        )
    else:
        prompt = instruction

    # Apply delay: between steps (not the very first step) or on retries
    is_retry = tries > 0 and step_info.get("lastRunIso")
    is_subsequent_step = current_step > 0 and tries == 0
    if step_delay > 0 and (is_retry or is_subsequent_step):
        time.sleep(step_delay * 60)

    now = datetime.now(timezone.utc).isoformat()
    step_runs[step_id] = {
        "status": "IN_PROGRESS",
        "tries": tries + 1,
        "lastRunIso": now,
    }
    state["stepRuns"] = step_runs
    save_state(state_path, state)

    agent_cmd = get_agent_cmd() + [prompt]
    try:
        result = subprocess.run(
            agent_cmd,
            capture_output=True,
            text=True,
            timeout=3600,
            shell=False,
        )
        success = result.returncode == 0
    except subprocess.TimeoutExpired:
        success = False
        result = type("Result", (), {"stderr": "Timeout"})()

    if success and step_def:
        all_present, missing = _check_required_outputs(step_def, state_path.parent)
        if not all_present:
            success = False
            result = type("Result", (), {"stderr": f"Missing required outputs: {', '.join(missing)}"})()

    step_runs[step_id]["status"] = "DONE" if success else "FAILED"
    step_runs[step_id]["lastRunIso"] = datetime.now(timezone.utc).isoformat()
    stdout = getattr(result, "stdout", "") or ""
    if stdout:
        step_runs[step_id]["stdout"] = stdout[:500]
    if not success:
        step_runs[step_id]["error"] = getattr(result, "stderr", str(result))[:500]
    state["stepRuns"] = step_runs
    save_state(state_path, state)

    if not success:
        check_script = get_check_script_path(scripts_dir)
        if check_script.exists():
            subprocess.run(
                [sys.executable, str(check_script), str(state_path)],
                cwd=state_path.parent,
            )
        return 1

    # Advance to next step immediately: invoke check script so it bumps currentStep and runs runner again
    check_script = get_check_script_path(scripts_dir)
    if check_script.exists():
        subprocess.run(
            [sys.executable, str(check_script), str(state_path)],
            cwd=state_path.parent,
        )
    return 0


def main() -> int:
    try:
        state_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("state.json")
        if not state_path.is_absolute():
            state_path = Path.cwd() / state_path
        return run(state_path)
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())


path: test/test_step_sequencer.py
content: #!/usr/bin/env python3
"""
Automated tests for agent-step-sequencer check and runner.
Uses echo/false as agent commands for deterministic behavior.
"""

import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

# Add scripts to path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
CHECK = SCRIPTS_DIR / "step-sequencer-check.py"
RUNNER = SCRIPTS_DIR / "step-sequencer-runner.py"


def run_check(state_path: Path, env: dict | None = None) -> subprocess.CompletedProcess:
    env = env or os.environ.copy()
    return subprocess.run(
        [sys.executable, str(CHECK), str(state_path)],
        cwd=state_path.parent,
        env=env,
        capture_output=True,
        text=True,
    )


def load_state(state_path: Path) -> dict:
    with open(state_path) as f:
        return json.load(f)


def test_basic_flow_two_steps():
    """Check invokes runner; with auto-advance, one run_check can complete both steps."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {
                "steps": {
                    "step-1": {"title": "First", "instruction": "one"},
                    "step-2": {"title": "Second", "instruction": "two"},
                }
            },
            "stepQueue": ["step-1", "step-2"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "echo"

        # Run 1: runner does step 1 -> DONE -> invokes check -> advance -> runner step 2 -> DONE -> check -> advance
        run_check(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "DONE"
        assert s["stepRuns"]["step-2"]["status"] == "DONE"
        assert s["currentStep"] == 2

        # Run 2: check sees currentStep >= len(stepQueue), sets status DONE
        run_check(state_path, env)
        s = load_state(state_path)
        assert s["status"] == "DONE"

    print("test_basic_flow_two_steps: OK")


def test_failure_marks_failed():
    """Agent returns non-zero -> step marked FAILED, error stored."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "Fail", "instruction": "x"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "false"  # always fails

        run_check(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "FAILED"
        assert "error" in s["stepRuns"]["step-1"]

    print("test_failure_marks_failed: OK")


def test_retry_stops_at_max_retries():
    """On FAILED, retries until STEP_MAX_RETRIES, then adds to blockers."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "Fail", "instruction": "x"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "false"
        env["STEP_MAX_RETRIES"] = "2"

        run_check(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "FAILED"
        assert s["stepRuns"]["step-1"]["tries"] >= 2
        assert "blockers" in s
        assert any("step-1" in b for b in s["blockers"])

    print("test_retry_stops_at_max_retries: OK")


def test_recovery_mid_flow():
    """State with step 1 DONE, step 2 PENDING -> one run_check advances and runs step 2."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {
                "steps": {
                    "step-1": {"title": "Done", "instruction": "a"},
                    "step-2": {"title": "Next", "instruction": "b"},
                }
            },
            "stepQueue": ["step-1", "step-2"],
            "currentStep": 0,
            "stepRuns": {"step-1": {"status": "DONE", "tries": 1, "lastRunIso": "2025-01-01T00:00:00Z"}},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "echo"

        run_check(state_path, env)
        s = load_state(state_path)
        assert s["currentStep"] == 2
        assert s["stepRuns"]["step-2"]["status"] == "DONE"

        run_check(state_path, env)  # set status DONE
        s = load_state(state_path)
        assert s["status"] == "DONE"

    print("test_recovery_mid_flow: OK")


def test_no_state_does_nothing():
    """No state file -> check exits 0, does nothing."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "nonexistent.json"
        r = run_check(state_path)
        assert r.returncode == 0

    print("test_no_state_does_nothing: OK")


def test_step_agent_cmd_blocked():
    """STEP_AGENT_CMD=bash -c is rejected (command injection prevention)."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "X", "instruction": "hello"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "bash -c"

        r = subprocess.run(
            [sys.executable, str(RUNNER), str(state_path)],
            cwd=tmp,
            env=env,
            capture_output=True,
            text=True,
        )
        assert r.returncode == 2
        assert "bash" in r.stderr or "shell" in r.stderr.lower()

    print("test_step_agent_cmd_blocked: OK")


def run_runner(state_path: Path, env: dict | None = None) -> subprocess.CompletedProcess:
    env = env or os.environ.copy()
    return subprocess.run(
        [sys.executable, str(RUNNER), str(state_path)],
        cwd=state_path.parent,
        env=env,
        capture_output=True,
        text=True,
    )


def test_required_outputs_missing_fails():
    """Agent exits 0 but requiredOutputs files missing -> step FAILED."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {
                "steps": {
                    "step-1": {
                        "title": "Produce file",
                        "instruction": "write out",
                        "requiredOutputs": ["out.md"],
                    }
                }
            },
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "echo"  # exits 0 but no out.md

        run_runner(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "FAILED"
        assert "Missing required outputs" in s["stepRuns"]["step-1"].get("error", "")

    print("test_required_outputs_missing_fails: OK")


def test_required_outputs_present_succeeds():
    """Agent exits 0 and requiredOutputs exist -> step DONE."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        (Path(tmp) / "artifacts").mkdir(exist_ok=True)
        (Path(tmp) / "artifacts" / "report.md").write_text("done")
        state = {
            "plan": {
                "steps": {
                    "step-1": {
                        "title": "Produce report",
                        "instruction": "write report",
                        "requiredOutputs": ["artifacts/report.md"],
                    }
                }
            },
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "echo"

        run_runner(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "DONE"

    print("test_required_outputs_present_succeeds: OK")


def test_done_state_does_nothing():
    """status=DONE -> check does nothing."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {}},
            "stepQueue": [],
            "currentStep": 0,
            "stepRuns": {},
            "status": "DONE",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        run_check(state_path)
        s = load_state(state_path)
        assert s["status"] == "DONE"

    print("test_done_state_does_nothing: OK")


def test_step_agent_cmd_unset():
    """STEP_AGENT_CMD unset -> runner exits 2 with clear error."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "X", "instruction": "hello"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env.pop("STEP_AGENT_CMD", None)

        r = subprocess.run(
            [sys.executable, str(RUNNER), str(state_path)],
            cwd=tmp,
            env=env,
            capture_output=True,
            text=True,
        )
        assert r.returncode == 2, f"Expected exit 2, got {r.returncode}"
        assert "STEP_AGENT_CMD" in r.stderr

    print("test_step_agent_cmd_unset: OK")


def test_step_agent_cmd_binary_not_found():
    """STEP_AGENT_CMD points to nonexistent binary -> runner exits 2."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "X", "instruction": "hello"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "nonexistent-binary-xyz --message"

        r = subprocess.run(
            [sys.executable, str(RUNNER), str(state_path)],
            cwd=tmp,
            env=env,
            capture_output=True,
            text=True,
        )
        assert r.returncode == 2, f"Expected exit 2, got {r.returncode}"
        assert "not found on PATH" in r.stderr

    print("test_step_agent_cmd_binary_not_found: OK")


def test_stdout_captured_on_success():
    """Agent stdout is stored in stepRuns on success."""
    with tempfile.TemporaryDirectory() as tmp:
        state_path = Path(tmp) / "state.json"
        state = {
            "plan": {"steps": {"step-1": {"title": "Echo", "instruction": "hello world"}}},
            "stepQueue": ["step-1"],
            "currentStep": 0,
            "stepRuns": {},
            "stepDelayMinutes": 0,
            "status": "IN_PROGRESS",
        }
        with open(state_path, "w") as f:
            json.dump(state, f, indent=2)

        env = os.environ.copy()
        env["STEP_AGENT_CMD"] = "echo"

        run_runner(state_path, env)
        s = load_state(state_path)
        assert s["stepRuns"]["step-1"]["status"] == "DONE"
        assert "stdout" in s["stepRuns"]["step-1"]
        assert "hello world" in s["stepRuns"]["step-1"]["stdout"]

    print("test_stdout_captured_on_success: OK")


def main():
    tests = [
        test_no_state_does_nothing,
        test_step_agent_cmd_blocked,
        test_step_agent_cmd_unset,
        test_step_agent_cmd_binary_not_found,
        test_done_state_does_nothing,
        test_basic_flow_two_steps,
        test_failure_marks_failed,
        test_retry_stops_at_max_retries,
        test_recovery_mid_flow,
        test_required_outputs_missing_fails,
        test_required_outputs_present_succeeds,
        test_stdout_captured_on_success,
    ]
    failed = []
    for t in tests:
        try:
            t()
        except Exception as e:
            failed.append((t.__name__, e))
            print(f"{t.__name__}: FAIL - {e}")

    if failed:
        print(f"\n{len(failed)}/{len(tests)} tests failed")
        sys.exit(1)
    print(f"\nAll {len(tests)} tests passed")
    sys.exit(0)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)