chore: initial monorepo scaffold + WDS Phase 1+2 artifacts

- Nx 22.7 monorepo (pnpm 11.1, TypeScript 5.9, Node 24)
- apps/api: NestJS 11 (CJS conforme CODING-RULES.md PGD-DB-004)
- apps/web: React 19 + Vite 8 (ESM)
- libs/shared/api-interface: Zod contract base
- Docker Compose dev: Postgres 18, Valkey 8, MinIO, Mailpit
- WDS artifacts:
  - design-artifacts/A-Product-Brief/ (5 docs canônicos + 16 dialogs)
  - design-artifacts/B-Trigger-Map/ (hub + 4 personas + feature impact)
- Stack canon: STACK.md v2.2 + CODING-RULES.md v2.0 + brand.md
- AGENTS.md + README.md como entrada para devs/agentes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:34:20 +00:00
commit 17c08e6392
3631 changed files with 855518 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 BMAD
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
# story-automator
Python implementation of BMAD `story-automator`.
This package is the Python port of [`bma-d/bmad-story-automator-go`](https://github.com/bma-d/bmad-story-automator-go).
Status: works as the Python runtime bundled by this repository, but has been tested less than the Go implementation.

View File

@@ -0,0 +1,6 @@
---
name: bmad-story-automator
description: 'Automate the BMAD story build cycle across create, dev, QA automation, review, and retrospective steps with resumable tmux orchestration. Use when the user says "run story automator", "automate stories", or asks to build all stories in an epic.'
---
Follow the instructions in ./workflow.md.

View File

@@ -0,0 +1,102 @@
# Adaptive Retry Strategy
**Purpose:** Handle dev-story failures intelligently based on progress patterns and agent switching.
**Version:** 2.0.0
**See also:** `retry-fallback-strategy.md` for the universal retry/fallback pattern.
---
## Agent Alternation
This strategy works WITH the retry-fallback pattern:
- Odd attempts (1, 3, 5): Use primary agent
- Even attempts (2, 4): Use fallback agent (if configured)
- Plateau detection applies ACROSS agents (same task across both agents = complexity issue)
---
## Progress Tracking
Track failure patterns across retries (per agent):
```
attempt_1_progress = {agent: primary, tasks: 5/9}
attempt_2_progress = {agent: fallback, tasks: 4/9}
attempt_3_progress = {agent: primary, tasks: 5/9} # same as attempt 1
attempt_4_progress = {agent: fallback, tasks: 5/9} # plateau detected
attempt_5_progress = {agent: primary, tasks: 5/9} # confirmed plateau
```
---
## Decision Logic
| Attempt | Condition | Action |
|---------|-----------|--------|
| 1 | FAILURE | Switch to fallback agent, retry |
| 2 | FAILURE, progress > attempt_1 | Switch back to primary, retry with 2x poll interval |
| 2 | FAILURE, progress ≤ attempt_1 | Switch back to primary, analyze if same plateau point |
| 3 | FAILURE, plateau at same task (any agent) | Continue to attempt 4 (confirm with other agent) |
| 4 | FAILURE, plateau confirmed across agents | **DEFER** story (complexity/context limit hit) |
| 4 | FAILURE, variable progress | One more retry with extended timeout |
| 5 | FAILURE, plateau confirmed | **DEFER** story |
| 5 | FAILURE, zero progress all attempts | **ESCALATE** (likely API/connection issue) |
| 5 | FAILURE, variable but incomplete | **ESCALATE** (all retries exhausted) |
---
## Plateau Detection
If `tasks_completed` is identical across 2+ attempts AND the session crashed/stopped at the same task, this indicates a complexity or context limit.
**Indicators:**
- Same task number across multiple attempts
- Session crashes at same point
- No progress despite retries
**Action:** Mark story as "deferred" and continue with next story.
---
## DEFER Action
When a story is deferred (not failed):
1. **Update state:** Mark story as "deferred" in progress table
2. **Log:** "Story {N} deferred - dev-story hit complexity limit at {tasks_completed}/{tasks_total}"
3. **Continue:** Proceed to next story (do not escalate to user unless custom instructions say otherwise)
**Why defer vs fail?**
- Deferred stories can be revisited manually
- Doesn't block automation of remaining stories
- Distinguishes from actual errors (API failures, etc.)
---
## Integration with Crash Recovery
Adaptive retry works WITH crash recovery AND agent fallback:
| Type | Trigger | Handling |
|------|---------|----------|
| **Adaptive Retry** | Session completed but FAILED (wrong output, tests failed) | Progress-based retry with agent alternation |
| **Crash Recovery** | Session DIED unexpectedly (context limit, API error, kill) | Switch agent, retry with new session |
| **Agent Fallback** | Primary agent fails | Automatic switch to fallback agent on next attempt |
All three mechanisms work together:
1. Primary crashes → switch to fallback, new session
2. Fallback fails at task 5 → switch to primary, retry
3. Primary fails at task 5 → plateau detected across agents → DEFER
**Single attempt counter across all failure types.**
---
## Network Error Handling
On network-related failures (see `retry-fallback-strategy.md`):
- Sleep 60 seconds before next attempt
- Network errors do NOT count toward plateau detection
- Always retry after network error (up to max attempts)

View File

@@ -0,0 +1,4 @@
{
"version": "1.0.0",
"presets": []
}

View File

@@ -0,0 +1,199 @@
# Agent Configuration Prompts
---
## 🚨 PREREQUISITE (MUST BE MET BEFORE DISPLAYING)
Before showing agent configuration prompts, you MUST have:
1.**Complexity Matrix displayed** - User has seen the story complexity breakdown
2.**`stories_json` populated** - Programmatic complexity data from `scripts/story-automator parse-story --rules`
3.**Complexity summary available** - Counts of Low/Medium/High stories
**If these are not met, DO NOT proceed with agent configuration. Go back and complete step 3.**
---
## Agent Configuration Display (v6.0.0)
**IMPORTANT:** This prompt MUST reference the actual complexity data. Do not show generic prompts.
**IMPORTANT:** Select the correct table variant based on `skip_automate`:
- If `skip_automate` is **false**: show the **WITH auto** table
- If `skip_automate` is **true**: show the **WITHOUT auto** table
**IMPORTANT:** Before displaying options, check for saved presets:
```bash
presets_result=$("{buildStateDoc}" agent-config list --file "{agentConfigPresets}")
preset_count=$(echo "$presets_result" | jq -r '.count')
```
- If `preset_count > 0`: include **[L]oad saved** option in the menu
- If `preset_count == 0`: omit [L] option (show only S/U/C)
### Variant A: WITH auto column (skip_automate=false)
```
**AI Agent Configuration (Based on Your Complexity Analysis)**
Your stories by complexity:
- Low: {low_count} stories
- Medium: {medium_count} stories
- High: {high_count} stories
**Agent Details:**
- **Claude:** `claude --dangerously-skip-permissions` + natural language skill prompt
- **Codex:** `codex exec --full-auto` + natural language prompt (no command prefix)
**Suggested Complexity-Based Configuration:**
| Complexity | create | dev | auto | review | Rationale |
|------------|--------|-----|------|--------|-----------|
| Low | claude | claude | claude | claude | Claude handles simple tasks well |
| Medium | codex | codex | codex | codex | Codex for moderate complexity (Claude fallback) |
| High | codex | codex | codex | codex | Codex for complex work (Claude fallback) |
| Retro | inherits default | - | - | - | Retrospectives follow the configured primary agent unless overridden |
**Options:**
1. **[S]uggested** - Apply complexity-based defaults above
2. **[U]niform** - Same agent for ALL stories (you specify which)
3. **[C]ustom** - Define your own per-complexity or per-task settings
{IF_PRESETS}4. **[L]oad saved** - Use a previously saved configuration{END_IF_PRESETS}
Enter choice ({IF_PRESETS}S/U/C/L{ELSE}S/U/C{END_IF}) or provide custom overrides:
```
**Conditional display rule:** `{IF_PRESETS}` blocks render only when `preset_count > 0`.
### Variant B: WITHOUT auto column (skip_automate=true)
```
**AI Agent Configuration (Based on Your Complexity Analysis)**
Your stories by complexity:
- Low: {low_count} stories
- Medium: {medium_count} stories
- High: {high_count} stories
**Agent Details:**
- **Claude:** `claude --dangerously-skip-permissions` + natural language skill prompt
- **Codex:** `codex exec --full-auto` + natural language prompt (no command prefix)
**Suggested Complexity-Based Configuration:**
| Complexity | create | dev | review | Rationale |
|------------|--------|-----|--------|-----------|
| Low | claude | claude | claude | Claude handles simple tasks well |
| Medium | codex | codex | codex | Codex for moderate complexity (Claude fallback) |
| High | codex | codex | codex | Codex for complex work (Claude fallback) |
| Retro | inherits default | - | - | Retrospectives follow the configured primary agent unless overridden |
**Options:**
1. **[S]uggested** - Apply complexity-based defaults above
2. **[U]niform** - Same agent for ALL stories (you specify which)
3. **[C]ustom** - Define your own per-complexity or per-task settings
{IF_PRESETS}4. **[L]oad saved** - Use a previously saved configuration{END_IF_PRESETS}
Enter choice ({IF_PRESETS}S/U/C/L{ELSE}S/U/C{END_IF}) or provide custom overrides:
```
## Load Saved Preset Prompt (Option L)
**Prerequisite:** `preset_count > 0` (checked before displaying main menu).
```bash
presets_result=$("{buildStateDoc}" agent-config list --file "{agentConfigPresets}")
```
Display:
```
**Saved Agent Configurations:**
{numbered list from presets_result, e.g.:}
1. all-claude (saved 2026-03-10)
2. codex-heavy (saved 2026-03-08)
[D]elete a preset
Enter preset number to load, or [B]ack to return to options:
```
**Wait.**
**IF number selected:**
```bash
preset_name="{selected preset name}"
loaded=$("{buildStateDoc}" agent-config load --file "{agentConfigPresets}" --name "$preset_name")
agent_config_json=$(echo "$loaded" | jq -r '.config')
```
Display loaded config summary, then proceed with this as `agent_config_json`.
**IF D selected:**
Ask which preset number to delete, then:
```bash
"{buildStateDoc}" agent-config delete --file "{agentConfigPresets}" --name "$delete_name"
```
Redisplay this prompt (or return to main options if no presets remain).
**IF B selected:** Return to main S/U/C/L menu.
---
## Save Configuration Prompt
**When to show:** After the user completes a **[C]ustom** or **[U]niform** configuration (NOT after [S]uggested or [L]oad).
```
**Save this configuration for future runs?**
Enter a name to save (e.g., `all-claude`, `codex-heavy`) or [N]o to skip:
```
**Wait.**
**IF name provided:**
```bash
"{buildStateDoc}" agent-config save --file "{agentConfigPresets}" --name "$save_name" --config-json "$agent_config_json"
```
Display: "Configuration saved as **{save_name}**."
**IF N or empty:** Skip, continue.
---
## Uniform Agent Prompt (Option U)
```
**Uniform Agent Configuration**
Use the same agent for ALL {total_count} stories regardless of complexity.
Which agent for all tasks?
- `claude` - Claude for everything (more capable, slower)
- `codex` - Codex for everything (faster, simpler)
- `claude, false` - Claude only, no fallback
- `codex, claude` - Codex primary, Claude fallback
Enter agent config:
```
## Custom Configuration Prompt (Option C)
```
**Custom Agent Configuration**
Define agents per complexity level and/or per task.
**Per-Complexity Format:** `complexity.task: primary, fallback`
- `low.dev: claude, false` → Claude for low-complexity dev, no fallback
- `medium.create: codex, claude` → Codex for medium-complexity create
- `high.review: claude, false` → Claude for high-complexity review
**Per-Task Format (applies to all complexities):** `task: primary, fallback`
- `review: claude, false` → Claude for ALL reviews
- `dev: codex, claude` → Codex for ALL dev
**Complexity levels:** low, medium, high
**Tasks:** create, dev, auto, review
Enter overrides (comma-separated):
```

View File

@@ -0,0 +1,180 @@
# Agent Fallback Troubleshooting
### Issue: Session spawns Claude instead of Codex
**Symptoms:**
- Output shows Claude-specific messages (e.g., "You've used 84% of your weekly limit")
- Expected Codex but got Claude
**Cause:** The `--agent` flag must be passed to `story-automator tmux-wrapper spawn`, not to `build-cmd`.
**Correct Usage (v1.4.0+):**
```bash
# Method 1: Use --agent flag on spawn (RECOMMENDED)
session=$("$scripts" tmux-wrapper spawn dev "$epic" "$story_id" \
--agent codex \
--command "$("$scripts" tmux-wrapper build-cmd dev "$story_id")")
# Method 2: Set environment variable before spawn
export AI_AGENT="codex"
session=$("$scripts" tmux-wrapper spawn dev "$epic" "$story_id" \
--command "$("$scripts" tmux-wrapper build-cmd dev "$story_id")")
```
**Wrong Usage:**
```bash
# WRONG - this doesn't work
session=$("$scripts" tmux-wrapper spawn dev "$epic" "$story_id" \
--command "$("$scripts" tmux-wrapper build-cmd dev "$story_id" --agent codex)")
```
### Issue: Monitor reports "stuck" but Codex is active
**Symptoms:**
- `story-automator monitor-session` returns `stuck` state after 4 polls
- Manual inspection shows Codex still producing output (no prompt, output continues to grow)
**Cause:** The monitoring script relied on marker detection instead of output freshness.
**Fixed in v2.4.0:**
- Output freshness tracking (no marker reliance)
- `CODEX_OUTPUT_STALE_SECONDS` controls how long Codex can be silent before "stuck"
- Codex still gets 6 poll grace period before "stuck"
**Verification:**
```bash
# Check if session has AI_AGENT set
tmux show-environment -t "session-name" AI_AGENT
# Manual session status check
"$scripts" tmux-status-check "session-name" --project-root "$PWD"
```
### Issue: log command error when using --agent flag
**Symptoms:**
```
log: Unknown subcommand 'Codex agent detected - applying 1.5x timeout (90min)'
```
**Cause:** macOS has `/usr/bin/log` system command. If the `log()` bash function wasn't defined before first use, bash fell through to the system command.
**Fixed in v1.4.0:** The `log()` function is now defined before argument parsing in `story-automator monitor-session`.
### Issue: Manual polling required as workaround
**If monitoring still fails**, use this manual polling approach:
```bash
for i in {1..60}; do
sleep 30
# Check if session still exists
if ! tmux has-session -t "session-name" 2>/dev/null; then
echo "Session ended"
break
fi
# Check for shell prompt (completion indicator)
last_line=$(tmux capture-pane -t "session-name" -p | tail -1)
if echo "$last_line" | grep -qE '$|\$$|#$'; then
echo "Session complete (shell prompt detected)"
break
fi
done
```
### Issue: Codex sessions explore files but don't execute full workflow (v1.4.0)
**Symptoms:**
- Session output shows file exploration (`sed`, `rg`, `cat` commands)
- No actual review findings or story updates
- Sprint-status never changes from "review" to "done"
- Session completes but workflow steps 1-5 weren't followed
**Cause:** Codex uses natural language prompts and may not follow structured workflow instructions as reliably as Claude.
**Mitigation strategies:**
1. **Use Claude for code-review by default** - More reliable at following multi-step workflows
2. **Add explicit step markers** - Tell Codex to output "STEP 1 COMPLETE", "STEP 2 COMPLETE" etc.
3. **Verify after session** - Check story file Status field, not just sprint-status
**Recommended agent configuration for deterministic reliability:**
```yaml
agentConfig:
defaultPrimary: "auto"
defaultFallback: false # Disable global fallback; opt in per task
perTask:
# create-story: Either agent works well
create:
primary: "claude"
# dev-story: Either agent works, Codex may be faster for simple tasks
dev:
primary: "codex"
fallback: "claude"
# code-review: Claude recommended - more reliable at following workflow
review:
primary: "claude"
fallback: false
```
### Issue: Code-review doesn't update sprint-status.yaml
**Symptoms:**
- Code-review session completes
- Story file shows review was done (Dev Agent Record updated)
- But sprint-status.yaml still shows "review" instead of "done"
**Cause:** Code-review workflow step 5 updates sprint-status, but session may not reach step 5 or may use wrong story key format.
**Verification (v1.4.0):**
```bash
# Check story file status directly
"$scripts" orchestrator-helper story-file-status 8.2
# Compare with sprint-status
"$scripts" orchestrator-helper sprint-status get "8-2-flipside-crypto-provider"
# If story file shows "done" but sprint-status doesn't, manually sync:
# Edit _bmad-output/implementation-artifacts/sprint-status.yaml and change "8-2-story-name: review" to "done"
```
### When to manually intervene
**Intervene immediately if:**
1. **5 code-review cycles with no progress** - Agent likely stuck in a loop
2. **Story file shows "done" but sprint-status doesn't** - Sync issue, manual fix is faster
3. **Tests passing but review keeps finding issues** - May be false positives
4. **Codex sessions consistently incomplete** - Switch to Claude for that workflow
**Steps for manual intervention:**
```bash
# 1. Check actual story status
"$scripts" orchestrator-helper story-file-status {story_id}
# 2. Run tests to verify code quality
go test ./src/... || npm test
# 3. If tests pass, manually update sprint-status
# Edit: _bmad-output/implementation-artifacts/sprint-status.yaml
# Change: "8-2-story-name: review" to "8-2-story-name: done"
# 4. Resume orchestration - it will see "done" and proceed to commit
```
### Debugging Agent Detection
```bash
# Check current agent type detection
"$scripts" tmux-wrapper agent-type
# Check what CLI command would be used
"$scripts" tmux-wrapper agent-cli
# Check what command prefix would be used
"$scripts" tmux-wrapper skill-prefix
# View session environment
tmux show-environment -t "session-name"
# Check story key normalization (v1.4.0)
"$scripts" orchestrator-helper normalize-key "8.2"
"$scripts" orchestrator-helper normalize-key "8-2-flipside-crypto-provider"
```

View File

@@ -0,0 +1,136 @@
# Agent Fallback Strategy (v3.0.0)
**Multi-Agent Support:** The orchestrator can use Claude or Codex as AI coding agents, with automatic fallback on failure.
## Configuration
From state document (v3.0.0):
```yaml
agentConfig:
defaultPrimary: "auto"
defaultFallback: false
perTask:
dev:
primary: "codex"
fallback: "claude"
complexityOverrides:
low:
dev:
primary: "claude"
fallback: false
```
Agent selection is resolved via the deterministic agents file created in preflight:
`_bmad-output/story-automator/agents/agents-{state_filename}.md`
## Agent Differences
| Agent | CLI | Prompt Style | Timeout | Todo Tracking |
|-------|-----|--------------|---------|---------------|
| Claude | `claude --dangerously-skip-permissions` | Natural language skill prompt | 60min | ☒/☐ checkboxes |
| Codex | `codex exec --full-auto` | Natural language prompt | 90min (1.5x) | Not supported |
**CRITICAL:** Both Claude and Codex prompts must name the skill/workflow to execute and include the story ID.
The `story-automator tmux-wrapper build-cmd` function automatically generates the correct prompt format based on `AI_AGENT` environment variable.
**See `workflow-commands.md` for complete prompt templates.**
## Fallback Behavior
**When to fallback:**
- Primary agent session crashes (non-zero exit)
- Retries exhausted with primary agent
- `fallback` is configured for the task and not disabled ("false")
**Fallback procedure:**
1. Log: "Primary agent ({primary}) failed after {retries} attempts. Trying fallback ({fallback})..."
2. Set environment: `AI_AGENT={fallback}`
3. Respawn session with fallback agent
4. Monitor as normal (timeouts auto-adjust based on agent type)
5. If fallback also fails → CRITICAL escalation
**Environment Variable:**
```bash
# Set before spawning session
export AI_AGENT="codex" # or "claude"
# story-automator tmux-wrapper reads this automatically and generates correct prompt format
session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \
--command "$("$scripts" tmux-wrapper build-cmd dev {story_id})")
```
## Codex Monitoring Notes
- **No todo checkboxes:** Codex doesn't use ☒/☐ - `todos_done` and `todos_total` will be 0
- **Longer waits:** Status check script returns 90s wait estimate for Codex (vs 60s for Claude)
- **Different activity detection:** Uses output freshness + heartbeat (no marker reliance)
- **Output staleness window:** `CODEX_OUTPUT_STALE_SECONDS` (default: 300)
- **1.5x timeout multiplier:** `story-automator monitor-session` applies 1.5x multiplier when `--agent codex`
- **Fake todo progress (v2.2):** When Codex is idle after activity, reports `1/1` to indicate "work done, needs verification"
- **Idle vs Completed (v2.2):** Codex sessions report "idle" instead of "completed" when CLI stops but no terminal markers
## ⚠️ Codex Code-Review Limitations (v1.5.0)
**CRITICAL: Codex is NOT recommended for code-review workflow.**
### Known Issue: Sprint-Status Not Updated
Codex code-review sessions often complete (CLI exits) WITHOUT updating `sprint-status.yaml` to "done". This causes:
- Monitor reports "completed" but sprint-status unchanged
- Orchestrator loops indefinitely, spawning new review cycles
- 8+ cycles with 0 progress (observed in Story 8.2)
### Root Cause
Codex runs non-interactively via `codex exec`. When it finishes:
1. Tmux session goes idle (no active CLI process)
2. Monitor sees "idle" and marks as "completed"
3. But workflow step 5 (update sprint-status) may not have executed
4. No way to verify workflow actually finished
### Recommended Configuration
```yaml
agentConfig:
defaultPrimary: "codex"
defaultFallback: "claude"
perTask:
review:
primary: "claude" # Never use Codex for code-review
fallback: false
```
### "incomplete" State (v2.2)
The monitoring system now detects when Codex finishes but sprint-status wasn't updated:
- `final_state: "completed"` → Verified: sprint-status shows "done"
- `final_state: "incomplete"` → Session idle but sprint-status NOT "done"
When "incomplete" is detected:
- **Do NOT retry automatically** (prevents infinite loop)
- Escalate to user with options:
1. Manual fix (update sprint-status yourself)
2. Run code-review with Claude
3. Skip this story
### Verification Command (v2.2)
Check if code-review actually completed:
```bash
"$scripts" orchestrator-helper verify-code-review {story_id}
# Returns: {"verified":true/false, "sprint_status":"...", ...}
```
## Backwards Compatibility
- If `agentConfig` is missing, the primary agent resolves from the active runtime provider and fallback is disabled
- If `aiCommand` is set (legacy), use it directly with the generated natural language prompt
- New orchestrations should use `agentConfig` instead of `aiCommand`
- Agents file is authoritative when present
---
## Troubleshooting
See `agent-fallback-troubleshooting.md` for detailed troubleshooting steps.

View File

@@ -0,0 +1,164 @@
# Code Review Loop Pattern (v2.3)
**Purpose:** Code review loop execution using script-based automation with per-task agent configuration.
---
## Configuration
```
reviewCycle = 1
maxCycles = 5
```
---
## Agent Selection (v3.0)
Code-review uses **deterministic agent selection** from the agents file, same as all other workflow steps.
```bash
# Resolve agent for review task (uses agents file)
resolve_agent_for_task "review" "$state_file" "{story_id}"
review_agent="$primary_agent"
review_fallback="$fallback_agent"
echo "Code review using: primary=$review_agent, fallback=$review_fallback"
```
**Per-task override example in state document:**
```yaml
agentConfig:
defaultPrimary: "codex"
defaultFallback: "claude"
perTask:
review:
primary: "claude" # Override: use Claude for reviews
fallback: false # Disable fallback for reviews
```
**Note on Codex:** If Codex is configured for reviews and fails to update sprint-status, the `story-automator monitor-session --workflow review` verification catches this and returns `final_state: "incomplete"`, triggering the escalation path below.
---
## Loop Execution
**WHILE reviewCycle ≤ maxCycles:**
### 1. Spawn Review Session
```bash
scripts="$(printf "%s" "{project_root}/<installed-skill-root>/bmad-story-automator/scripts/story-automator")"
[ -n "$scripts" ] || { echo "story-automator helper not found" >&2; exit 1; }
# ⚠️ CRITICAL: --command is REQUIRED - without it, no command runs → never_active failure!
# Spawn with story-automator tmux-wrapper (handles naming, state cleanup, env vars)
session_name=$("$scripts" tmux-wrapper spawn review {epic} {story_id} \
--agent "$review_agent" \
--cycle $reviewCycle \
--command "$("$scripts" tmux-wrapper build-cmd review {story_id} --agent "$review_agent" --state-file "$state_file")")
```
### 2. Monitor Session with Verification (v2.2)
```bash
# Single call replaces 14+ API roundtrips
# Pass --workflow and --story-key for completion verification
result=$("$scripts" monitor-session "$session_name" --json --verbose \
--agent "$review_agent" \
--workflow review --story-key {story_id} --state-file "$state_file")
final_state=$(echo "$result" | jq -r '.final_state')
output_file=$(echo "$result" | jq -r '.output_file')
```
**Note:** The `--workflow review --story-key` parameters enable sprint-status verification before marking complete.
### 3. Parse Output
```bash
# Sub-agent parsing (haiku, 99% cheaper than main context)
parsed=$("$scripts" orchestrator-helper parse-output "$output_file" review --state-file "$state_file")
```
### 4. Verify Sprint Status
```bash
status=$("$scripts" orchestrator-helper sprint-status get {story_key})
is_done=$(echo "$status" | jq -r '.done')
```
---
## Decision Logic
### Handle final_state (v2.2)
**IF final_state == "completed":**
- Session verified complete (sprint-status shows "done")
- Log "Code review passed, story marked done"
- Cleanup: `"$scripts" tmux-wrapper kill "$session_name"`
- **EXIT LOOP** → proceed to Git Commit
**IF final_state == "incomplete":** (v2.2 - Codex-specific)
- Session idle but sprint-status NOT updated
- Cleanup: `"$scripts" tmux-wrapper kill "$session_name"`
- Increment `reviewCycle`
- If `reviewCycle <= maxCycles`: count this as a failed attempt and **CONTINUE** with a retry
- If `reviewCycle > maxCycles`: Escalate with CRITICAL priority (Trigger #8), then present options:
1. **[1] Manual Fix** - Update sprint-status.yaml yourself
2. **[2] Run with Claude** - Re-run code-review with Claude agent
3. **[3] Skip Story** - Mark story as skipped and continue
- **HALT** — wait for user choice only after maxCycles is exhausted
**IF final_state == "crashed" or "stuck":**
- Log "Review session failed: $final_state"
- Cleanup: `"$scripts" tmux-wrapper kill "$session_name"`
- Increment reviewCycle
- **CONTINUE** (retry with new session)
### Handle is_done check
**IF is_done == true:**
- Log "Sprint-status verified done"
- **EXIT LOOP** → proceed to Git Commit
**IF is_done == false AND final_state == "completed":**
- This shouldn't happen with v2.2 verification
- Fallback: check story file status
- If story file shows "done", treat as complete
**IF reviewCycle > maxCycles:**
- Check escalation: `"$scripts" orchestrator-helper escalate review-loop "cycles=$reviewCycle"`
- **HALT** — wait for user choice
---
## Sprint-Status Verification (v3.0)
Status is determined by **CRITICAL issues remaining** after auto-fix:
- "done" → 0 CRITICAL issues, proceed to commit
- "in-progress" → 1+ CRITICAL issues, new review cycle
HIGH/MEDIUM/LOW issues are tracked as action items but don't block automation.
---
## Output Verification Fallback (v1.4.0)
If `output_verified == false` or output truncated, use story file fallback:
```bash
file_status=$("$scripts" orchestrator-helper story-file-status {story_id})
# If status == "done", skip parsing - story is complete
```
---
## Verification Command (v2.2)
Check if code-review actually completed:
```bash
"$scripts" orchestrator-helper verify-code-review {story_id} --state-file "$state_file"
# Returns: {"verified":true/false, "sprint_status":"...", ...}
```

View File

@@ -0,0 +1,246 @@
{
"version": "2.0",
"thresholds": {
"low_max": 3,
"medium_max": 7
},
"structural_rules": {
"ac_count_medium": 6,
"ac_count_high": 10,
"ac_count_medium_score": 1,
"ac_count_high_score": 2,
"dependency_score": 1,
"large_story_word_threshold": 400,
"large_story_score": 1
},
"rules": [
{
"id": "external_api",
"label": "External API integration",
"pattern": "whatsapp|oauth|stripe|payment|third[- ]party|external api|twilio|sendgrid|mailgun|slack api|discord api|shopify|salesforce|hubspot|zapier|plaid|aws sdk|gcp sdk|azure sdk",
"score": 2
},
{
"id": "webhook_async",
"label": "Webhook/async processing",
"pattern": "webhook|async handler|asynchronous|message queue|queue worker|background job|event listener|pub.?sub|kafka|rabbitmq|sqs|nats|event.?driven|callback url",
"score": 2
},
{
"id": "realtime",
"label": "Real-time communication",
"pattern": "websocket|web socket|socket\\.io|sse|server.sent events|real.?time update|live update|push notification|long polling",
"score": 2
},
{
"id": "db_migration",
"label": "Database schema changes",
"pattern": "migration|schema change|new table|alter table|add column|database table|create index|foreign key|database schema|modify schema",
"score": 1
},
{
"id": "db_complex_query",
"label": "Complex database operations",
"pattern": "complex quer|join.*join|subquer|aggregate|group by|window function|recursive.*query|materialized view|stored procedure|database transaction|deadlock|connection pool",
"score": 2
},
{
"id": "data_transform",
"label": "Data transformation/ETL",
"pattern": "data transform|etl|data pipeline|data migration|bulk import|bulk export|csv.*(import|export|parse)|data mapping|data sync|batch process|normalize data|denormalize",
"score": 2
},
{
"id": "caching",
"label": "Caching layer",
"pattern": "cache|redis|memcache|cdn|invalidat|cache.?bust|stale.?while|cache.?strategy|in.?memory store|session store",
"score": 1
},
{
"id": "search_index",
"label": "Search/indexing",
"pattern": "elasticsearch|full.?text search|search index|algolia|typesense|meilisearch|solr|vector search|semantic search|fuzzy search|search engine",
"score": 2
},
{
"id": "file_storage",
"label": "File upload/storage",
"pattern": "file upload|s3|blob storage|image upload|media upload|file processing|pdf generat|csv generat|document generat|file download|cloud storage|presigned url",
"score": 1
},
{
"id": "auth_system",
"label": "Authentication system",
"pattern": "authenticat|login flow|sign.?up flow|session management|jwt|token refresh|password reset|magic link|sso|single sign|two.?factor|2fa|mfa|social login|auth middleware|auth guard",
"score": 2
},
{
"id": "authorization",
"label": "Authorization/permissions",
"pattern": "authori[zs]|rbac|role.?based|permission|access control|acl|policy engine|guard|middleware.*auth|protect.*route|tenant.*isol|multi.?tenant|row.?level security",
"score": 2
},
{
"id": "encryption",
"label": "Encryption/security",
"pattern": "encrypt|decrypt|hash|bcrypt|argon|hmac|digital signature|certificate|ssl|tls|secret.*management|vault|key.*rotation|sanitiz|xss|csrf|sql injection|security header|cors config",
"score": 1
},
{
"id": "state_management",
"label": "Complex state management",
"pattern": "state management|redux|zustand|recoil|jotai|context.*provider|global state|state machine|finite state|xstate|event sourc|cqrs|saga pattern|optimistic update",
"score": 1
},
{
"id": "backend_frontend",
"label": "Backend + Frontend combined",
"pattern": "backend.*frontend|frontend.*backend|full.?stack|api.*and.*ui|server.*and.*client|both.*api.*and|endpoint.*and.*page|controller.*and.*component",
"score": 2
},
{
"id": "microservice",
"label": "Service communication",
"pattern": "microservice|service.to.service|grpc|inter.?service|api gateway|service mesh|service discover|distributed|cross.?service|orchestrat.*service",
"score": 2
},
{
"id": "infrastructure",
"label": "Infrastructure changes",
"pattern": "docker|kubernetes|k8s|terraform|ci.?cd|pipeline|deploy|nginx|caddy|load balanc|auto.?scal|infrastructure|server config|environment variable|env config|systemd|reverse proxy",
"score": 2
},
{
"id": "error_handling",
"label": "Complex error handling",
"pattern": "error handling|error boundar|retry logic|circuit.?break|graceful.?degrad|fallback.*strateg|dead.?letter|error recover|exception handling|rollback|compensat.*transaction|idempoten",
"score": 1
},
{
"id": "transaction",
"label": "Transaction management",
"pattern": "transaction|atomic.*operation|two.?phase|eventual.?consisten|distributed.*lock|optimistic.*lock|pessimistic.*lock|conflict.*resolut|concurren.*control|race condition",
"score": 2
},
{
"id": "performance",
"label": "Performance optimization",
"pattern": "performance|optimiz|pagination|infinite scroll|virtual.*list|lazy load|code split|bundle.*size|lighthouse|core web vital|throttl|debounc|memoiz|profil",
"score": 1
},
{
"id": "rate_limiting",
"label": "Rate limiting/throttling",
"pattern": "rate limit|throttl|quota|usage.*limit|api.*limit|request.*limit|cooldown|backoff|exponential.*back",
"score": 1
},
{
"id": "batch_processing",
"label": "Batch/bulk operations",
"pattern": "batch.*process|bulk.*operat|mass.*update|bulk.*insert|batch.*job|scheduled.*task|cron|periodic.*task|bulk.*delete|queue.*process",
"score": 1
},
{
"id": "complex_form",
"label": "Complex forms",
"pattern": "multi.?step form|form wizard|dynamic form|form validation|conditional field|nested form|form builder|file.*input.*form|complex.*form|form.*state",
"score": 1
},
{
"id": "visualization",
"label": "Charts/visualization",
"pattern": "chart|graph|d3|visualization|dashboard.*widget|data.*viz|sparkline|heatmap|treemap|pie.*chart|bar.*chart|line.*chart|recharts|plotly|canvas.*draw",
"score": 1
},
{
"id": "drag_drop",
"label": "Drag and drop",
"pattern": "drag.?and.?drop|dnd|sortable|reorder|draggable|droppable|kanban.*board|drag.*handle",
"score": 1
},
{
"id": "accessibility",
"label": "Accessibility requirements",
"pattern": "accessib|a11y|screen reader|aria|wcag|keyboard.*navigat|focus.*management|tab.*order|assistive|color.*contrast",
"score": 1
},
{
"id": "i18n",
"label": "Internationalization",
"pattern": "i18n|internationali[zs]|locali[zs]|translat|multi.?language|rtl|right.?to.?left|locale|plural.*form|number.*format|date.*format.*locale",
"score": 1
},
{
"id": "integration_test",
"label": "Integration testing required",
"pattern": "integration test|e2e test|end.to.end|playwright|cypress|selenium|test.*api.*endpoint|test.*database|test.*external|contract.*test|smoke.*test",
"score": 1
},
{
"id": "test_fixtures",
"label": "Complex test setup",
"pattern": "test fixture|mock.*service|stub.*api|seed.*data|test.*factory|test.*database|test.*container|docker.*test|test.*environment|test.*isolation",
"score": 1
},
{
"id": "email_notification",
"label": "Email/notification system",
"pattern": "email.*send|notification.*system|push.*notif|sms.*send|in.?app.*notif|notification.*preference|email.*template|mailer|notification.*queue|alert.*system",
"score": 1
},
{
"id": "logging_monitoring",
"label": "Logging/monitoring/observability",
"pattern": "logging.*system|monitoring|observab|telemetry|tracing|distributed.*trace|log.*aggregat|metrics.*collect|health.*check|alerting|sentry|datadog|newrelic",
"score": 1
},
{
"id": "config_system",
"label": "Configuration/feature flags",
"pattern": "feature.*flag|feature.*toggle|config.*system|dynamic.*config|a.?b.*test|experiment|remote.*config|launch.*darkly|unleash|posthog.*flag",
"score": 1
},
{
"id": "frontend_only",
"label": "Frontend only (no backend)",
"pattern": "frontend only|ui only|css only|layout only|style only|cosmetic|visual.*only|markup.*only|static.*page|presentation.*only",
"score": -1
},
{
"id": "simple_crud",
"label": "Simple CRUD operations",
"pattern": "simple crud|basic crud|create read update delete|simple.*list|basic.*form|standard.*rest|straightforward|simple.*endpoint|basic.*page|simple.*component",
"score": -1
},
{
"id": "documentation_only",
"label": "Documentation/config only",
"pattern": "documentation only|readme|config.*change only|env.*update only|update.*docs|comment.*only|rename only|typo|text.*change only",
"score": -2
},
{
"id": "refactor_only",
"label": "Pure refactor (no behavior change)",
"pattern": "refactor only|code.*cleanup|rename|extract.*method|move.*file|reorgani[zs]e|restructure|no.*behavior.*change|no.*functional.*change",
"score": -1
},
{
"id": "simple_bugfix",
"label": "Simple/isolated bug fix",
"pattern": "simple.*fix|minor.*bug|typo.*fix|off.?by.?one|null.*check|missing.*import|syntax.*error|small.*patch|hotfix|one.?line.*fix",
"score": -1
},
{
"id": "uncertainty",
"label": "Uncertain/research-heavy scope",
"pattern": "research|investigate|spike|prototype|proof of concept|poc|tbd|to be determined|unclear|explore|experiment.*with|evaluate.*option|might.*need|may.*require",
"score": 1
},
{
"id": "breaking_change",
"label": "Breaking/migration change",
"pattern": "breaking.*change|backward.*compat|deprecat|migration.*guide|version.*bump.*major|api.*v\\d|legacy.*support|upgrade.*path",
"score": 2
}
]
}

View File

@@ -0,0 +1,153 @@
# Story Complexity Scoring (v2.0.0)
Estimate each story's complexity to predict dev-story success likelihood and inform agent selection. Scoring combines **regex-based pattern matching** (detecting domain signals in story text) with **structural analysis** (measuring story size and shape).
---
## How Scoring Works
The Python helper (`scripts/story-automator parse-story --rules`) performs two passes:
### Pass 1: Pattern Matching (regex rules)
Each rule in `complexity-rules.json` has a regex pattern tested case-insensitively against the concatenation of the story's **title + description + acceptance criteria**. When a rule matches, its score is added (positive = complexity, negative = simplicity).
### Pass 2: Structural Analysis
The parser also examines the story's **structure** independent of text content:
| Structural Factor | Condition | Score | Reason |
|---|---|---|---|
| Acceptance Criteria count (medium) | AC lines > 6 | +1 | More ACs = more surface area to implement and verify |
| Acceptance Criteria count (high) | AC lines > 10 | +2 | (replaces medium; not additive) Large AC count signals multi-faceted story |
| Explicit dependency | Story references dependency on another story | +1 | Cross-story dependencies add coordination overhead |
| Large story | Word count > 400 | +1 | Verbose stories indicate broader scope |
### Final Score
`final_score = sum(matched_rule_scores) + structural_bonus`
---
## Rule Categories (40 rules)
### External Integration (+2 each)
| Rule | Detects |
|---|---|
| External API integration | Third-party services (Stripe, Twilio, WhatsApp, AWS SDK, etc.) |
| Webhook/async processing | Webhooks, message queues, pub/sub, background jobs, event-driven patterns |
| Real-time communication | WebSockets, SSE, push notifications, live updates, long polling |
### Database & Data (+1 to +2)
| Rule | Score | Detects |
|---|---|---|
| Database schema changes | +1 | Migrations, new tables, index creation, foreign keys |
| Complex database operations | +2 | Complex queries, joins, subqueries, aggregates, stored procedures, transactions |
| Data transformation/ETL | +2 | Data pipelines, bulk import/export, CSV parsing, data sync, normalization |
| Caching layer | +1 | Redis, memcache, CDN, cache invalidation, session stores |
| Search/indexing | +2 | Elasticsearch, Algolia, full-text search, vector search |
| File upload/storage | +1 | S3, blob storage, file processing, PDF/CSV generation, presigned URLs |
### Security & Auth (+1 to +2)
| Rule | Score | Detects |
|---|---|---|
| Authentication system | +2 | Login flows, JWT, password reset, SSO, 2FA/MFA, social login |
| Authorization/permissions | +2 | RBAC, ACL, row-level security, multi-tenant isolation, route guards |
| Encryption/security | +1 | Encryption, hashing, CSRF/XSS protection, security headers, CORS |
### State & Architecture (+1 to +2)
| Rule | Score | Detects |
|---|---|---|
| Complex state management | +1 | Redux, Zustand, state machines, CQRS, event sourcing, optimistic updates |
| Backend + Frontend combined | +2 | Full-stack changes touching both API and UI layers |
| Service communication | +2 | Microservices, gRPC, API gateway, service mesh, distributed systems |
| Infrastructure changes | +2 | Docker, Kubernetes, CI/CD, reverse proxies, deployment, auto-scaling |
### Error Handling & Resilience (+1 to +2)
| Rule | Score | Detects |
|---|---|---|
| Complex error handling | +1 | Error boundaries, retry logic, circuit breakers, graceful degradation, idempotency |
| Transaction management | +2 | Atomic operations, distributed locks, conflict resolution, race conditions |
### Performance (+1)
| Rule | Score | Detects |
|---|---|---|
| Performance optimization | +1 | Pagination, lazy loading, code splitting, memoization, Core Web Vitals |
| Rate limiting/throttling | +1 | Rate limits, quotas, backoff strategies, cooldowns |
| Batch/bulk operations | +1 | Batch processing, bulk inserts/updates, cron jobs, scheduled tasks |
### UI/UX Complexity (+1)
| Rule | Score | Detects |
|---|---|---|
| Complex forms | +1 | Multi-step forms, wizards, dynamic forms, conditional fields |
| Charts/visualization | +1 | D3, Recharts, dashboards, heatmaps, canvas drawing |
| Drag and drop | +1 | DnD, sortable lists, Kanban boards, reorderable UI |
| Accessibility | +1 | WCAG, ARIA, screen reader support, keyboard navigation |
| Internationalization | +1 | i18n, translations, RTL support, locale-aware formatting |
### Testing Signals (+1)
| Rule | Score | Detects |
|---|---|---|
| Integration testing required | +1 | E2E tests, Playwright, Cypress, contract tests, API endpoint tests |
| Complex test setup | +1 | Test fixtures, service mocks, seed data, test containers |
### Cross-Cutting (+1)
| Rule | Score | Detects |
|---|---|---|
| Email/notification system | +1 | Email sending, push notifications, SMS, in-app notifications |
| Logging/monitoring | +1 | Observability, telemetry, distributed tracing, Sentry, Datadog |
| Configuration/feature flags | +1 | Feature toggles, A/B tests, remote config, LaunchDarkly |
### Simplicity Reducers (-1 to -2)
| Rule | Score | Detects |
|---|---|---|
| Frontend only | -1 | UI-only, CSS-only, layout-only, static pages |
| Simple CRUD | -1 | Basic CRUD, standard REST, straightforward endpoints |
| Documentation/config only | -2 | README updates, config changes, doc-only changes |
| Pure refactor | -1 | Code cleanup, renames, restructuring with no behavior change |
| Simple bug fix | -1 | Typo fixes, null checks, missing imports, one-line patches |
### Risk/Uncertainty Signals (+1 to +2)
| Rule | Score | Detects |
|---|---|---|
| Uncertain scope | +1 | Research spikes, prototypes, POCs, TBD items, exploratory work |
| Breaking change | +2 | Breaking changes, deprecations, major version bumps, migration guides |
---
## Complexity Levels
| Score | Level | Meaning | Agent Recommendation |
|---|---|---|---|
| ≤ 3 | **Low** | High success probability | Claude handles well autonomously |
| 47 | **Medium** | Normal execution, moderate risk | Codex primary with Claude fallback |
| ≥ 8 | **High** | Consider longer timeouts, may need intervention | Codex primary with Claude fallback, monitor closely |
---
## Why This Matters
**Session 3 learning:** Backend WhatsApp stories (6.5-6.8) consistently failed dev-story while frontend i18n stories (7.1-7.2) succeeded. The original 8-rule system couldn't distinguish these patterns.
**v2.0 improvements:**
- 40 rules across 10 categories (was 8 rules, 1 category)
- Structural analysis adds AC count, dependency, and story size signals
- 5 simplicity reducers (was 2) prevent over-scoring simple work
- Expanded regex patterns catch contextual signals, not just exact keywords
- Recalibrated thresholds account for higher score range
**Without accurate complexity scoring:**
- Agent configuration cannot be informed by actual story difficulty
- Simple stories get over-provisioned (waste) or complex stories get under-provisioned (failure)
- The orchestration may fail or produce suboptimal results

View File

@@ -0,0 +1,174 @@
# Crash Recovery Pattern
**Purpose:** Handle sessions that crash or disappear unexpectedly.
---
## Detection
The status script returns `session_state` in CSV column 6:
- `crashed` - Session exited with non-zero exit code (column 5 = exit code, column 4 = output file)
- `not_found` - Session disappeared (killed, crashed without trace)
---
## Recovery Logic
| Condition | Action |
|-----------|--------|
| `crashed` with output file | Read output, check partial progress, retry |
| `not_found` (no output) | Session died silently, retry immediately |
| Retry 1 failed | Retry with `-r2` suffix in session name |
| Retry 2 failed | Escalate to user with diagnostics |
---
## Retry Pattern
```bash
# On crash/not_found, spawn retry with unique suffix
project_slug=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c1-8)
timestamp=$(date +%y%m%d-%H%M%S)
session_name="sa-${project_slug}-${timestamp}-e{epic}-s{story_suffix}-{step}-r2"
# Clear stale state (project-scoped v2.0)
PROJECT_HASH=$(echo -n "$PWD" | md5sum 2>/dev/null | cut -c1-8 || echo -n "$PWD" | md5 -q 2>/dev/null | cut -c1-8)
rm -f "/tmp/.sa-${PROJECT_HASH}-session-${session_name}-state.json"
# ... spawn and monitor as normal
```
---
## Agent Fallback (v3.0.0)
**Before escalating**, check if fallback agent is configured:
```bash
# Resolve agents for this story/task from agents file
selection=$("$scripts" orchestrator-helper agents-resolve \
--state-file "$state_file" --story "{story_id}" --task "{task}")
primary=$(echo "$selection" | jq -r '.primary')
fallback=$(echo "$selection" | jq -r '.fallback')
if [ "$fallback" != "false" ] && [ -n "$fallback" ]; then
if [ "$current_agent" = "$primary" ]; then
export AI_AGENT="$fallback"
retry_count=0
session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \
--command "$("$scripts" tmux-wrapper build-cmd dev {story_id})")
# Continue monitoring...
fi
fi
```
**Fallback flow:**
1. Primary agent crashes after 2 retries
2. IF `fallback != "false"` AND haven't tried fallback yet
3. Switch `AI_AGENT` to fallback agent
4. Reset retry counter to 0
5. Retry with fallback agent (gets 2 more attempts)
6. IF fallback also fails after 2 retries → CRITICAL escalation
**Log message:**
"Primary agent (claude) failed after 2 attempts. Switching to fallback agent (codex)..."
---
## Escalation (after exhausting all retries)
Display:
```
**Session crashed for Story {N}**
Primary agent: {primary} - Failed after 2 attempts
Fallback agent: {fallback} - Failed after 2 attempts
Exit code: {exit_code}
Partial progress: {tasks_completed}/{tasks_total}
[R]etry with primary
[F]allback retry
[S]kip story (mark deferred)
[A]bort orchestration
```
Show any partial output captured for diagnostics.
---
## Integration with Adaptive Retry
Crash recovery is SEPARATE from adaptive retry:
- **Adaptive retry** = session completed but FAILED (wrong output, tests failed)
- **Crash recovery** = session DIED unexpectedly (context limit, API error, kill)
Both can occur: a session might crash on attempt 1, then fail normally on attempt 2.
Track both counters independently.
---
## Orchestrator Monitoring Task Crash (v1.9.0)
### The Problem
When the orchestrator uses background tasks (e.g., Bash with `run_in_background`) to monitor tmux sessions, the monitoring task itself can crash. This is **different** from the tmux session crashing.
**Observed failure mode:**
1. Orchestrator spawns background task to run create+dev+monitor loop
2. Background task crashes after dev-story completes
3. TaskOutput shows "running" but task is dead
4. Tmux session actually completed successfully
5. Orchestrator waits forever on dead monitoring task
6. Code-review never runs because monitoring never returned
### Detection
Signs that your monitoring task has crashed (not the tmux session):
| Signal | Meaning |
|--------|---------|
| `TaskOutput` returns empty 2+ times | Task may be dead |
| Output file path doesn't exist | Task never wrote results |
| "running" status but no progress | Task is stuck or dead |
| Background task ID invalid | Task crashed |
### Recovery Sequence
**See `monitoring-fallback.md` for detailed fallback patterns.**
Quick reference:
1. Stop waiting on dead monitoring task
2. Find tmux sessions: `tmux list-sessions | grep "sa-.*e{epic}-s{story}"`
3. Check session status directly: `story-automator tmux-status-check`
4. Verify source of truth: story file, sprint-status.yaml
5. Resume based on verified state
### Prevention
**NEVER chain multiple workflow steps in a single background task:**
```bash
# ❌ WRONG - If this task crashes, all subsequent steps are lost
for step in create dev review; do
session=$(...spawn...)
result=$(...monitor...)
done
# ✅ CORRECT - Each step is monitored separately
# Step 1
session=$(...spawn create...)
result=$(...monitor...)
# Verify state
# Step 2 (only after Step 1 verified)
session=$(...spawn dev...)
result=$(...monitor...)
# Verify state
```
### Key Principle
**The tmux session is the source of truth for session state.**
**The story file and sprint-status.yaml are the source of truth for workflow state.**
Monitoring is just observation - if monitoring fails, verify from source of truth and continue.

View File

@@ -0,0 +1,100 @@
# Data File Index (v1.9.0)
**Purpose:** Explicit guidance on when to load each data file during execution.
---
## Loading Rules
1. **LOAD ONCE** = Read at step initialization, keep in context
2. **LOAD ON TRIGGER** = Read only when specific condition occurs
3. **NEVER LOAD** = Reference/debug files, not for execution
---
## Step 03: Execute - File Loading Guide
### LOAD ONCE (at step start)
| File | Why |
|------|-----|
| `orchestrator-rules.md` | Core rules for orchestrator behavior |
| `execution-patterns.md` | FORBIDDEN patterns - must know before any execution |
| `scripts-reference.md` | Script usage patterns |
### LOAD ON TRIGGER
| File | When to Load |
|------|--------------|
| `retry-fallback-strategy.md` | When a step FAILS and you need retry logic |
| `monitoring-fallback.md` | When monitoring FAILS (TaskOutput empty/error 2+ times) |
| `crash-recovery.md` | When session CRASHES (not just fails) |
| `code-review-loop.md` | When entering code review phase (Step D) |
| `escalation-triggers.md` | When considering escalation to user |
| `escalation-messages-core.md` | When displaying escalation message (triggers 1-4) |
| `escalation-messages-extended.md` | When displaying escalation message (triggers 5-8) |
| `agent-fallback.md` | When switching from primary to fallback agent |
| `agent-fallback-troubleshooting.md` | When fallback agent also fails |
| `adaptive-retry.md` | When same task fails 3+ times (plateau detection) |
| `subagent-prompts.md` | When parsing session output with sub-agent |
| `monitoring-codex.md` | When using Codex agent (not Claude) |
### NEVER LOAD DURING EXECUTION
| File | Purpose |
|------|---------|
| `tmux-commands.md` | Reference doc - use scripts instead |
| `tmux-long-command-*.md` | Debug/testing docs |
| `complexity-scoring.md` | Used during preflight, not execution |
| `preflight-prompts.md` | Used in step-02, not step-03 |
| `stop-hook-*.md` | Setup docs, not execution |
| `marker-file-format.md` | Internal format reference |
| `success-patterns.md` | Output pattern reference |
| `workflow-commands.md` | Reference doc |
| `wrapup-templates.md` | Used in step-04, not step-03 |
| `retrospective-*.md` | Used in step-03b retrospective section only |
---
## Quick Decision Tree
```
Starting execution?
→ Load: orchestrator-rules.md, execution-patterns.md, scripts-reference.md
Step failed?
→ Load: retry-fallback-strategy.md
→ If 3+ same failures: Load adaptive-retry.md
Monitoring not responding?
→ Load: monitoring-fallback.md
Session crashed?
→ Load: crash-recovery.md
Entering code review?
→ Load: code-review-loop.md
Need to escalate?
→ Load: escalation-triggers.md, then escalation-messages-*.md
Using Codex?
→ Load: monitoring-codex.md
```
---
## Anti-Pattern: Loading Everything
**WRONG:**
```
Load ALL data files at start of step-03
```
**WHY WRONG:** Bloats context, increases confusion, wastes tokens.
**CORRECT:**
```
Load 3 core files at start
Load additional files ONLY when their trigger condition occurs
```

View File

@@ -0,0 +1,103 @@
# Escalation Message Templates
Use these templates when an escalation trigger fires.
## 1. Code Review Loop Exceeded
**Pre-Escalation Verification:**
```bash
file_status=$("$scripts" orchestrator-helper story-file-status {story_id})
file_done=$(echo "$file_status" | jq -r '.status')
if [ "$file_done" = "done" ]; then
echo "✅ Story file shows done - sprint-status out of sync"
fi
test_result=$(cd "$PROJECT_ROOT" && go test ./src/... 2>&1 || npm test 2>&1 || true)
tests_pass=$([[ "$test_result" != *"FAIL"* ]] && echo "true" || echo "false")
```
**Diagnostic Summary (required):**
| Cycle | Agent | Issues Found | Fixed | Duration |
|-------|-------|--------------|-------|----------|
{cycle_history_table}
**Escalation message:**
```
🔔 DECISION NEEDED: Code Review Loop (5 cycles exhausted)
Story: {story_name}
Story ID: {story_id}
```
---
## 2. Cannot Parse Session Output
**Escalation message:**
```
🔔 DECISION NEEDED: Ambiguous Session Output
Story: {story_name}
Step: {step_name}
Session: {session_id}
Unable to determine if step succeeded or failed.
Last 20 lines of output:
{output_snippet}
Options:
[1] Mark as success and proceed
[2] Mark as failure and retry
[3] View full session output
[4] Pause for manual inspection
Select option:
```
---
## 3. Session Spawn Failure
**Escalation message:**
```
🔔 DECISION NEEDED: Session Spawn Failed
Story: {story_name}
Step: {step_name}
Error: {error_message}
Unable to spawn tmux session after retry.
Options:
[1] Retry again
[2] Skip this step
[3] Abort story
[4] Pause orchestration
Select option:
```
---
## 4. Git Commit Failure
**Escalation message:**
```
🔔 DECISION NEEDED: Git Commit Failed
Story: {story_name}
Error: {error_message}
Unable to commit changes for this story.
Options:
[1] Retry commit
[2] Skip commit and proceed (changes remain uncommitted)
[3] Pause for manual git resolution
[4] Abort story
Select option:
```
---

View File

@@ -0,0 +1,76 @@
# Escalation Message Templates (Extended)
## 5. Unexpected Error
**Escalation message:**
```
🔔 DECISION NEEDED: Unexpected Error
Story: {story_name}
Step: {step_name}
Error: {error_message}
An unexpected error occurred during orchestration.
Options:
[1] Retry current step
[2] Skip current step
[3] Abort story and continue with next
[4] Pause orchestration for investigation
Select option:
```
---
## 6. Dependency Conflict
**Escalation message:**
```
🔔 DECISION NEEDED: Potential Dependency Conflict
Stories in parallel: {story_list}
Detected conflict: {conflict_description}
These stories may have conflicting changes.
Options:
[1] Continue in parallel (accept risk)
[2] Run sequentially instead
[3] Pause for manual review
Select option:
```
---
## 7. Dev-Story Implementation Failure
**Pre-escalation behavior:**
1. Check blocking status (conservative if uncertain)
2. If BLOCKING: retry up to 3 times
3. If NOT BLOCKING: retry once
**Escalation message:**
```
🔔 DECISION NEEDED: Dev-Story Implementation Failure
Story: {story_name}
Step: dev-story
Attempts: {attempt_count}
Blocking: {yes/no} (affects stories: {list or "none"})
Latest error:
{error_summary}
Options:
[1] Retry dev-story - Spawn new session to fix
[2] Manual fix - Pause orchestration so you can fix it
[3] View session output - See full output
[4] Skip story - Move to next (only if not blocking)
[5] Abort orchestration - Stop entire build cycle
Select option:
```
**Note:** Option [4] only valid if story is NOT blocking.

View File

@@ -0,0 +1,5 @@
# Escalation Message Templates
See:
- `escalation-messages-core.md` (Triggers 1-4)
- `escalation-messages-extended.md` (Triggers 5-7)

View File

@@ -0,0 +1,114 @@
# Escalation Triggers
**Purpose:** Conditions that require human decision and cannot be resolved autonomously.
## Escalation Categories
### CRITICAL Escalations
**Definition:** Automation CANNOT proceed - requires human decision.
**Behavior:**
1. Delete marker file: `rm "{marker_file}"`
2. Update state: set status to PAUSED in state document
3. Present options (stop hook won't interfere)
4. Wait for user input
5. On resume: recreate marker, set IN_PROGRESS, continue
**Triggers in this category:**
- Code Review Loop Exceeded (#1)
- Session Spawn Failure (#3)
- Git Commit Failure (#4)
- Unexpected Error (#5)
- Dev-Story Implementation Failure (#7) when blocking + retries exhausted
- Session Incomplete (#8) - session finished but workflow not verified complete (v2.2)
### PREFERENCE Escalations
**Definition:** Automation COULD proceed either way - user chooses direction.
**Behavior:**
1. Keep marker file (automation still "active")
2. Present options
3. Act on selection immediately
**Triggers in this category:**
- Cannot Parse Session Output (#2)
- Dependency Conflict (#6)
- Dev-Story Implementation Failure (#7) when NOT blocking
---
## Escalation Protocol
When an escalation trigger is hit:
1. Categorize: CRITICAL or PREFERENCE
2. If CRITICAL: delete marker, set status to PAUSED
3. Notify: sound/notification
4. Present: situation + numbered options
5. Wait: halt until user responds
6. Log: record decision in action log
7. Resume: if CRITICAL, recreate marker, set IN_PROGRESS, continue
---
## Trigger Index
Each trigger includes its escalation message template in:
- `data/escalation-messages-core.md` (Triggers 1-4)
- `data/escalation-messages-extended.md` (Triggers 5-7)
### 1. Code Review Loop Exceeded (CRITICAL)
**Trigger:** Code review has run 5 cycles without clean status.
**See:** `escalation-messages-core.md#1-code-review-loop-exceeded`
### 2. Cannot Parse Session Output (PREFERENCE)
**Trigger:** Output doesn't match success/failure patterns.
**See:** `escalation-messages-core.md#2-cannot-parse-session-output`
### 3. Session Spawn Failure (CRITICAL)
**Trigger:** T-Mux session failed to spawn after retries.
**See:** `escalation-messages-core.md#3-session-spawn-failure`
### 4. Git Commit Failure (CRITICAL)
**Trigger:** Git commit failed (conflict, hook error, etc.).
**See:** `escalation-messages-core.md#4-git-commit-failure`
### 5. Unexpected Error (CRITICAL)
**Trigger:** Unhandled exception or unexpected condition.
**See:** `escalation-messages-extended.md#5-unexpected-error`
### 6. Dependency Conflict (PREFERENCE)
**Trigger:** Parallelism detects potential conflict.
**See:** `escalation-messages-extended.md#6-dependency-conflict`
### 7. Dev-Story Implementation Failure (CRITICAL or PREFERENCE)
**Trigger:** dev-story completes with errors after retries.
**See:** `escalation-messages-extended.md#7-dev-story-implementation-failure`
### 8. Session Incomplete (CRITICAL) [v2.2]
**Trigger:** `story-automator monitor-session` returns `final_state: "incomplete"` **after maxCycles exhausted**
**Condition:** Session finished (idle/exited) but workflow verification failed across all retry attempts.
**Typical cause:** Codex code-review session ended without updating sprint-status.
**Why CRITICAL (not PREFERENCE):**
- Automated retries already exhausted
- Human must decide: manual fix, use Claude, or skip story
**Options:**
1. **[1] Manual Fix** - Update sprint-status.yaml yourself
2. **[2] Run with Claude** - Re-run code-review with Claude agent
3. **[3] Skip Story** - Mark story as skipped and continue
4. **[X] Pause** - Stop orchestration for investigation
**Verification command:**
```bash
"$scripts" orchestrator-helper verify-code-review {story_id}
```
---
## Non-Escalation Conditions
Handled automatically (no escalation):
- Optional step (automate) skipped by override → log and continue
- Session completes with clear success → continue
- Session completes with clear failure → retry once, then escalate if still fails

View File

@@ -0,0 +1,59 @@
# Execution Patterns (v1.9.0)
**Purpose:** Critical execution patterns and anti-patterns for the orchestrator.
---
## 🚨 FORBIDDEN EXECUTION PATTERNS (NO EXCEPTIONS)
### NEVER Chain Multiple Workflow Steps
**FORBIDDEN:**
```bash
# ❌ WRONG - Chaining steps in a loop bypasses per-step error handling
for step in create dev; do
session=$("$scripts" tmux-wrapper spawn "$step" ...)
result=$("$scripts" monitor-session "$session" ...)
done
```
**WHY:** If the monitoring task crashes mid-loop, ALL subsequent steps are lost. The orchestrator loses visibility even though tmux sessions may have completed successfully.
**REQUIRED:**
```bash
# ✅ CORRECT - Each step is a separate operation with its own error handling
# Step A: Create
session=$("$scripts" tmux-wrapper spawn create ...)
result=$("$scripts" monitor-session "$session" ...)
"$scripts" tmux-wrapper kill "$session"
# VERIFY state before proceeding
# Step B: Dev (only after create verified)
session=$("$scripts" tmux-wrapper spawn dev ...)
result=$("$scripts" monitor-session "$session" ...)
"$scripts" tmux-wrapper kill "$session"
# VERIFY state before proceeding
```
---
## ALWAYS Verify State After Each Step
After each workflow step completes (create/dev/auto/review), **VERIFY state from source of truth** before proceeding to the next step:
1. **Story file exists and has expected content** (for create-story)
2. **Sprint-status.yaml shows correct status** (for dev-story, code-review)
3. **DO NOT rely solely on monitoring output** - if monitoring fails, verify directly
---
## IF Monitoring Fails
If `story-automator monitor-session` or background task monitoring fails:
1. Check if tmux session still exists: `tmux list-sessions | grep {pattern}`
2. Check session status directly: `"$scripts" tmux-status-check "$session"`
3. Verify story file / sprint-status regardless of monitoring output
4. Only escalate after direct verification confirms failure
**See also:** `monitoring-fallback.md` for detailed fallback patterns.

View File

@@ -0,0 +1,67 @@
# Marker File Format
**Location:** Resolved by `orchestrator-helper marker path` for the active runtime layout:
- Claude: `.claude/.story-automator-active`
- Codex: follows the active Codex skill root parent, usually `.agents/.story-automator-active` or `.codex/.story-automator-active`
If a runtime is explicitly selected but the installed story-automator skill is discovered under another supported root, the marker follows that active skill root. Always use `orchestrator-helper marker path` rather than hard-coding the marker path.
**Purpose:** Enables the Stop hook to prevent premature stopping during orchestration.
---
## JSON Structure
```json
{
"epic": "{epic_id}",
"currentStory": "{first_story_id}",
"storiesRemaining": {story_count},
"stateFile": "{path_to_state_document}",
"startedAt": "{timestamp}",
"heartbeat": "{timestamp}",
"pid": {process_id},
"projectSlug": "{project_slug}"
}
```
---
## Field Descriptions
| Field | Description |
|-------|-------------|
| `epic` | Epic identifier (e.g., "5") |
| `currentStory` | Current story being processed (e.g., "5.3") |
| `storiesRemaining` | Count of stories left in queue |
| `stateFile` | Path to orchestration state document |
| `startedAt` | Orchestration start timestamp (ISO 8601) |
| `heartbeat` | Last activity timestamp, updated periodically |
| `pid` | Process ID of orchestrator (crash detection) |
| `projectSlug` | (v2.0) Project identifier for session naming |
---
## Heartbeat Updates
The orchestrator should update the heartbeat timestamp every ~5 minutes during long-running operations. This prevents the marker from going stale if the orchestrator is still running but taking a while on a complex story.
**Staleness threshold:** 30 minutes (see story-automator stop-hook)
---
## Creation Command
```bash
project_slug=$(echo "$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}")" | jq -r '.slug')
"{stateHelper}" orchestrator-helper marker create --epic "$epic_id" --story "$first_story_id" \
--remaining "$selected_count" --state-file "$state_path" \
--project-slug "$project_slug" --pid "$$" --heartbeat "{timestamp}"
```
---
## Related Documentation
- **Stop Hook:** See `stop-hook-config.md` for hook behavior
- **Troubleshooting:** See `stop-hook-troubleshooting.md` for issues

View File

@@ -0,0 +1,66 @@
# Codex-Specific Monitoring (v2.4.0)
**Purpose:** Special handling for Codex CLI sessions in story-automator monitor-session
---
## Agent Detection
Codex sessions are detected by:
1. `AI_AGENT` environment variable (most reliable)
2. Explicit Codex CLI identifiers: `OpenAI Codex`, `codex exec`, `codex-cli`, `gpt-*-codex`, `tokens used`
---
## Session States for Codex
| State | Meaning | Detection |
|-------|---------|-----------|
| `in_progress` | Codex actively working | Heartbeat alive OR output changed recently |
| `idle` | Session alive but no prompt yet | Heartbeat idle + output stale (pre-stuck window) |
| `completed` | CLI has exited | Prompt returned, pane exited, or `tokens used` |
| `stuck` | No recent output for too long | Output stale beyond threshold |
**Key Difference:** For Codex, "idle" is NOT the same as "completed". The CLI may have stopped but the workflow might not have finished.
---
## Output Freshness vs Completed Detection
```
output_fresh(): Output hash changed within CODEX_OUTPUT_STALE_SECONDS
codex_completed(): Prompt returned, pane exited, or "tokens used"
```
**Priority:** `completed` > `active` > `idle` > `stuck`
### Output Staleness Window
`CODEX_OUTPUT_STALE_SECONDS` (default: 300) defines how long Codex can be silent
before the session is considered `stuck`. Any output change refreshes the timer.
---
## Code-Review Workflow Verification
For code-review with Codex, story-automator monitor-session verifies completion:
```bash
# Must pass --workflow and --story-key for verification
result=$("$scripts" monitor-session "$session" --json \
--workflow review --story-key {story_id})
```
**Verification checks:**
1. Sprint-status.yaml shows "done" for story
2. OR story file Status field shows "done"
3. If neither → `final_state: "incomplete"`
---
## Fake Todo Progress
Codex doesn't use TodoWrite, so `story-automator tmux-status-check` fakes progress:
- Start: `todos_total=1, todos_done=0`
- While running: Keep `0/1`
- On idle after activity: Set `1/1` (signals "done, needs verification")

View File

@@ -0,0 +1,85 @@
# Monitoring Failure Fallback (v1.9.0)
**Purpose:** Recovery patterns when primary monitoring fails.
---
## When Primary Monitoring Fails
Primary monitoring can fail in several ways:
- Background task crashes (TaskOutput returns empty/error)
- Network timeout during monitoring
- Process killed unexpectedly
- Output file missing or corrupted
**Key insight:** The tmux session may have completed successfully even if monitoring died.
---
## Fallback Sequence
When `story-automator monitor-session` fails or background monitoring task dies:
```bash
# STEP 1: Check if tmux session still exists
sessions=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "sa-.*{story_pattern}" || true)
# STEP 2: If session exists, check its status directly
if [ -n "$sessions" ]; then
while IFS= read -r session; do
status=$("$scripts" tmux-status-check "$session")
session_state=$(echo "$status" | cut -d',' -f6)
# Act based on direct status
done <<< "$sessions"
fi
# STEP 3: ALWAYS verify source of truth regardless of session status
# Story file check:
story_file=$(ls _bmad-output/implementation-artifacts/{story_prefix}-*.md 2>/dev/null | head -1)
if [ -f "$story_file" ]; then
# Story file exists - check its status field
fi
# Sprint-status check:
status=$("$scripts" orchestrator-helper sprint-status get "{story_key}")
is_done=$(echo "$status" | jq -r '.done')
```
---
## Detection: Monitoring Task Crashed
Signs that your monitoring task has crashed:
| Signal | Meaning |
|--------|---------|
| `TaskOutput` returns empty 2+ times | Task may be dead |
| Output file path doesn't exist | Task never wrote results |
| "running" status but no progress | Task is stuck or dead |
**Recovery:**
1. Do NOT wait indefinitely for dead monitoring task
2. After 2+ empty TaskOutput results, switch to direct verification
3. Use tmux session checks + source of truth verification
4. Resume workflow based on verified state, not monitoring state
---
## Integration with Retry Logic
**If fallback verification shows step succeeded:**
- Proceed to next step (monitoring failed but workflow succeeded)
- Log: "Monitoring failed but direct verification confirmed success"
**If fallback verification shows step failed/incomplete:**
- Apply normal retry/fallback strategy
- Do NOT treat monitoring failure as step failure
---
## Key Principle
**The tmux session is the source of truth for session state.**
**The story file and sprint-status.yaml are the source of truth for workflow state.**
Monitoring is just observation - if monitoring fails, verify from source of truth and continue.

View File

@@ -0,0 +1,27 @@
# Monitoring Pattern: Parsing & Review Handling
## Sub-Agent Pattern
**ALWAYS use sub-agent for output parsing:**
```bash
# Correct: Let haiku parse
parsed=$("$scripts" orchestrator-helper parse-output "$output_file" dev)
action=$(echo "$parsed" | jq -r '.next_action')
# WRONG: Parse yourself
# content=$(cat "$output_file") # DON'T DO THIS
# if grep -q "SUCCESS" ... # DON'T DO THIS
```
**Why:** Sub-agent costs ~200 tokens. Main context is ~50k+. Parsing yourself wastes 99% more context.
---
## Code Review Special Handling
See `code-review-loop.md` for review cycle logic. Key points:
- Auto-fix via instruction: `code-review ${story_id} auto-fix all issues without prompting`
- No menu detection needed - instruction handles it
- After completion, verify sprint-status before proceeding

View File

@@ -0,0 +1,186 @@
# Session Monitoring Pattern
## Quick Reference
**All monitoring is handled by the installed helper (`$scripts`, usually `scripts/story-automator`). DO NOT manually construct tmux commands.**
### Binary Location
```
scripts/
└── story-automator # single Python helper (use subcommands below)
```
---
## 🚨 FORBIDDEN PATTERNS (NO EXCEPTIONS)
| Pattern | Why Forbidden |
|---------|---------------|
| `tmux capture-pane` directly | Context bloat, use status script |
| `while true` loops in LLM context | Session crash, use `$scripts monitor-session` |
| Manual session name construction | Error-prone, use `$scripts tmux-wrapper` |
| Parsing raw output yourself | Use `$scripts orchestrator-helper parse-output` |
---
## Standard Workflow: Spawn + Monitor + Verify (Create Example)
```bash
# STEP 1: Spawn session (use $scripts tmux-wrapper)
session_name=$("$scripts" tmux-wrapper spawn create 5 5.3 \
--command "$("$scripts" tmux-wrapper build-cmd create 5.3 --state-file "$state_file")")
# STEP 2: Monitor until completion (SINGLE API CALL)
result=$("$scripts" monitor-session "$session_name" \
--verbose --json \
--workflow create --story-key 5.3 --state-file "$state_file")
# STEP 3: Verify success against the shared create contract
validation=$("$scripts" orchestrator-helper verify-step create 5.3 --state-file "$state_file")
verified=$(echo "$validation" | jq -r '.verified')
# STEP 4: Act on verifier result
[ "$verified" = "true" ] || echo "retry-or-escalate"
# STEP 5: ALWAYS cleanup session (v1.2.0)
"$scripts" tmux-wrapper kill "$session_name"
```
**Context savings:** This entire cycle is 5 bash calls instead of 15+ API roundtrips.
**Session Cleanup (v1.2.0):** ALWAYS kill the session after processing, regardless of success or failure. Orphaned sessions consume resources and cause confusion.
---
## Script Quick Reference
### $scripts tmux-wrapper
```bash
# Spawn session
"$scripts" tmux-wrapper spawn <step> <epic> <story_id> [--command "..."] [--cycle N]
# Generate session name only
"$scripts" tmux-wrapper name <step> <epic> <story_id> [--cycle N]
# Build workflow command
"$scripts" tmux-wrapper build-cmd <step> <story_id> [extra_instruction]
# List/kill sessions
"$scripts" tmux-wrapper list [--project-only]
"$scripts" tmux-wrapper kill <session_name>
"$scripts" tmux-wrapper kill-all [--project-only]
```
### $scripts monitor-session
```bash
# Monitor until completion (returns when session ends)
"$scripts" monitor-session <session_name> [options]
# Options:
# --max-polls N Maximum iterations (default: 30)
# --timeout MIN Overall timeout in minutes (default: 60)
# --verbose Print progress to stderr
# --json Output as JSON instead of CSV
# Output (JSON):
# {"final_state":"completed|crashed|stuck|timeout|incomplete|not_found","output_file":"/tmp/...","exit_reason":"..."}
```
### $scripts orchestrator-helper
```bash
# Check sprint status
"$scripts" orchestrator-helper sprint-status get <story_key>
# Parse session output with sub-agent (haiku)
"$scripts" orchestrator-helper parse-output <file> <step_type>
# Marker file operations
"$scripts" orchestrator-helper marker create --epic E --story S --remaining N
"$scripts" orchestrator-helper marker remove
"$scripts" orchestrator-helper marker check
# Escalation checks
"$scripts" orchestrator-helper escalate <trigger> <context>
```
### $scripts orchestrator-helper verify-step
```bash
# Validate create-story via the shared success verifier
"$scripts" orchestrator-helper verify-step create 5.3 --state-file "$state_file"
```
---
## Decision Flow
After `$scripts monitor-session` returns:
| final_state | Action |
|-------------|--------|
| `completed` | Run step verifier or parser for the active workflow |
| `incomplete` | **(v2.2)** Session idle but workflow NOT verified → Escalate immediately |
| `crashed` | Check retry count → retry or escalate |
| `stuck` | Get output → investigate → may need restart |
| `timeout` | Get output → escalate to user |
| `not_found` | Session gone → check for partial work |
---
## Monitoring Failure Fallback (v1.9.0)
**See `monitoring-fallback.md` for complete fallback patterns when monitoring fails.**
Key points:
- If monitoring crashes, tmux session may have completed successfully
- Fall back to direct session checks + source of truth verification
- Do NOT treat monitoring failure as step failure
---
## Statusline Time Gate (v2.6.0)
**Purpose:** Prevent ALL false "stuck" escalations by using the Claude Code statusline as definitive proof-of-life.
### How It Works
Claude Code displays a statusline at the bottom of the terminal:
```
folder | ctx(N%) | HH:MM:SS
^^^^^^^^ <- This time updates continuously while Claude runs
```
The installed helper's `$scripts tmux-status-check` command:
1. Parses the statusline time from the tmux pane
2. Stores it in the session state file
3. Compares with previous poll's time
4. **If time has advanced → session is ALIVE → DO NOT escalate**
### Decision Matrix
| Previous Time | Current Time | Other Checks Say | Result |
|---------------|--------------|------------------|--------|
| 10:00:00 | 10:01:00 | stuck | `just_started` (time advanced = alive) |
| 10:00:00 | 10:00:00 | stuck | `stuck` (time unchanged) |
| (none) | 10:00:00 | stuck | `just_started` (first observation = alive) |
| (none) | (none) | stuck | `stuck` (no statusline data) |
### Key Principle
**The statusline time gate is the FINAL AUTHORITY.** Even if all other detection methods (process checks, activity indicators, heartbeat) suggest the session is stuck, if the statusline time has advanced, the session is definitively alive and MUST NOT be escalated.
This prevents false escalations for:
- Complex sessions in long thinking phases
- Sessions with unusual output patterns
- Edge cases where other detection fails
---
## References
- **Codex monitoring details:** `monitoring-codex.md`
- **Output parsing + review handling:** `monitoring-pattern-parsing.md`

View File

@@ -0,0 +1,146 @@
{
"version": 1,
"snapshot": {
"relativeDir": "_bmad-output/story-automator/policy-snapshots"
},
"runtime": {
"parser": {
"provider": "claude",
"model": "haiku",
"timeoutSeconds": 120
},
"merge": {
"maps": "deep",
"arrays": "replace"
}
},
"workflow": {
"sequence": ["create", "dev", "auto", "review", "retro"],
"repeat": {
"review": {
"maxCycles": 5,
"successVerifier": "review_completion",
"onIncomplete": "retry",
"onExhausted": "escalate"
}
},
"crash": {
"maxRetries": 2,
"onExhausted": "escalate"
}
},
"steps": {
"create": {
"label": "create-story",
"assets": {
"skillName": "bmad-create-story",
"workflowCandidates": ["workflow.md", "workflow.yaml"],
"instructionsCandidates": ["discover-inputs.md"],
"checklistCandidates": ["checklist.md"],
"templateCandidates": ["template.md"],
"required": ["skill"]
},
"prompt": {
"templateFile": "data/prompts/create.md",
"interactionMode": "autonomous"
},
"parse": {
"schemaFile": "data/parse/create.json"
},
"success": {
"verifier": "create_story_artifact",
"config": {
"glob": "_bmad-output/implementation-artifacts/{story_prefix}-*.md",
"expectedMatches": 1
}
}
},
"dev": {
"label": "dev-story",
"assets": {
"skillName": "bmad-dev-story",
"workflowCandidates": ["workflow.md", "workflow.yaml"],
"instructionsCandidates": [],
"checklistCandidates": ["checklist.md"],
"templateCandidates": [],
"required": ["skill"]
},
"prompt": {
"templateFile": "data/prompts/dev.md",
"interactionMode": "autonomous"
},
"parse": {
"schemaFile": "data/parse/dev.json"
},
"success": {
"verifier": "session_exit"
}
},
"auto": {
"label": "qa-generate-e2e-tests",
"assets": {
"skillName": "bmad-qa-generate-e2e-tests",
"workflowCandidates": ["workflow.md", "workflow.yaml"],
"instructionsCandidates": [],
"checklistCandidates": ["checklist.md"],
"templateCandidates": [],
"required": []
},
"prompt": {
"templateFile": "data/prompts/auto.md",
"interactionMode": "autonomous"
},
"parse": {
"schemaFile": "data/parse/auto.json"
},
"success": {
"verifier": "session_exit"
}
},
"review": {
"label": "code-review",
"assets": {
"skillName": "bmad-story-automator-review",
"workflowCandidates": ["workflow.yaml", "workflow.md"],
"instructionsCandidates": ["instructions.xml"],
"checklistCandidates": ["checklist.md"],
"templateCandidates": [],
"required": ["skill"]
},
"prompt": {
"templateFile": "data/prompts/review.md",
"interactionMode": "autonomous",
"acceptExtraInstruction": true,
"defaultExtraInstruction": "auto-fix all issues without prompting"
},
"parse": {
"schemaFile": "data/parse/review.json"
},
"success": {
"verifier": "review_completion",
"contractFile": "<skills-root>/bmad-story-automator-review/contract.json"
}
},
"retro": {
"label": "retrospective",
"assets": {
"skillName": "bmad-retrospective",
"workflowCandidates": ["workflow.md", "workflow.yaml"],
"instructionsCandidates": [],
"checklistCandidates": [],
"templateCandidates": [],
"required": ["skill"]
},
"prompt": {
"templateFile": "data/prompts/retro.md",
"interactionMode": "autonomous"
},
"parse": {
"schemaFile": "data/parse/retro.json"
},
"success": {
"verifier": "epic_complete"
}
}
}
}

View File

@@ -0,0 +1,86 @@
# Orchestrator Rules Appendix
## Session Naming
**See `tmux-commands.md` for complete session naming documentation.**
Pattern: `sa-{project_slug}-{timestamp}-e{epic}-s{N}-{type}` where type = `create`, `dev`, `auto`, `review-{cycle}`
## Workflow Command Arguments
**CRITICAL:** ALWAYS pass required positional arguments to BMAD workflows.
### Story ID Requirement
**create-story, dev-story, code-review, automate (`testarch-automate` or `qa-generate-e2e-tests`)** — All require the story ID as a positional argument.
**WRONG:**
```bash
Execute the BMAD create-story workflow.
```
This causes create-story to create ALL stories in the epic, not just one.
**CORRECT:**
```bash
Execute the BMAD create-story workflow for story 5.3.
```
This creates ONLY story 5.3.
### Validation After create-story
**After create-story session completes:**
1. Count story files BEFORE spawning session
2. Count story files AFTER session completes
3. Verify exactly ONE new file created
4. IF 0 or >1 files → Escalate with file list
**This prevents runaway story creation** where create-story creates 5.3, 5.4, 5.5, etc. instead of just the requested story.
## State Updates
After EVERY action:
1. Update `currentStep` in state document
2. Log action with timestamp
3. Update story progress table
## Escalation Protocol
**See `data/escalation-triggers.md` for complete trigger definitions and behavior.**
### Quick Reference
| Category | Marker Action | State | When |
|----------|---------------|-------|------|
| CRITICAL | **DELETE** | PAUSED | Cannot proceed (retries exhausted) |
| PREFERENCE | Keep | IN_PROGRESS | Could proceed either way |
### CRITICAL Escalation (Key Steps)
1. Delete marker: run `orchestrator-helper marker remove` via the installed story-automator helper
2. Set state to PAUSED
3. Present menu (stop hook won't interfere)
4. On resume: recreate marker, set IN_PROGRESS
### Dev-Story Smart Retry
Before escalating, check if story is blocking:
- **Blocking:** Retry up to 3 times → then CRITICAL
- **Not blocking:** Retry once → then PREFERENCE (can skip)
## Session Monitoring & Output Parsing
**CRITICAL:** These topics have dedicated reference files. Load them when needed:
- **Session Monitoring:** See `data/monitoring-pattern.md`
- FORBIDDEN patterns (capture-pane, etc.)
- Status script usage and CSV format
- Decision tree for poll results
- Polling loop with state tracking
- **Output Parsing:** See `data/monitoring-pattern.md` (Sub-Agent Invocation section)
- NEVER parse output yourself
- ALWAYS use sub-agents (Task tool, haiku)
- Verification checkpoint before proceeding
- **Sub-Agent Prompts:** See `data/subagent-prompts.md`
- Session Output Parser
- Code Review Analyzer (also see `subagent-prompts-analysis.md`)

View File

@@ -0,0 +1,180 @@
# Orchestrator Rules
Load once at workflow start. Do not re-read in subsequent steps.
---
## Your Role
You are the **Build Cycle Orchestrator** — an autonomous coordinator that:
- Spawns T-Mux sessions for each workflow step
- Monitors progress and parses outputs
- Handles code review loops until clean
- Commits after each completed story
- Escalates to user ONLY when decisions are needed
## Ground Truth: sprint-status.yaml
**CRITICAL:** `_bmad-output/implementation-artifacts/sprint-status.yaml` is the single source of truth.
### 🚨 ABSOLUTE RULE: NEVER UPDATE sprint-status.yaml 🚨
**YOU (the orchestrator) MUST NEVER, EVER write to sprint-status.yaml.**
- ❌ NEVER use Edit tool on sprint-status.yaml
- ❌ NEVER use Write tool on sprint-status.yaml
- ❌ NEVER use Bash to modify sprint-status.yaml
- ❌ NEVER "fix" mismatches by updating sprint-status.yaml
**WHO updates it:** The T-Mux sessions running dev-story, code-review, etc.
**IF MISMATCH DETECTED:**
1. Do NOT "correct" sprint-status.yaml
2. Re-run the workflow that SHOULD update it (dev-story, code-review)
3. The session will update sprint-status.yaml as part of its workflow
**When to READ (read-only):**
- At initialization — check if earlier stories are incomplete
- When resuming — verify current state matches
- After each story "completes" — verify sprint-status shows `done`
**Initialization/Resume check:**
- If earlier stories in range are not `done`, ask user: "Stories X, Y are not complete. Process them first?"
- If yes → add them to queue before requested stories
**Post-story verification:**
- After code review passes and commit succeeds, check sprint-status.yaml
- If story is NOT marked `done` → re-run code-review (it will update sprint-status)
- Only proceed to next story when sprint-status confirms `done`
### Sprint-Status "done" from Dev-Story (Session 22 Note)
**IMPORTANT:** If dev-story marks sprint-status as "done" but code-review later finds HIGH issues:
- This is EXPECTED behavior - dev-story completes successfully, but code-review finds additional issues
- The code-review workflow will update sprint-status appropriately
- Do NOT trust "done" status from dev-story alone
- ALWAYS run code-review to verify the implementation quality
## Custom Instructions
User-provided instructions are flexible and may apply to:
- The orchestrator itself (e.g., "prioritize story 3")
- Specific sessions (e.g., "always run tests" → pass to dev sessions)
- Conditional situations (e.g., "always run tests after changes")
**Interpret intelligently** — Don't mechanically inject instructions everywhere. Apply judgment about when and how instructions are relevant.
## Core Rules
1. **Coordinate, don't implement** — Spawn sessions, don't write code yourself
2. **Log everything** — Update state document after every action
3. **Escalate, don't decide** — When uncertain, ask the user
4. **Use sub-agents for parsing** — Don't bloat context with raw output
5. **Follow the sequence** — Don't skip or reorder steps
6. **Sprint-status is truth** — Always sync with sprint-status.yaml
7. **Always cleanup sessions** — Kill tmux sessions after completion (v1.2.0)
8. **Verify state after each step** — Check source of truth, not just monitoring output (v1.9.0)
---
## State Verification After Each Step (v1.9.0)
### 🚨 CRITICAL: Verify Before Proceeding
After **EVERY** workflow step completes (create/dev/auto/review), you MUST verify state from the **source of truth** before proceeding to the next step.
**DO NOT rely solely on monitoring output.** Monitoring can fail, crash, or lose connection. The source of truth is:
- **Story files** in `_bmad-output/implementation-artifacts/`
- **sprint-status.yaml** in `_bmad-output/implementation-artifacts/`
### Verification Sequence
After each step:
```bash
# 1. Get monitoring result (may be incomplete/failed)
result=$("$scripts" monitor-session "$session" --json)
final_state=$(echo "$result" | jq -r '.final_state')
# 2. ALWAYS verify from source of truth regardless of monitoring result
# For create-story: verify story file exists
# For dev-story: verify sprint-status updated
# For code-review: verify sprint-status shows "done"
# 3. Only proceed when source of truth confirms success
```
### Monitoring Failure Fallback
**See `monitoring-fallback.md` for complete fallback patterns.**
Quick reference:
1. Check if session exists: `tmux list-sessions | grep {session_pattern}`
2. Check session status directly: `"$scripts" tmux-status-check "$session"`
3. Verify source of truth: story file / sprint-status.yaml
4. Proceed based on verified state, not monitoring state
### Why This Matters
Observed failure mode: Orchestrator's monitoring task crashed after dev-story completed. The tmux session had actually succeeded, but the orchestrator lost visibility and never ran code-review. **Direct state verification would have recovered from this.**
---
## Agent Fallback Strategy
**See `agent-fallback.md` for complete multi-agent documentation.**
**Troubleshooting:** `agent-fallback-troubleshooting.md`
**Quick Reference:**
- Primary/fallback agents configurable (Claude or Codex)
- Different CLI commands and prompt styles per agent
- Automatic fallback on crash after retries exhausted
- Codex has 1.5x timeouts, no todo tracking
---
### 🚨 ABSOLUTE RULE: NEVER Change Working Directory 🚨
**YOU (the orchestrator) MUST NEVER use the `cd` command.**
- ❌ NEVER use `cd backend && ...`
- ❌ NEVER use `cd /path/to/dir`
- ❌ NEVER change working directory for ANY reason
- ✅ ALWAYS use absolute paths for all file operations
- ✅ ALWAYS use absolute paths for script invocations
**Why?** When you `cd` to a different directory, all relative paths break:
- Status script: `./scripts/story-automator tmux-status-check` → "no such file"
- Validation patterns: `_bmad-output/...` → wrong location
- All monitoring fails, causing fallback to FORBIDDEN patterns
**Example - WRONG:**
```bash
cd backend && go test ./internal/api/...
```
**Example - CORRECT:**
```bash
go test {project_root}/backend/internal/api/...
```
### 🚨 ABSOLUTE RULE: NEVER Edit Source Code Directly 🚨
**YOU (the orchestrator) MUST NEVER use Edit/Write tools on source code.**
- ❌ NEVER use Edit tool on `.go`, `.ts`, `.tsx`, `.js`, `.py`, etc.
- ❌ NEVER use Write tool to create source code files
- ❌ NEVER "fix issues" by modifying code directly
- ✅ ALWAYS spawn a T-Mux session (dev-story) to make code changes
- ✅ ALWAYS delegate code fixes to child sessions
**Why?** The orchestrator's role is COORDINATION, not implementation. All code changes must go through proper workflow sessions that:
- Have full project context
- Run tests after changes
- Update sprint-status appropriately
- Can be reviewed and audited
## Appendix
See `orchestrator-rules-appendix.md` for session naming, workflow command arguments, monitoring, and output parsing details.

View File

@@ -0,0 +1,10 @@
{
"requiredKeys": ["status", "tests_added", "coverage_improved", "summary", "next_action"],
"schema": {
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"tests_added": "integer",
"coverage_improved": "true|false",
"summary": "brief description",
"next_action": "proceed|retry|escalate"
}
}

View File

@@ -0,0 +1,10 @@
{
"requiredKeys": ["status", "story_created", "story_file", "summary", "next_action"],
"schema": {
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"story_created": "true|false",
"story_file": "path or null",
"summary": "brief description",
"next_action": "proceed|retry|escalate"
}
}

View File

@@ -0,0 +1,10 @@
{
"requiredKeys": ["status", "tests_passed", "build_passed", "summary", "next_action"],
"schema": {
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"tests_passed": "true|false",
"build_passed": "true|false",
"summary": "brief description",
"next_action": "proceed|retry|escalate"
}
}

View File

@@ -0,0 +1,8 @@
{
"requiredKeys": ["status", "summary", "next_action"],
"schema": {
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"summary": "brief description",
"next_action": "proceed|retry|escalate"
}
}

View File

@@ -0,0 +1,15 @@
{
"requiredKeys": ["status", "issues_found", "all_fixed", "summary", "next_action"],
"schema": {
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"issues_found": {
"critical": "integer",
"high": "integer",
"medium": "integer",
"low": "integer"
},
"all_fixed": "true|false",
"summary": "brief description",
"next_action": "proceed|retry|escalate"
}
}

View File

@@ -0,0 +1,141 @@
# Pre-flight Prompts
Reference prompts for the pre-flight configuration step.
---
## Context Gathering Questions
Present these questions to gather implementation context:
```
**Context Gathering:**
To help the implementation sessions succeed, please clarify:
1. **Technical Context:** Are there any architectural decisions, patterns, or conventions the dev sessions should follow?
2. **Testing Requirements:** Any specific testing frameworks or coverage expectations?
3. **Dependencies:** Are there external services, APIs, or packages that need to be set up first?
4. **Known Challenges:** Any tricky areas or things that previous attempts struggled with?
5. **Anything Else:** Any other context that would help the sessions succeed?
Feel free to answer as much or as little as you'd like. You can also say 'none' if the stories are self-explanatory.
```
**After user responds:**
- Think about their response before continuing
- If response raises new questions, ask 1-2 follow-up questions
- Continue until context is sufficient
---
## Agent Configuration (v1.2.0)
```
**AI Agent Selection:**
Which AI coding agent should run your workflows?
| Agent | CLI Command | Prompt Style | Best For |
|-------|-------------|--------------|----------|
| **Claude** | `claude --dangerously-skip-permissions` | Natural language skill prompt | BMAD workflows |
| **Codex** | `codex exec --full-auto` | Natural language skill prompt | OpenAI Codex users |
**Primary Agent:** (default: auto, resolves from active runtime provider)
**Fallback Agent:** (default: false, disabled unless configured)
**Enable Fallback:** (default: no)
Examples:
- `auto` → Active runtime provider, no fallback
- `claude` → Claude primary, no fallback
- `codex` → Codex primary, Claude fallback
- `claude, none` → Claude only, no fallback
- `codex, claude` → Codex primary, Claude fallback
Enter agent config or press Enter for defaults:
```
Store response as `agentConfig` (v3.0.0):
```yaml
agentConfig:
defaultPrimary: "auto"
defaultFallback: false
perTask: {}
complexityOverrides: {}
```
---
## Legacy AI Command Configuration (Deprecated)
```
**AI Command:**
What command invokes Claude Code (or your AI CLI) in the terminal?
Examples:
- `claude --dangerously-skip-permissions` (default - autonomous mode, no prompts)
- `claude` (interactive mode - will prompt for permissions)
- `cursor` (Cursor IDE)
- `/usr/local/bin/claude --dangerously-skip-permissions` (full path)
Enter command or press Enter for default (`claude --dangerously-skip-permissions`):
```
Store response as `aiCommand`. **Note:** This is deprecated in v1.2.0. Use `agentConfig` instead.
---
## Execution Overrides
```
**Execution Overrides:**
By default, the orchestrator will:
- Run all steps: create-story → dev-story → automate → code-review
- Run stories sequentially (one at a time)
- Commit after each completed story
**Would you like to change any defaults?**
| Option | Default | Your Choice |
|--------|---------|-------------|
| Skip `automate` (guardrail tests) | No | ? |
| Max parallel stories | 1 | ? |
Enter changes (e.g., `skip automate, max parallel 2`) or `defaults` to keep all defaults:
```
---
## Configuration Review Template
```
**Pre-flight Complete. Here's your configuration:**
**Project Context Loaded:**
- Product Brief: {loaded/not found}
- PRD: {loaded/not found}
- Architecture: {loaded/not found}
- Other docs: {list or 'None'}
**Epic:** {epic_name}
**Stories:** {story_range} ({count} stories)
**Stories to implement:**
{story_list_with_titles}
**AI Command:** `{aiCommand}`
**Overrides:**
- Skip automate: {yes/no}
- Max parallel: {number}
**Additional Context from Conversation:**
{context_summary_or_'None provided'}
**Does this look correct?** I'll create the state document and we can begin execution.
```

View File

@@ -0,0 +1,74 @@
# Preflight Requirements (v1.10.0)
> **🚨 CRITICAL:** Load and internalize these requirements BEFORE executing any preflight steps.
---
## MANDATORY Sequence (NO EXCEPTIONS)
Steps 1-3 MUST be completed IN ORDER using the Python helper BEFORE proceeding to steps 4-7:
1. **Step 1-2:** Request and parse epic(s) → `scripts/story-automator parse-epic`
2. **Step 3:** Parse ALL stories with complexity scoring → `scripts/story-automator parse-story --rules`
3. **GATE:** Verify `stories_json` is populated with programmatic complexity data
4. **Step 4:** Display Complexity Matrix (from step 3 data)
5. **Steps 5-7:** Custom instructions, agent config, execution settings
---
## 🛑 FORBIDDEN PATTERNS
-**NEVER** skip step 3 (complexity scoring)
-**NEVER** manually assess complexity by reading epic/story content
-**NEVER** proceed to agent configuration without displaying the Complexity Matrix
-**NEVER** guess complexity levels - they MUST come from `parse-story --rules`
-**NEVER** create state document without `stories_json` containing complexity data
---
## ✅ REQUIRED Verification
Before step 5 (Configure Agent), you MUST have:
- [ ] `stories_json` variable populated with complexity data from Python helper
- [ ] Complexity Matrix displayed to user showing all stories with levels/scores
- [ ] User has seen the complexity breakdown before being asked about agents
---
## Why This Matters
Without programmatic complexity scoring:
- Agent configuration cannot be informed by actual story difficulty
- User cannot make informed decisions about which agents to use
- The orchestration may fail or produce suboptimal results
The Python helper (`scripts/story-automator parse-story --rules`) applies consistent, deterministic rules from `data/complexity-rules.json` to score each story. This data MUST be gathered before agent configuration.
---
## Complexity Matrix Display Template
After gathering complexity data, you MUST display:
```
**Story Complexity Matrix**
| Story | Title | Score | Level | Reasons |
|-------|-------|-------|-------|---------|
| {storyId} | {title} | {score} | {level} | {reasons or "-"} |
...
**Summary:**
- Low: {count} stories
- Medium: {count} stories
- High: {count} stories
```
---
## Verification Gate (Step 3d)
Before proceeding to step 4 (Custom Instructions), verify:
- `stories_json` contains complexity data for ALL selected stories
- Complexity Matrix has been displayed to user
- If either is missing, DO NOT PROCEED - re-run step 3

View File

@@ -0,0 +1,4 @@
Execute the BMAD {{label}} workflow for story {{story_id}}.
{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}Story file: _bmad-output/implementation-artifacts/{{story_prefix}}-*.md
Auto-apply all discovered gaps in tests.

View File

@@ -0,0 +1,7 @@
Execute the BMAD create-story workflow for story {{story_id}}.
{{skill_line}}{{workflow_line}}{{instructions_line}}{{template_line}}{{checklist_line}}Create story file at: _bmad-output/implementation-artifacts/{{story_prefix}}-*.md
Story ID: {{story_id}}
#YOLO - Do NOT wait for user input.

View File

@@ -0,0 +1,4 @@
Execute the BMAD dev-story workflow for story {{story_id}}.
{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}Story file: _bmad-output/implementation-artifacts/{{story_prefix}}-*.md
Implement all tasks marked [ ]. Run tests. Update checkboxes.

View File

@@ -0,0 +1,33 @@
Execute the BMAD retrospective workflow for epic {{story_id}}.
{{skill_line}}{{workflow_line}}{{instructions_line}}Run the retrospective in #YOLO mode.
Assume the user will NOT provide any input to the retrospective directly.
For ALL prompts that expect user input, make reasonable autonomous decisions based on:
- Sprint status data
- Story files and their dev notes
- Previous retrospective if available
- Architecture and PRD documents
Key behaviors:
- When asked to confirm epic number: auto-confirm based on sprint-status
- When asked for observations: synthesize from story analysis
- When asked for decisions: make data-driven choices
- When presented menus: select the most appropriate option based on context
- Skip all "WAIT for user" instructions - continue autonomously
After the retrospective has run and created documents, you MUST:
1. Create a list of documentation that may need updates based on implementation learnings
2. For each doc in the list, verify whether updates are actually needed by:
- Reading the current doc content
- Comparing against actual implementation code
- Checking for discrepancies between doc and code
3. Update docs that have verified discrepancies
4. Discard proposed updates where code matches docs
Focus on these doc types:
- Architecture decisions that changed during implementation
- API documentation that diverged from specs
- README files with outdated instructions
- Configuration documentation
EVERYTHING SHOULD BE AUTOMATED. THIS IS NOT A SESSION WHERE YOU SHOULD BE EXPECTING USER INPUT.

View File

@@ -0,0 +1,4 @@
Execute the story-automator review workflow for story {{story_id}}.
{{skill_line}}{{workflow_line}}{{instructions_line}}{{checklist_line}}Story file: _bmad-output/implementation-artifacts/{{story_prefix}}-*.md
Review implementation, find issues, fix them automatically. {{extra_instruction}}

View File

@@ -0,0 +1,30 @@
# Validation Report Retention Policy
Purpose: keep workflow repo lean while preserving historical validation evidence.
## Policy
- Keep latest 10 validation reports in `validation-reports/` as `.md`.
- Archive older reports into `validation-reports/archive/` as `.md.gz`.
- Keep `validation-report-*-current.md` files unarchived.
- Never delete archived `.md.gz` files automatically.
## Suggested Maintenance Command
Run from workflow root:
```bash
mkdir -p validation-reports/archive
ls -1t validation-reports/validation-report-*.md \
| rg -v -- '-current\.md$' \
| awk 'NR>10' \
| while read -r f; do
gzip -c "$f" > "validation-reports/archive/$(basename "$f").gz" && rm "$f"
done
```
## Operational Notes
- This policy applies to historical reports only.
- Current run artifacts remain readable markdown.
- Archival is optional during active development, recommended during wrap-up.

View File

@@ -0,0 +1,140 @@
# Retrospective Automation Data
This file provides instructions for running retrospectives in YOLO mode (fully automated, no user input expected).
---
## YOLO Mode Principles
1. **No User Input Expected**: The retrospective must complete autonomously
2. **Data-Driven Decisions**: All decisions based on sprint-status, story files, and artifacts
3. **Safe Failure**: If anything goes wrong, log and skip - never escalate
4. **Configured Agent**: Retrospectives inherit the configured primary agent unless `retro` is explicitly overridden
---
## Agent Constraints
Retrospectives have complex multi-agent "party mode" interactions that require:
- Natural language dialogue synthesis
- Multi-step reasoning across story analysis
- Document generation with rich context
Retrospectives use the configured `agentConfig` retro selection. If no explicit `retro` override is present, they inherit the configured primary agent.
### Timeout Configuration
Retrospectives analyze all stories in an epic and generate comprehensive reports:
- **Base timeout**: 60 minutes (3600000ms)
- **Extended timeout for large epics (>10 stories)**: 90 minutes (5400000ms)
---
## YOLO Mode Prompt Template
When spawning a retrospective in YOLO mode, use this prompt:
```
Execute the BMAD retrospective workflow for epic {epic_number}.
READ this skill first: <installed-skill-root>/bmad-retrospective/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-retrospective/workflow.md
Run the retrospective in #YOLO mode.
Assume the user will NOT provide any input to the retrospective directly.
For ALL prompts that expect user input, make reasonable autonomous decisions based on:
- Sprint status data
- Story files and their dev notes
- Previous retrospective if available
- Architecture and PRD documents
Key behaviors:
- When asked to confirm epic number: auto-confirm based on sprint-status
- When asked for observations: synthesize from story analysis
- When asked for decisions: make data-driven choices
- When presented menus: select the most appropriate option based on context
- Skip all "WAIT for user" instructions - continue autonomously
After the retrospective has run and created documents, you MUST:
1. Create a list of documentation that may need updates based on implementation learnings
2. For each doc in the list, verify whether updates are actually needed by:
- Reading the current doc content
- Comparing against actual implementation code
- Checking for discrepancies between doc and code
3. Update docs that have verified discrepancies
4. Discard proposed updates where code matches docs
Focus on these doc types:
- Architecture decisions that changed during implementation
- API documentation that diverged from specs
- README files with outdated instructions
- Configuration documentation
EVERYTHING SHOULD BE AUTOMATED. THIS IS NOT A SESSION WHERE YOU SHOULD BE EXPECTING USER INPUT.
```
---
## Multi-Epic Support
When multiple epics are provided to story-automator:
### Tracking Multiple Epics
State document should track:
```yaml
epics:
- epicNumber: 1
storyRange: ["1-1", "1-2", "1-3"]
status: "completed"
retrospectiveStatus: "completed"
- epicNumber: 2
storyRange: ["2-1", "2-2"]
status: "in_progress"
retrospectiveStatus: "pending"
```
### Aggregation Rules
1. **Complete epics during run**: If epic N completes while stories from epic N+1 are being processed, trigger retrospective for epic N
2. **Batch retrospectives**: After all stories complete, run retrospectives for all completed epics in order
3. **Independent failures**: If retrospective for epic N fails, continue to epic N+1 retrospective
### Safe Skip on Failure
If a retrospective fails:
1. Log: `⚠️ Retrospective for Epic {N} skipped: {reason}`
2. Update state: `retrospectives.epic-{N}.status = "skipped"`
3. Update state: `retrospectives.epic-{N}.reason = "{reason}"`
4. Continue to next epic - **NEVER ESCALATE**
---
## Documentation Verification
See `retrospective-doc-verification.md` for doc verification patterns and output parsing.
## Error Handling
### Network Errors
If retrospective session fails due to network:
1. Wait 60 seconds
2. Retry once
3. If retry fails, mark as skipped
### Session Crashes
If retrospective session crashes:
1. Check output file for partial progress
2. If retro doc was partially created, mark as partial
3. Log crash reason
4. Skip to next epic
### Timeout
If retrospective exceeds timeout:
1. Check if core analysis completed
2. If retro doc exists, mark as partial success
3. Skip doc verification phase
4. Continue to next epic

View File

@@ -0,0 +1,94 @@
# Retrospective Doc Verification
Companion to `retrospective-automation.md`. Contains doc verification patterns and output parsing guidance.
## Doc Verification Patterns
After retrospective generates documents, verify updates against code:
### Documents to Check
| Doc Type | Pattern | Verification Method |
|----------|---------|---------------------|
| Architecture | `*architecture*.md` | Compare decisions against implementation |
| API Docs | `*api*.md`, `*openapi*.yaml` | Verify endpoints match code |
| README | `README.md` | Check setup/usage instructions |
| Config Docs | `*config*.md` | Verify env vars and settings |
### Verification Prompt Template
```
Verify whether this documentation update is needed:
**Document:** {doc_path}
**Proposed Change:** {change_summary}
**Reason:** {reason}
Instructions:
1. Read the current document at {doc_path}
2. Read the relevant implementation code referenced
3. Compare doc against actual implementation
4. Determine if update is genuinely needed
Output JSON:
{
"should_update": true|false,
"confidence": "high"|"medium"|"low",
"reason": "explanation",
"discrepancies": ["list", "of", "specific", "issues"]
}
If discrepancies exist, apply the fix directly.
```
### Confidence Thresholds
- **High confidence**: Auto-apply update
- **Medium confidence**: Auto-apply with log note
- **Low confidence**: Skip update, log for manual review
---
## Output Parsing
### Parse Doc Proposals from Retrospective Output
Look for sections in retrospective output:
```
## Documentation Updates Needed
### {doc_path}
- **Change:** {summary}
- **Reason:** {reason}
- **Impact:** {impact}
```
Extract into structured format:
```json
{
"proposals": [
{
"path": "{doc_path}",
"summary": "{summary}",
"reason": "{reason}",
"impact": "{impact}"
}
]
}
```
### Retrospective Completion Markers
Successful completion indicators:
- "Retrospective Complete" in output
- "epic-{N}-retro-*.md" file created
- Sprint status updated with retrospective done
Failure indicators:
- Session timeout
- Error messages in output
- No retro file created after 30+ minutes
---

View File

@@ -0,0 +1,86 @@
# Retrospective Prompts
Prompts used by step-05-retrospective for automated retrospective execution.
---
## YOLO Mode Retrospective Prompt
Use this prompt when spawning the retrospective session:
```
Execute the BMAD retrospective workflow for epic {epic_number}.
READ this skill first: <installed-skill-root>/bmad-retrospective/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-retrospective/workflow.md
Run the retrospective in #YOLO mode.
Assume the user will NOT provide any input to the retrospective directly.
For ALL prompts that expect user input, make reasonable autonomous decisions based on:
- Sprint status data
- Story files and their dev notes
- Previous retrospective if available
- Architecture and PRD documents
Key behaviors:
- When asked to confirm epic number: auto-confirm based on sprint-status
- When asked for observations: synthesize from story analysis
- When asked for decisions: make data-driven choices
- When presented menus: select the most appropriate option based on context
- Skip all "WAIT for user" instructions - continue autonomously
After the retrospective has run and created documents, you MUST:
1. Create a list of documentation that may need updates based on implementation learnings
2. For each doc in the list, verify whether updates are actually needed by:
- Reading the current doc content
- Comparing against actual implementation code
- Checking for discrepancies between doc and code
3. Update docs that have verified discrepancies
4. Discard proposed updates where code matches docs
Focus on these doc types:
- Architecture decisions that changed during implementation
- API documentation that diverged from specs
- README files with outdated instructions
- Configuration documentation
EVERYTHING SHOULD BE AUTOMATED. THIS IS NOT A SESSION WHERE YOU SHOULD BE EXPECTING USER INPUT.
```
---
## Doc Verification Prompt
Use this prompt when spawning doc verification subagents:
```
Verify whether this documentation update is needed:
**Document:** ${proposed_doc.path}
**Proposed Change:** ${proposed_doc.summary}
**Reason:** ${proposed_doc.reason}
Instructions:
1. Read the current document at ${proposed_doc.path}
2. Read the relevant implementation code referenced
3. Compare doc against actual implementation
4. Determine if update is genuinely needed
Output JSON:
{
"should_update": true|false,
"confidence": "high"|"medium"|"low",
"reason": "explanation",
"discrepancies": ["list", "of", "specific", "issues"] // only if should_update
}
If discrepancies exist, apply the fix directly. Output should_update=true only if you made changes.
```
---
## Usage Notes
- **YOLO Prompt:** Replace `{epic_number}` with actual epic number
- **Doc Verification Prompt:** Replace `${proposed_doc.*}` variables with actual values
- Both prompts are designed for fully automated execution (no user input expected)

View File

@@ -0,0 +1,100 @@
# Retry & Fallback Implementation Examples
**Purpose:** Detailed implementation wrapper and step-specific validation patterns.
---
## Implementation Pattern
```bash
# Universal retry wrapper with deterministic agent resolution
task_type="{step}" # create, dev, auto, or review
resolve_agent_for_task "$task_type" "$state_file" "{story_id}"
# Now primary_agent and fallback_agent are set for this story/task
max_attempts=5
attempt=0
success=false
while [ $attempt -lt $max_attempts ] && [ "$success" = "false" ]; do
attempt=$((attempt + 1))
# Alternate agent: odd attempts = primary, even = fallback (if available)
if [ $((attempt % 2)) -eq 1 ] || [ -z "$fallback_agent" ]; then
current_agent="$primary_agent"
else
current_agent="$fallback_agent"
fi
# Delay logic (after first attempt)
if [ $attempt -gt 1 ]; then
if [ $attempt -ge 4 ] || [ "$last_was_network_error" = "true" ]; then
echo "Waiting 60s before retry (attempt $attempt)..."
sleep 60
fi
fi
# Execute workflow step
session=$("$scripts" tmux-wrapper spawn {step} {epic} {story_id} \
--agent "$current_agent" \
--command "$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$current_agent" --state-file "$state_file")")
result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
# Cleanup session
"$scripts" tmux-wrapper kill "$session"
# Check for network errors
last_was_network_error="false"
if echo "$result" | grep -qiE "(connection refused|timeout|rate limit|503|502|never_active)"; then
last_was_network_error="true"
fi
if [ "$(echo "$result" | jq -r '.final_state')" = "crashed" ]; then
output_size=$(wc -c < "$(echo "$result" | jq -r '.output_file')" 2>/dev/null || echo "0")
[ "$output_size" -lt 100 ] && last_was_network_error="true"
fi
# Check success (step-specific validation)
# ... validation logic here ...
if [ "$validation_passed" = "true" ]; then
success=true
else
echo "Attempt $attempt failed (agent: $current_agent). $([ $attempt -lt $max_attempts ] && echo "Retrying..." || echo "Escalating.")"
fi
done
if [ "$success" = "false" ]; then
# All attempts exhausted - NOW escalate
escalate_to_user "Step failed after $max_attempts attempts"
fi
```
---
## Step-Specific Validation
### Create Story
```bash
validation=$("$scripts" orchestrator-helper verify-step create {story_id} --state-file "$state_file")
validation_passed=$(echo "$validation" | jq -r '.verified')
```
### Dev Story
```bash
parsed=$("$scripts" orchestrator-helper parse-output "$output_file" dev)
next_action=$(echo "$parsed" | jq -r '.next_action')
validation_passed=$([ "$next_action" = "proceed" ] && echo "true" || echo "false")
```
### Automate
```bash
parsed=$("$scripts" orchestrator-helper parse-output "$output_file" auto)
# Non-blocking: log warning but continue
validation_passed="true" # Always proceed (automate is non-blocking)
```
### Code Review
```bash
# See code-review-loop.md for specific review cycle handling
# Reviews have their own internal retry loop
```

View File

@@ -0,0 +1,131 @@
# Retry & Fallback Strategy
**Purpose:** Universal retry and fallback agent pattern for all workflow steps (create, dev, auto, review).
**Version:** 2.0.0
---
## Core Principle
**NEVER escalate to user on first failure.** Exhaust all retry options first:
1. Try fallback agent (if configured for this task)
2. Retry with alternating agents up to 5 total attempts
3. Sleep between retries if network issues detected
4. Only escalate after all attempts exhausted
---
## Agent Configuration (v3.0.0)
**Deterministic agent resolution via agents file:**
```bash
# Resolve agent for a specific task (create, dev, auto, review)
# Uses agents file generated during preflight (complexity-aware)
resolve_agent_for_task() {
local task="$1"
local state_file="$2"
local story_id="$3"
result=$("$scripts" orchestrator-helper agents-resolve \
--state-file "$state_file" \
--story "$story_id" \
--task "$task")
primary_agent=$(echo "$result" | jq -r '.primary')
fallback_agent=$(echo "$result" | jq -r '.fallback')
# Handle "false"/null meaning disabled
[ "$fallback_agent" = "false" ] && fallback_agent=""
}
# Usage:
resolve_agent_for_task "review" "$state_file" "{story_id}"
echo "Review task: primary=$primary_agent, fallback=$fallback_agent"
```
**Fallback behavior:**
- If `fallback_agent` is empty, "false", or same as primary → retry with primary only
- If `fallback_agent` differs → alternate between agents on retries
- Complexity overrides win per task, then per-task overrides, then defaults
---
## Retry Sequence (5 Attempts Max)
| Attempt | Agent | Delay Before | Notes |
|---------|-------|--------------|-------|
| 1 | primary | none | Initial attempt |
| 2 | fallback | 0-60s | Switch agent; delay if network error |
| 3 | primary | 0-60s | Back to primary |
| 4 | fallback | 60s | Always delay by attempt 4 |
| 5 | primary | 60s | Final attempt |
**If no fallback configured:** All 5 attempts use primary agent.
---
## Network Error Detection
**Indicators of network/transient issues:**
- Session output contains: "connection refused", "timeout", "rate limit", "503", "502"
- Session crashed with zero output
- `story-automator monitor-session` returns `final_state: "crashed"` with empty output
- Session stuck at "never_active" state (no response from API)
**On network error detection:**
- Sleep 60 seconds before next attempt
- Log: "Network issue detected, waiting 60s before retry..."
---
## Implementation & Validation Examples
Detailed bash patterns and step-specific validation examples are moved to:
- **`retry-fallback-implementation.md`** (implementation wrapper + per-step validation)
---
## Escalation (After All Attempts)
Only after exhausting all 5 attempts:
1. Update state: `status = "AWAITING_DECISION"`
2. Log all attempt details:
```
[timestamp] ESCALATION: {step} failed after 5 attempts
- Attempt 1 (primary): {result}
- Attempt 2 (fallback): {result}
- Attempt 3 (primary): {result}
- Attempt 4 (fallback): {result}
- Attempt 5 (primary): {result}
```
3. Present options to user:
- Retry with different settings
- Skip this story
- Abort orchestration
---
## Integration with Adaptive Retry
This strategy **replaces** the simple retry logic. The adaptive-retry.md plateau detection still applies within this framework:
- If same task plateau detected across 3+ attempts → DEFER instead of escalate
- Plateau detection runs AFTER agent switching (so both agents hit same wall)
---
## Logging
All retry attempts should be logged in the action log:
```
[timestamp] {step} attempt {N}/{max} with {agent}: {result}
```
On success after retry:
```
[timestamp] {step} succeeded on attempt {N} with {agent} (after {N-1} failures)
```

View File

@@ -0,0 +1,102 @@
# Command Reference
All operations use the installed helper at `scripts/story-automator` (usually via the `$scripts` variable). **DO NOT construct tmux commands manually.**
## Core Commands
| Script | Purpose |
|--------|---------|
| `$scripts tmux-wrapper` | Session spawning, naming, lifecycle |
| `$scripts monitor-session` | Batched polling (14+ API calls → 1) |
| `$scripts tmux-status-check` | Context-efficient status checking (v2.4.0) |
| `$scripts codex-status-check` | Codex-specific status with heartbeat (v2.4.0) |
| `$scripts heartbeat-check` | CPU-based process heartbeat detection |
| `$scripts orchestrator-helper` | Sprint-status, parsing, markers |
| `$scripts orchestrator-helper verify-step` | Shared success verifier checks per step |
| `$scripts orchestrator-helper agents-build` | Deterministic agents file generation |
| `$scripts orchestrator-helper agents-resolve` | Agent lookup per story/task via state file or direct agents file |
| `$scripts validate-story-creation` | Legacy story file count validation |
| `$scripts commit-story` | Deterministic git commit with JSON output |
## Usage Pattern
> **⚠️ CRITICAL: `--command` IS REQUIRED**
> You MUST pass `--command` with the built command string to `spawn`.
> Without `--command`, the tmux session will be created but NO command runs → `never_active` failure.
```bash
scripts="{scriptsDir}"
# ⚠️ --command is REQUIRED - without it, session sits idle!
# Spawn session
session=$("$scripts" tmux-wrapper spawn {type} {epic} {story_id} \
--agent "$agent" \
--command "$("$scripts" tmux-wrapper build-cmd {type} {story_id} --agent "$agent")")
# Monitor session
result=$("$scripts" monitor-session "$session" --json --agent "$agent")
# Parse output
parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" {type})
# Cleanup
"$scripts" tmux-wrapper kill "$session"
```
## Deterministic Agent Selection
Agent selection is driven by the agents file created during preflight:
`_bmad-output/story-automator/agents/agents-{state_filename}.md`
To resolve agents for a specific story/task:
```bash
selection=$("$scripts" orchestrator-helper agents-resolve --state-file "$state_file" --story "{story_id}" --task "{task}")
primary=$(echo "$selection" | jq -r '.primary')
fallback=$(echo "$selection" | jq -r '.fallback')
```
Direct agents-file resolution is also supported when you already know the generated agents plan path:
```bash
selection=$("$scripts" orchestrator-helper agents-resolve --agents-file "$agents_file" --story "{story_id}" --task "{task}")
primary=$(echo "$selection" | jq -r '.primary')
fallback=$(echo "$selection" | jq -r '.fallback')
```
## Step Types
| Type | Description | Agent Support |
|------|-------------|---------------|
| `create` | Create story from epic | Claude, Codex |
| `dev` | Implement story tasks | Claude, Codex |
| `auto` | Test automation | Claude, Codex |
| `review` | Code review with auto-fix | Claude, Codex |
| `retro` | Retrospective (YOLO mode) | Claude, Codex |
## Retrospective Commands (v1.5.0)
**CRITICAL:** Retrospectives use a special step type that:
- Resolves the retro agent from `agentConfig`
- Returns full YOLO mode prompt with doc verification instructions
- Uses epic_number instead of story_id
```bash
# For retro, "story_id" parameter is actually the epic_number
retro_agent=$("$scripts" orchestrator-helper retro-agent --state-file "{state_file}" | jq -r '.primary')
cmd=$("$scripts" tmux-wrapper build-cmd retro {epic_number} --agent "$retro_agent")
session=$("$scripts" tmux-wrapper spawn retro "" {epic_number} --agent "$retro_agent" --command "$cmd")
# Monitor (retrospectives never block, failures just logged)
result=$("$scripts" monitor-session "$session" --json --agent "$retro_agent")
"$scripts" tmux-wrapper kill "$session"
```
The `build-cmd retro` command automatically includes:
- The bmad-retrospective skill invocation prompt
- Full YOLO mode instructions (no user input expected)
- Key autonomous behaviors for menus/prompts
- Doc verification instructions with subagent patterns
- Instructions to update docs that have verified discrepancies
## Binary Location
The installed helper lives at `../scripts/story-automator` relative to step files.

View File

@@ -0,0 +1,187 @@
# Stop Hook Configuration
This document defines the Stop hook required for the story-automator to prevent premature stopping during orchestration in Claude or Codex.
**Related:** See `stop-hook-troubleshooting.md` for child session handling, manual override, and troubleshooting.
---
## Overview
The Stop hook uses a **marker file approach**:
1. When story-automator starts → Creates marker file with orchestration context
2. When the active agent tries to stop → Hook script checks marker file
3. If no marker or completed → Allow stop (normal agent usage)
4. If marker exists with pending stories → Block stop with continuation guidance
5. When story-automator completes → Removes marker file
**Important (v2 fix):** The hook intentionally does NOT check the `stop_hook_active` flag. This flag stays `true` for the entire session after one blocked stop, which caused premature exits in long orchestrations. The marker file alone is the source of truth.
---
## Multi-Project Support (v2.0)
**CRITICAL:** The marker file is now PROJECT-SCOPED to support running story-automator on multiple projects simultaneously.
**Old location (DEPRECATED):** `/tmp/.story-automator-active`
**New location:** runtime-specific project marker resolved by `orchestrator-helper marker path`
### Why Project-Scoped?
When running story-automator on multiple projects at the same time:
- Old: All projects shared `/tmp/.story-automator-active` → Cross-project interference
- New: Each project has its own marker in the active runtime layout. The marker follows the active installed skill root parent, for example `.claude/`, `.agents/`, or `.codex/`.
### How It Works
1. The installed hook command exports `PROJECT_ROOT` for the target project before invoking `story-automator stop-hook`
2. The stop hook resolves the marker from `PROJECT_ROOT`, not from the caller's ambient working directory
3. Project A's stop hook only sees Project A's marker
4. Project B's stop hook only sees Project B's marker
Do not hard-code the marker path. Use `orchestrator-helper marker path`; this keeps Claude, `.agents`-based Codex, and `.codex`-based Codex installs consistent with the active skill root.
### State Files Also Scoped
The status check script state files are also project-scoped:
- **Old:** `/tmp/.tmux-session-{SESSION}-state.json`
- **New:** `/tmp/.sa-{project_hash}-session-{SESSION}-state.json`
Where `project_hash` = first 8 chars of MD5 hash of project root path.
---
## Hook Configuration
### Runtime Selection
The helper selects hook configuration syntax from the active provider:
- `BMAD_RUNTIME_PROVIDER`
- `STORY_AUTOMATOR_RUNTIME_PROVIDER`
Set one of these to `claude` or `codex` to force the provider. If none is set, the helper infers the provider from the installed skill root.
`AI_AGENT` only selects child-agent runtime for spawned work. It does not decide which top-level hook files are written.
The provider decides which hook files are written. Marker location is resolved separately and follows the active installed story-automator skill root when possible. For example, a Codex run using a migrated `.claude/skills/bmad-story-automator` install still uses the `.claude/.story-automator-active` marker so the hook and orchestrator read the same file.
For Claude, add this to the target project's `.claude/settings.json`:
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/absolute/path/to/scripts/story-automator stop-hook",
"timeout": 10
}
]
}
]
}
}
```
For Codex, enable hooks in the target project's `.codex/config.toml`:
```toml
[features]
codex_hooks = true
```
Then add this to `.codex/hooks.json`:
```json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/absolute/path/to/scripts/story-automator stop-hook",
"timeout": 10,
"statusMessage": "Checking story automator state"
}
]
}
]
}
}
```
Codex trust is separate from hook configuration. A project can have the Story Automator hook written to disk and still require trust approval before Codex will run it. `ensure-stop-hook` now reports that state as pending trust instead of verified.
### Binary Path is Always Absolute
**The stop hook binary resolves itself to an absolute path.** Regardless of how the caller passes the `--command` argument (relative, project-relative, or absolute), the helper stores a consistent absolute path in `.claude/settings.json` or `.codex/hooks.json`.
This prevents the inconsistency where the AI agent resolves frontmatter paths differently across sessions, which previously caused repeated hook installations and unnecessary restart loops.
**Migration:** If an existing hook config contains a relative or project-relative path, `ensure-stop-hook` will normalize it to absolute in-place without triggering a restart (`reason: "hook_normalized"`).
**When hook fails with "no such file or directory":**
- Verify BMAD is installed in the target project
- Check the binary exists in the active runtime skills tree, for example: `test -x <installed-skill-root>/bmad-story-automator/scripts/story-automator`
- Ensure binary is executable: `chmod +x <installed-skill-root>/bmad-story-automator/scripts/story-automator`
---
## Marker File Format
**Location (v2.0):** resolved by `orchestrator-helper marker path`
*Note: The orchestrator adds the active marker entry returned by `orchestrator-helper marker path` to `.gitignore`. Common entries are `.claude/.story-automator-active`, `.agents/.story-automator-active`, and `.codex/.story-automator-active`.*
Content (JSON - v1.2.0 with heartbeat):
```json
{
"epic": "epic-01",
"currentStory": "story-01",
"storiesRemaining": 3,
"stateFile": "/path/to/orchestration-epic01.md",
"startedAt": "2026-01-13T10:00:00Z",
"heartbeat": "2026-01-13T10:30:00Z",
"pid": 12345
}
```
### Fields (v1.2.0):
- `heartbeat`: Last activity timestamp, updated periodically during execution
- `pid`: Process ID of the orchestrator (helps detect crashed sessions)
### Staleness Check
The stop hook checks if marker heartbeat is older than 30 minutes (stale = orchestrator crashed). If stale, allow stop. See `story-automator stop-hook` for implementation.
---
## Verification Logic
The orchestrator verifies hook installation at startup:
```
1. Resolve active runtime provider
2. For Claude, check `.claude/settings.json`; for Codex, check `.codex/hooks.json` and `.codex/config.toml`
3. Parse hook JSON and look for hooks.Stop array
4. Check if any hook command contains "story-automator stop-hook"
IF found → Continue
IF not found → Add hook, instruct restart
```
---
## Hook Behavior
| Scenario | Action |
|----------|--------|
| `STORY_AUTOMATOR_CHILD=true` | `exit 0` → Always allow (child session) |
| No marker file | `exit 0` → Allow stop |
| Marker exists, `storiesRemaining=0` | `exit 0` → Allow stop |
| Marker exists, `storiesRemaining > 0` | Output JSON → Block stop with reason |
**Key fix (Session 10):** The hook no longer checks `stop_hook_active`. This flag was causing premature exits in long orchestrations because it stays `true` for the entire session after the first blocked stop.

View File

@@ -0,0 +1,87 @@
# Stop Hook Recovery
**You are reading this because the Stop hook blocked your attempt to stop.**
---
## Decision Matrix
**Find your scenario and follow the action exactly:**
| Scenario | Action | Why |
|----------|--------|-----|
| Just finished a story, more in queue | **CONTINUE** → Process next story | Stopping between stories wastes progress |
| Session completed successfully | **CONTINUE** → Check state, process output | Session done ≠ orchestration done |
| Session failed/errored | **CONTINUE** → Follow retry/fallback strategy | Retries not exhausted yet |
| Feeling uncertain what to do | **CONTINUE** → Check state document, follow step instructions | Uncertainty is not a blocker |
| Want to "check in" with user | **CONTINUE** → User will interrupt if needed | User doesn't want status updates |
| Waiting at a menu for user input | **DECIDE** → Use sub-agent to gather context, make informed decision | Don't block on user, keep moving |
| Multiple valid paths forward | **DECIDE** → Pick the path most likely to finish stories in order | Your job is to finish, not ask |
| Unrecoverable error (all retries exhausted) | **STOP** → Follow stop procedure below | Cannot proceed without intervention |
| External dependency down (API, service) | **RETRY** → Sleep with increasing delay (1m, 2m, 4m, 8m, 16m), max 5 attempts | Often recovers on its own |
| User explicitly requested stop earlier | **STOP** → Follow stop procedure below | Honoring user intent |
---
## Quick Reference
### CONTINUE (Most Scenarios)
1. Read your state document
2. Check `currentStep`, `storyQueue`, `currentStoryId`
3. Resume execution from where you left off
4. Process the next story in queue
### DECIDE (Autonomous Decision Required)
1. Spawn sub-agent to analyze current context
2. Gather: state document, recent session output, story requirements
3. Make the decision most likely to complete stories in order
4. Document decision in state log, then execute
5. **Never wait for user** - your job is to keep moving
### RETRY (External Dependency Issues)
1. Log the failure with timestamp
2. Sleep with exponential backoff: 1m → 2m → 4m → 8m → 16m
3. Retry the operation after each sleep
4. After 5 failed attempts → treat as unrecoverable → STOP
### STOP (Genuinely Unrecoverable)
1. Update state document status to `STOPPED`
2. Delete marker using the installed helper: `orchestrator-helper marker remove`
3. Log WHY you stopped (be specific)
4. Exit cleanly
---
## Stop Hook Messages ≠ User Input
The stop hook fires when Claude pauses, not just when explicitly stopping.
**If you see this while at a decision point:**
- This message is NOT telling you what to choose
- Use a sub-agent to gather context and DECIDE autonomously
- Do NOT wait for user - make the call yourself
---
## Core Principle
**Your job is to finish every story in the correct order.**
Do whatever it takes. Make autonomous decisions. Only stop when genuinely unrecoverable (all retries exhausted, user explicitly requested stop).
---
## Common Mistakes to Avoid
| Mistake | Correct Behavior |
|---------|------------------|
| Stopping to report progress | Continue silently, user sees state doc |
| Stopping after one story completes | Continue to next story |
| Stopping because session errored | Follow retry strategy first |
| Waiting for user at decision points | Decide autonomously, keep moving |
| Stopping on first API/service failure | Retry with exponential backoff (5 attempts) |
| Asking user which path to take | Pick the path that finishes stories in order |

View File

@@ -0,0 +1,107 @@
# Stop Hook Troubleshooting
**Related:** See `stop-hook-config.md` for core configuration.
---
## Child Session Handling (Session 19 Fix)
**CRITICAL:** The stop hook is installed at the PROJECT level. When the orchestrator spawns T-Mux sessions (create-story, dev-story, code-review), those child agent instances:
1. Run in the same project directory
2. Read the same project-level hook configuration
3. Have the same stop hook configured
4. See the same marker file
**Problem:** Without distinction, the stop hook blocks child sessions from completing, creating infinite loops.
**Solution:** All T-Mux child sessions MUST be spawned with:
```bash
tmux new-session -d -s "SESSION_NAME" -e STORY_AUTOMATOR_CHILD=true
```
The `-e STORY_AUTOMATOR_CHILD=true` flag exports the environment variable to the session. The stop hook checks this FIRST and immediately allows stop if set.
**Who gets blocked vs allowed:**
| Session Type | STORY_AUTOMATOR_CHILD | Stop Hook Behavior |
|--------------|----------------------|-------------------|
| Orchestrator | not set | BLOCKED (if marker + stories remaining) |
| create-story | `true` | ALLOWED (always) |
| dev-story | `true` | ALLOWED (always) |
| code-review | `true` | ALLOWED (always) |
| testarch-automate | `true` | ALLOWED (always) |
| Internal scripts (e.g., haiku calls) | `true` | ALLOWED (always) |
---
## Internal Nested Agent Calls (Session 20 Fix)
### Claude
**CRITICAL:** Scripts that internally call `claude` (like `story-automator tmux-status-check` using Haiku for wait estimation) while an orchestration marker is active MUST prefix the call with the environment variable.
```bash
# WRONG - will hang when stop hook blocks the claude exit
RESULT=$(claude -p --model haiku "..." 2>/dev/null)
# CORRECT - allows claude to exit normally
RESULT=$(STORY_AUTOMATOR_CHILD=true claude -p --model haiku "..." 2>/dev/null)
```
**Why:** Even non-interactive `claude -p` calls trigger the stop hook when they exit. Without the env var, the hook sees the marker file and blocks, causing the script to hang indefinitely.
### Codex
For Codex, apply the same `STORY_AUTOMATOR_CHILD=true` convention to any future internal non-interactive Codex calls that run inside an active story-automator project.
---
## Stop Hook Messages Are NOT User Input
**When you present a menu and wait for user input, the stop hook may fire with messages like:**
> "Story Automator is running with N stories remaining. Continue processing..."
**THIS IS NOT USER INPUT.** Do not interpret stop hook feedback as a menu selection.
- NEVER treat "continue processing" as selecting [R]esume
- NEVER proceed past a menu because the stop hook fired
- ALWAYS wait for ACTUAL user input (typed response)
- Stop hook messages are about STOPPING behavior only
**Why this happens:** The stop hook fires when the agent pauses, not just when explicitly stopping. During menu waits, it may fire repeatedly. Ignore these messages when waiting for user input.
---
## Manual Override
If the orchestrator gets stuck, users can:
1. Remove the marker file from the project root using the installed story-automator helper: `orchestrator-helper marker remove`
2. Stop the active agent normally
3. Resume later with the continue flow
**For multi-project cleanup:**
```bash
# Remove marker for current project only
helper="<installed-skill-root>/bmad-story-automator/scripts/story-automator"
[ -x "$helper" ] || { echo "story-automator helper not found: $helper" >&2; exit 1; }
"$helper" orchestrator-helper marker remove
# Clean up project-scoped state files (optional)
PROJECT_HASH=$(echo -n "$PWD" | md5sum | cut -c1-8)
rm -f /tmp/.sa-${PROJECT_HASH}-session-*
rm -f /tmp/sa-${PROJECT_HASH}-output-*
```
---
## Troubleshooting
| Issue | Check |
|-------|-------|
| Hook not running | Valid hook config? For Codex, is `[features].codex_hooks = true` set and is the project trusted? Script executable? Session restarted? |
| "no such file" | BMAD installed? Path correct in the active runtime skills tree? Check each installed root, for example `.claude/skills`, `.agents/skills`, or `.codex/skills`. |
| Premature stops | Marker exists? `storiesRemaining > 0`? v2 fix applied? |
| Child sessions blocked | `STORY_AUTOMATOR_CHILD=true` set? Check spawn command. |
| Script hangs | Internal agent calls missing env var? See Session 20 Fix. |
| Hook fires during menus | Normal behavior - ignore messages, wait for real input. |

View File

@@ -0,0 +1,87 @@
# Sub-Agent Analysis Prompts
**Purpose:** Analysis-focused prompt templates for sub-agents spawned during story-automator execution.
**Related:** See `subagent-prompts.md` for core execution prompts (parser, reader, updater).
---
## Code Review Analyzer
**Use:** Analyze code review output to determine review status and next steps.
**Prompt:**
```
You are a code review analyzer. Analyze the code review session output.
Story: {story_name}
Review cycle: {cycle_number} of 3
Review output:
---
{review_output}
---
Determine the review outcome by looking for:
1. "Story Status: done" or "Story Status: in-progress"
2. "Issues Fixed: N" count
3. "Issues Found: N High, N Medium, N Low"
Return:
{
"storyStatus": "done|in-progress|unknown",
"issuesFixed": N,
"highIssues": N,
"mediumIssues": N,
"lowIssues": N,
"recommendation": "proceed|retry|escalate",
"summary": "brief description of outcome"
}
```
**Decision logic:**
- storyStatus == "done" → proceed (exit review loop)
- storyStatus == "in-progress" → retry (new review cycle needed)
- storyStatus == "unknown" → check sprint-status.yaml directly
**CRITICAL:** The orchestrator MUST verify sprint-status.yaml after review completes. The sub-agent analysis is advisory; sprint-status.yaml is the source of truth.
---
## Dependency Analyzer
**Use:** Analyze stories for parallel execution safety.
**Prompt:**
```
You are a dependency analyzer. Determine if these stories can safely run in parallel.
Stories to analyze:
{stories_list}
For each pair of stories, check for:
- File conflicts (modifying same files)
- Logical dependencies (one builds on another)
- Resource conflicts (same database tables, API endpoints)
- Test conflicts (interfering test data)
Return:
{
"parallelSafe": true|false,
"conflicts": [
{
"story1": "...",
"story2": "...",
"conflictType": "file|logical|resource|test",
"description": "..."
}
],
"recommendation": "parallel|sequential|partial",
"suggestedOrder": ["story order if sequential needed"]
}
```
**Parallel safety indicators:**
- Different feature areas → likely safe
- Same component/module → check files
- Database migrations → sequential only
- Shared test fixtures → check for conflicts

View File

@@ -0,0 +1,153 @@
# Sub-Agent Prompt Templates
**Purpose:** Core prompt templates for sub-agents spawned during story-automator execution.
**Related:** See `subagent-prompts-analysis.md` for analysis prompts (code review, dependency).
---
## Session Output Parser
**Use:** Parse T-Mux session output to determine success/failure status.
**Prompt (v1.2.0 - strengthened):**
```
You are a session output parser. Your job is CRITICAL - incorrect parsing leads to workflow failures.
## MANDATORY STEPS (do these IN ORDER):
1. **READ THE ENTIRE FILE FIRST** - Use the Read tool to load the complete file
2. **COUNT LINES** - Note total line count. If <50 lines, output may be truncated
3. **SCAN FOR KEY MARKERS** - Look for these patterns:
- SUCCESS: "✅", "complete", "done", "Story file created", "Tests passed"
- FAILURE: "❌", "error", "failed", "Exception", "panic"
- TRUNCATED: File ends mid-sentence, no clear conclusion
4. **ANALYZE TASK PROGRESS** - Look for todo markers:
- "☒" = completed task
- "☐" = pending task
- Extract: tasks_completed / tasks_total
5. **DETERMINE STATUS:**
- SUCCESS: Clear completion markers AND file not truncated
- FAILURE: Error markers OR crash indicators
- AMBIGUOUS: Truncated output OR no clear markers (recommend escalate)
Session: {session_id}
Step: {step_name}
Story: {story_name}
Output file: {output_file_path}
## RESPONSE FORMAT (strict JSON):
{
"status": "SUCCESS|FAILURE|AMBIGUOUS",
"summary": "1-2 sentence description",
"tasks_completed": 0,
"tasks_total": 0,
"issues": ["list any errors found"],
"nextAction": "proceed|retry|escalate",
"confidence": "high|medium|low",
"line_count": 0,
"reasoning": "brief explanation of how you determined status"
}
## CRITICAL RULES:
- If output appears truncated (ends abruptly), set status="AMBIGUOUS" and nextAction="escalate"
- NEVER guess status - if unclear, use AMBIGUOUS
- Include line_count to verify you read the whole file
- For dev-story: tasks_completed < tasks_total with idle session = FAILURE (session crashed)
```
**Context for parser:**
- For create-story: Look for "Story file created" or file path in output. Verify file exists.
- For dev-story: Look for "Implementation complete", "Status: review/done", test pass indicators
- For code-review: Look for issue counts by severity (CRITICAL, HIGH, MEDIUM, LOW)
- For automate: Look for test file creation confirmation
**Why strengthened (Session 3):** Sub-agent sometimes returned incomplete analysis because it didn't read the entire file or missed truncation indicators.
---
## Story Reader
**Use:** Read a story file and produce a structured summary for pre-flight context.
**Prompt:**
```
You are a story reader. Analyze the following story file and extract key information for orchestration.
Story file: {story_file_path}
Content:
---
{story_content}
---
Extract and return:
{
"storyId": "...",
"title": "...",
"type": "feature|bugfix|refactor|test|docs",
"complexity": "simple|moderate|complex",
"dependencies": ["list of dependencies or blockers"],
"acceptanceCriteria": ["list of key acceptance criteria"],
"technicalNotes": "any technical implementation hints",
"estimatedSteps": ["create-story", "dev-story", "automate?", "code-review"],
"parallelSafe": true|false,
"parallelReason": "why parallel execution is safe or not"
}
```
---
## State Document Updater
**Use:** Generate state document update entries.
**Prompt:**
```
You are a state document updater. Generate the appropriate update for the orchestration state.
Action type: {action_type}
Story: {story_name}
Step: {step_name}
Result: {result}
Details: {details}
Generate:
1. Action log entry (timestamped)
2. Progress table update (if applicable)
3. Session reference update (if applicable)
Return:
{
"actionLogEntry": "timestamp | story | step | action | result",
"progressUpdate": {
"story": "...",
"column": "...",
"value": "..."
},
"sessionRef": {
"sessionId": "...",
"status": "...",
"completedAt": "..."
}
}
```
---
## Usage Notes
1. **Context Isolation:** Each sub-agent runs in its own context. Pass only necessary information.
2. **Return Format:** Always expect JSON responses for easy parsing.
3. **Error Handling:** If sub-agent response doesn't parse, escalate to user.
4. **Timeout:** Sub-agent calls should complete within 60 seconds by default but should be adaptive based on task and context. If timeout, retry once then escalate.
5. **Logging:** Log all sub-agent calls and responses to action log for debugging.
6. **Analysis Prompts:** For code review and dependency analysis prompts, see `subagent-prompts-analysis.md`.

View File

@@ -0,0 +1,93 @@
# Success Patterns
**Purpose:** Patterns for detecting when each workflow step has completed successfully.
---
## create-story
**Success indicators:**
- Story file created at expected path
- Story file contains required sections (title, acceptance criteria, etc.)
- Session output contains "Story created" or similar confirmation
**Failure indicators:**
- Error messages in session output
- Story file not found after session completes
- Session exits with non-zero code
---
## dev-story
**Success indicators:**
- Code changes committed or staged
- Tests pass (if applicable)
- Session output contains "Implementation complete" or similar
- No unresolved errors in session output
**Failure indicators:**
- Test failures
- Unresolved compilation/lint errors
- Session output contains error messages
- Session times out or crashes
---
## automate (guardrail tests)
**Success indicators:**
- Test files created
- Tests pass when run
- Session output confirms test generation complete
**Failure indicators:**
- Test generation errors
- Generated tests fail immediately
- Session output contains errors
---
## code-review
**Success indicators (clean):**
- "No issues found" or "LGTM" in session output
- Zero blocking issues reported
- Only informational/optional suggestions remain
**Success indicators (issues found):**
- Clear list of issues with file:line references
- Issues categorized by severity
- Actionable fix suggestions provided
**Failure indicators:**
- Unable to complete review
- Session crashes or times out
- Ambiguous output that can't be parsed
---
## git-commit
**Success indicators:**
- Commit created successfully
- Commit message follows convention
- No uncommitted changes remain (for story scope)
**Failure indicators:**
- Git errors (merge conflicts, etc.)
- Commit hook failures
- Unable to stage changes
---
## retrospective
**Success indicators:**
- Retrospective session completes
- Summary document generated
- Learnings captured
**Failure indicators:**
- Session incomplete
- Unable to generate summary

View File

@@ -0,0 +1,204 @@
# T-Mux Commands Reference
**Related:** See `workflow-commands.md` for BMAD workflow invocation commands.
---
## Session Names
**Pattern (v3.0 - MULTI-PROJECT):** `sa-{project_slug}-{YYMMDD}-{HHMMSS}-e{epic}-s{story}-{step}`
**Examples:**
- `sa-myproj-260114-223045-e6-s64-dev` (Project "myproject", Epic 6, Story 6.4, dev step)
- `sa-webapp-260114-223512-e6-s64-review-1` (Project "webapp", review cycle 1)
### Project Slug for Multi-Project Support
**Why project slug (v3.0):**
- **Isolates sessions per project** - List only current project's sessions
- **Prevents cross-project interference** - Won't kill another project's sessions
- **Enables parallel orchestration** - Run story-automator on multiple projects simultaneously
**Generate project slug:**
```bash
# First 8 chars of project directory name (lowercase, alphanumeric only)
project_slug=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c1-8)
```
**Example:** Project at `/home/user/my-awesome-project``project_slug="myawesom"`
**Why timestamps with seconds (v2.1):**
- Prevents collisions when multiple sessions spawn in same minute
- Easier debugging across multiple orchestration runs
- Session names are unique even if re-running same story
- Can identify stale sessions from crashed runs
**Generate full session name:**
```bash
project_slug=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c1-8)
timestamp=$(date +%y%m%d-%H%M%S) # Returns "260114-223045"
session_name="sa-${project_slug}-${timestamp}-e{epic}-s{story_suffix}-{step}"
```
### Listing/Killing Project-Specific Sessions
**List only current project's sessions:**
```bash
project_slug=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c1-8)
tmux list-sessions 2>/dev/null | grep "^sa-${project_slug}-"
```
**Kill only current project's sessions:**
```bash
project_slug=$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]' | cut -c1-8)
tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^sa-${project_slug}-" | xargs -I {} tmux kill-session -t {}
```
### No Dots in Session Names
**T-Mux session names CANNOT contain dots (`.`).** Story IDs like "6.2" must be converted to hyphens.
```bash
# Story ID to session name conversion
# Story ID "6.2" → session suffix "s6-2" (NOT "s6.2")
session_suffix=$(echo "{story_id}" | tr '.' '-')
```
**WRONG:** `sa-epic6-s6.2-review-1` ← Will fail with "can't find pane" error
**RIGHT:** `sa-epic6-s6-2-review-1` ← Works correctly
---
## Status Check Script (PREFERRED)
**ALWAYS use the status check script instead of raw pane capture.**
Script: resolve the installed helper under the active installed skill root. Use `.claude/skills` for Claude, `.agents/skills` for Codex, or `.codex/skills` when that is the installed Codex skill root.
```bash
# ALWAYS use absolute path - relative paths break when directory changes
script="$(printf "%s" "{project_root}/{installed-skill-root}/bmad-story-automator/scripts/story-automator")"
"$script" tmux-status-check "SESSION_NAME"
```
**Returns CSV:** `status,todos_done,todos_total,active_task,wait_estimate,session_state`
```
active,3,7,Running tests,90,in_progress
idle,0,0,,0,just_started
idle,0,0,,0,completed
not_found,0,0,,0,not_found
error,0,0,capture_failed,30,error
```
**CSV Columns:**
1. `status` - "active" | "idle" | "not_found" | "error" | "crashed"
2. `todos_done` - completed todo count (Claude only; Codex returns 0)
3. `todos_total` - total todo count (Claude only; Codex returns 0)
4. `active_task` - current task (truncated, no commas) OR output file path (for --full/crashed)
5. `wait_estimate` - seconds to wait before next check (heuristic-based). For crashed: exit code.
6. `session_state` - **KEY COLUMN** for decision making:
- `just_started` - Session spawned, agent loading
- `in_progress` - Actively working
- `completed` - Was active, now finished cleanly
- `crashed` - Session exited with non-zero status (v2)
- `stuck` - Never became active after multiple polls
- `not_found` / `error` - Problem states
**Agent Detection (v1.3.0):**
The status check script automatically detects Claude vs Codex sessions:
- **Claude:** Looks for `ctrl+c to interrupt`, `☒`/`☐` checkboxes
- **Codex:** Looks for `OpenAI Codex`, `codex exec`, `codex-cli`, `gpt-*-codex`, `tokens used`
- **Codex completion cues:** `tokens used` line, shell prompt return (e.g., ``, `$`, `#`), or clean tmux exit
- Codex sessions get 1.5x longer wait estimates (90s vs 60s default); "succeeded" alone is not treated as active
**Runtime Behavior (v1.13.0):**
- Normal `tmux-wrapper spawn` now uses a runner-based tmux path with explicit session state, not `tmux send-keys`
- Lifecycle truth comes from the session state file first; pane capture is still used for exported `output_file` artifacts
- Sessions keep dead panes with `remain-on-exit on`, so `pane_dead` and `pane_dead_status` remain inspectable after completion
- Temporary migration switch: `SA_TMUX_RUNTIME=legacy|runner|auto` (`auto` is the default)
**For full output (when completed/stuck):**
```bash
script="$(printf "%s" "{project_root}/<installed-skill-root>/bmad-story-automator/scripts/story-automator")"
"$script" tmux-status-check "SESSION_NAME" --full
```
Returns: `idle,0,0,/tmp/sa-output-SESSION_NAME.txt,0,completed`
---
## Polling Pattern (for step-03-execute)
**Use `wait_estimate` from CSV - heuristic estimates optimal interval.**
| status | Action |
|--------|--------|
| `active` | Log: "{todos_done}/{todos_total} - {active_task}". Sleep `wait_estimate` seconds, re-poll |
| `idle` | Run `--full`, parse output per success-patterns.md |
| `crashed` | Session crashed! Column 4 = output file, Column 5 = exit code. Apply adaptive retry strategy. |
| `not_found` | Session ended unexpectedly, escalate |
| `error` | Retry once, then escalate |
**Crashed vs Completed (v2):**
- `completed` = session was active, then exited cleanly (exit code 0)
- `crashed` = session exited with non-zero exit code (context limit, API error, etc.)
- Always check session_state to distinguish between success and failure!
---
## Core Commands
### Create Session + Run Command
**CRITICAL: All child sessions MUST set `STORY_AUTOMATOR_CHILD=true`**
This environment variable tells the stop hook to allow the session to complete normally.
Without it, the stop hook will block child sessions from stopping, causing infinite loops.
```bash
# Current implementation:
# 1. create the session with an inert placeholder command
# 2. set remain-on-exit on the pane/session
# 3. respawn the pane into a bash runner that executes the per-session command file
tmux new-session -d -s "SESSION_NAME" -x 200 -y 50 -c "PROJECT_PATH" \
-e STORY_AUTOMATOR_CHILD=true -e AI_AGENT=codex -e CLAUDECODE= -e BASH_ENV= \
/bin/sleep 86400
tmux set-option -t "PANE_ID" remain-on-exit on
tmux respawn-pane -k -t "PANE_ID" /usr/bin/bash "/tmp/.sa-<hash>-session-SESSION_NAME-runner.sh"
```
**Terminal Dimensions:** The `-x 200 -y 50` flags remain required. They preserve the wide pane geometry used for interactive agent sessions and pane-derived transcripts.
**Command Files:** The runtime now always writes a per-session command file and a per-session runner file. This removes the old short-command vs long-command split and avoids quoting or line-wrap failures from `send-keys`. Explicit `tmux-wrapper kill` deletes these artifacts; stale terminal artifacts are garbage-collected after the retention TTL.
See `data/tmux-long-command-debugging.md` for detailed troubleshooting.
### Other Commands
```bash
tmux has-session -t "SESSION" 2>/dev/null # Check exists
tmux kill-session -t "SESSION" # Kill session
tmux list-sessions # List all
tmux capture-pane -t "SESSION" -p -S -100 # Raw capture (use sparingly)
```
---
## Variables
**Agent Configuration (v1.3.0):**
| Variable | Claude | Codex |
|----------|--------|-------|
| CLI | `claude --dangerously-skip-permissions` | `codex exec --full-auto` |
| Prompt Style | Natural language skill prompt | Natural language skill prompt |
| Timeout Multiplier | 1x (60min) | 1.5x (90min) |
| Todo Tracking | ☒/☐ checkboxes | Not supported |
**Environment Variables:**
- `AI_AGENT` = `claude` or `codex` (used by story-automator tmux-wrapper and story-automator monitor-session)
- `AI_COMMAND` = Full CLI (legacy, deprecated)
`{projectPath}` = project root
*See `workflow-commands.md` for BMAD workflow command patterns (including Codex natural language prompts).*

View File

@@ -0,0 +1,138 @@
# Tmux Long Command Debugging Guide
**Created:** 2026-01-21
**Context:** Debugging retrospective session failures in story-automator
**Root Cause:** Terminal width causes line-wrap corruption of long commands
**Related:** See `tmux-long-command-testing.md` for detailed investigation steps and test scripts.
---
## Problem Summary
Tmux sessions spawned via `tmux send-keys` were failing silently when commands exceeded ~1000 characters. Sessions would spawn successfully but the command would never execute, resulting in `stuck/never_active` status.
**Symptoms:**
- Session spawns successfully (tmux session exists)
- Command appears in terminal output (visible in capture-pane)
- No child processes running (Claude never starts)
- No error messages visible
- Monitor reports `stuck` or `never_active`
---
## Root Cause
**Default tmux terminal dimensions:** 80 columns × 24 rows
When `tmux send-keys` sends a command longer than the terminal width:
1. The command wraps across multiple lines in the terminal buffer
2. The shell receives the wrapped input as if it were multiple lines
3. Shell parsing fails or behaves unexpectedly with multi-line wrapped input
4. The command silently fails or produces syntax errors
**Critical insight:** This is NOT a tmux bug or a shell bug individually - it's an interaction problem between how `tmux send-keys` delivers characters and how the shell's line editor handles wrapped input.
---
## Solution
Add explicit dimensions when creating tmux sessions:
```bash
# Before (BROKEN for long commands):
tmux new-session -d -s "$session_name" -c "$PROJECT_ROOT"
# After (FIXED):
tmux new-session -d -s "$session_name" -x 200 -y 50 -c "$PROJECT_ROOT"
```
**Why 200×50:**
- 200 columns handles commands up to ~3000 chars without wrapping
- 50 rows provides adequate scrollback for monitoring
- These dimensions don't affect the actual terminal the user might attach to
---
## Key Insights
### 1. Silent Failures are Deceptive
The command appears in the terminal output but never executes. This makes debugging difficult because:
- `tmux capture-pane` shows the command was "sent"
- No error message is visible
- The session exists and appears healthy
**Lesson:** Always verify command execution by checking for child processes or activity indicators, not just command presence.
### 2. Length Threshold is Approximate
The exact failure point depends on:
- Terminal width (obviously)
- Command content (special characters, quotes)
- Shell type (bash vs zsh)
- tmux version
**Lesson:** Use generous margins. If your longest expected command is 1500 chars, use 200+ column width.
### 3. Quote Escaping is NOT the Issue
Initial hypothesis was that escaped quotes (`\"`) or special characters caused parsing failures. Testing proved this wrong:
```bash
# This works fine with wide terminal:
cmd='claude "test with \"quotes\" inside"'
tmux send-keys -t "$sess" "$cmd" Enter # SUCCESS at 200 cols
```
**Lesson:** Don't chase red herrings. Test the simplest hypothesis (length/width) before investigating complex escaping issues.
### 4. Process Detection is Reliable
The most reliable way to verify command execution:
```bash
PANE_PID=$(tmux display -t "$session" -p '#{pane_pid}')
if pgrep -P "$PANE_PID" >/dev/null 2>&1; then
echo "Command is running"
else
echo "No child processes - command failed"
fi
```
---
## Checklist for Future Debugging
When tmux commands fail silently:
- [ ] Check command length: `echo ${#cmd}`
- [ ] Check terminal dimensions: `tmux display -t "$sess" -p '#{pane_width}'`
- [ ] Test with wider terminal: `-x 200 -y 50`
- [ ] Verify with process check: `pgrep -P $PANE_PID`
- [ ] Check pane status: `tmux display -t "$sess" -p '#{pane_dead}'`
- [ ] Capture full output: `tmux capture-pane -t "$sess" -p -S -100`
---
## Bug: Script File Path Not Executed (2026-02-09)
**Symptoms identical to the terminal-width issue**, but with a different root cause.
When `spawn` receives a command longer than 500 characters, it writes the command to a script file (`/tmp/sa-cmd-{session}.sh`) and sends the path via `tmux send-keys`. However, the path was sent **without the `bash` prefix**, so the shell received a raw file path instead of an executable command.
**Affected commands:** Retrospective prompts (~1577 chars) — all other steps (create-story, dev-story, code-review) are under 500 chars and use direct `send-keys`.
**Fix:** `src/story_automator/commands/tmux.py` — changed the long-command fallback to send `bash /tmp/sa-cmd-{session}.sh` instead of a raw script path, and fail fast if the temp script write or `tmux send-keys` path breaks.
**Lesson:** Two independent failure modes can produce identical symptoms (`never_active`). The `-x 200 -y 50` fix handles line-wrapping for direct `send-keys`, but the script-file fallback path had its own bug. Always check both paths when debugging.
---
## Related Files
- `scripts/story-automator tmux-wrapper` - Session spawning with `-x 200 -y 50` fix + script file `bash` prefix fix
- `scripts/story-automator monitor-session` - Polling loop that detects stuck sessions
- `scripts/story-automator tmux-status-check` - Status detection with activity indicators
- `data/monitoring-pattern.md` - Overall monitoring architecture
- `data/tmux-long-command-testing.md` - Detailed investigation and test scripts

View File

@@ -0,0 +1,184 @@
# Tmux Long Command Testing & Investigation
**Related:** See `tmux-long-command-debugging.md` for root cause analysis and solution.
---
## Investigation Process
### Step 1: Verify Command Syntax
First, confirm the command itself is valid:
```bash
# Build the command
cmd=$("$scripts" tmux-wrapper build-cmd retro 2 --agent "codex")
# Check for syntax issues
echo "$cmd" | od -c | head -20 # Look for unexpected characters
# Test parsing
bash -n -c "$cmd" # Syntax check only
```
**Finding:** Command syntax was correct. Quotes and escapes were properly formed.
### Step 2: Test Progressive Lengths
Binary search to find the breaking point:
```bash
test_length() {
local len=$1
local sess="test-len-$len-$$"
local prompt="Execute the BMAD retrospective workflow for epic 2. $(printf 'x%.0s' $(seq 1 $len))"
tmux new-session -d -s "$sess"
tmux send-keys -t "$sess" "claude --dangerously-skip-permissions \"$prompt\"" Enter
sleep 5
local capture=$(tmux capture-pane -t "$sess" -p)
tmux kill-session -t "$sess" 2>/dev/null
if echo "$capture" | grep -qiE "interrupt|Working|Running"; then
echo "Length $len: SUCCESS"
else
echo "Length $len: FAILED"
fi
}
# Test different lengths
test_length 200 # SUCCESS
test_length 500 # SUCCESS
test_length 800 # SUCCESS
test_length 1000 # SUCCESS
test_length 1200 # FAILED
```
**Finding:** Commands failed around 1000-1200 characters.
### Step 3: Test Terminal Width Hypothesis
```bash
# Default dimensions
sess="test-default-$$"
tmux new-session -d -s "$sess"
tmux display -t "$sess" -p 'cols:#{pane_width} rows:#{pane_height}'
# Output: cols:80 rows:24
# Send long command
tmux send-keys -t "$sess" "$long_cmd" Enter
sleep 10
# Result: FAILED - no activity
# Wide terminal
sess="test-wide-$$"
tmux new-session -d -s "$sess" -x 200 -y 50
tmux display -t "$sess" -p 'cols:#{pane_width} rows:#{pane_height}'
# Output: cols:200 rows:50
# Send same long command
tmux send-keys -t "$sess" "$long_cmd" Enter
sleep 10
# Result: SUCCESS - Claude running!
```
**Finding:** Wide terminal (200 cols) prevents the failure.
### Step 4: Understand the Mechanism
The shell's line editor (readline/zle) handles input differently when lines wrap:
1. **Normal input:** Characters arrive, shell builds command buffer
2. **Wrapped input:** Terminal sends characters that visually wrap
3. **Problem:** Some shell/terminal combinations mishandle the wrap points
4. **Result:** Command buffer corruption or premature execution
This is why the command "appears" in the terminal (tmux captured it) but doesn't execute properly (shell didn't parse it correctly).
---
## Testing Methodology
### Quick Smoke Test
```bash
#!/bin/bash
# smoke-test-tmux-command.sh
cmd="$1"
cmd_len=${#cmd}
echo "Testing command of length: $cmd_len"
# Test with default dimensions
sess="smoke-default-$$"
tmux new-session -d -s "$sess"
tmux send-keys -t "$sess" "$cmd" Enter
sleep 5
if tmux capture-pane -t "$sess" -p | grep -qiE "interrupt|Working|Running|Read"; then
echo "Default (80x24): SUCCESS"
else
echo "Default (80x24): FAILED"
fi
tmux kill-session -t "$sess" 2>/dev/null
# Test with wide dimensions
sess="smoke-wide-$$"
tmux new-session -d -s "$sess" -x 200 -y 50
tmux send-keys -t "$sess" "$cmd" Enter
sleep 5
if tmux capture-pane -t "$sess" -p | grep -qiE "interrupt|Working|Running|Read"; then
echo "Wide (200x50): SUCCESS"
else
echo "Wide (200x50): FAILED"
fi
tmux kill-session -t "$sess" 2>/dev/null
```
### Comprehensive Test
```bash
#!/bin/bash
# test-tmux-long-commands.sh
test_at_width() {
local width=$1
local cmd_len=$2
local sess="test-w${width}-l${cmd_len}-$$"
# Generate command of specific length
local padding=$(printf 'x%.0s' $(seq 1 $cmd_len))
local cmd="echo \"test $padding\""
tmux new-session -d -s "$sess" -x "$width" -y 24
tmux send-keys -t "$sess" "$cmd" Enter
sleep 2
local output=$(tmux capture-pane -t "$sess" -p)
tmux kill-session -t "$sess" 2>/dev/null
if echo "$output" | grep -q "test xxx"; then
echo "Width $width, Length $cmd_len: PASS"
return 0
else
echo "Width $width, Length $cmd_len: FAIL"
return 1
fi
}
# Test matrix
for width in 80 120 160 200; do
for len in 500 1000 1500 2000; do
test_at_width $width $len
done
done
```
---
## References
- tmux manual: `man tmux` (see `new-session` options)
- Shell line editing: readline (bash) / zle (zsh)
- Related issue: Commands with many arguments or long strings failing in tmux

View File

@@ -0,0 +1,118 @@
# Workflow Prompt Reference
**Related:** See `tmux-commands.md` for session naming and management.
---
## Multi-Agent Support
| Agent | CLI Command | Prompt Style |
|-------|-------------|--------------|
| **Claude** | `claude --dangerously-skip-permissions` | Natural language skill prompt |
| **Codex** | `codex exec --full-auto` | Natural language skill prompt |
All child sessions receive explicit skill and workflow paths. Command wrappers are not required.
---
## Required Prompt Fields
Every generated prompt must include:
1. Which skill/workflow to execute
2. The `SKILL.md` path when available
3. The `workflow.md` or `workflow.yaml` path
4. The story file pattern in `_bmad-output/implementation-artifacts`
5. The story ID or epic ID
6. Any automation instruction such as `#YOLO` or `auto-fix all issues without prompting`
---
## dev-story
```bash
tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "Execute the BMAD dev-story workflow for story STORY_ID.
READ this skill first: <installed-skill-root>/bmad-dev-story/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-dev-story/workflow.md
Story file: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md
Implement all tasks marked [ ]. Run tests. Update checkboxes."' Enter
```
---
## code-review
**MUST use the dedicated `bmad-story-automator-review` skill. Do NOT use a generic Task agent for reviews.**
```bash
tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "Execute the story-automator review workflow for story STORY_ID.
READ this skill first: <installed-skill-root>/bmad-story-automator-review/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-story-automator-review/workflow.yaml
Then read: <installed-skill-root>/bmad-story-automator-review/instructions.xml
Validate with: <installed-skill-root>/bmad-story-automator-review/checklist.md
Story file: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md
Review implementation, find issues, fix them automatically. auto-fix all issues without prompting"' Enter
```
**Why `auto-fix all issues without prompting`:** The dedicated review workflow normally presents a findings menu. This instruction tells it to automatically fix issues without prompting.
---
## create-story
```bash
tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "Execute the BMAD create-story workflow for story STORY_ID.
READ this skill first: <installed-skill-root>/bmad-create-story/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-create-story/workflow.md
Then read: <installed-skill-root>/bmad-create-story/discover-inputs.md
Use template: <installed-skill-root>/bmad-create-story/template.md
Validate with: <installed-skill-root>/bmad-create-story/checklist.md
Create story file at: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md
Story ID: STORY_ID
#YOLO - Do NOT wait for user input."' Enter
```
**CRITICAL:** Always pass the story ID (for example, `5.3`) to ensure create-story creates only that one story.
---
## automate
```bash
tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "Execute the BMAD qa-generate-e2e-tests workflow for story STORY_ID.
READ this skill first: <installed-skill-root>/bmad-qa-generate-e2e-tests/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-qa-generate-e2e-tests/workflow.md
Validate with: <installed-skill-root>/bmad-qa-generate-e2e-tests/checklist.md
Story file: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md
Auto-apply all discovered gaps in tests."' Enter
```
If `bmad-qa-generate-e2e-tests` is missing from the installed skill root, story-automator install still succeeds, but the orchestrator should run with `Skip Automate = true`.
---
## retrospective
```bash
tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "Execute the BMAD retrospective workflow for epic EPIC_ID.
READ this skill first: <installed-skill-root>/bmad-retrospective/SKILL.md
READ this workflow file next: <installed-skill-root>/bmad-retrospective/workflow.md
Run the retrospective in #YOLO mode and assume the user will NOT provide input."' Enter
```
---
## Variables
- `AI_AGENT` = `claude` or `codex`
- `AI_COMMAND` = full CLI command override, legacy and deprecated
- `STORY_PREFIX` = story ID with dots replaced by hyphens, for example `6.1` -> `6-1`
- `{projectPath}` = project root
All commands assume the session was created with `STORY_AUTOMATOR_CHILD=true`.

View File

@@ -0,0 +1,131 @@
# Wrapup Templates
Templates for the wrapup step summary, learnings, and recommendations.
---
## Summary Report Template
```
**📊 Build Cycle Summary**
**Epic:** {epic_name}
**Stories:** {story_range} ({completed}/{total} completed)
**Duration:** {start_time} to {end_time}
---
**Story Results:**
| Story | Title | Status | Review Cycles | Notes |
|-------|-------|--------|---------------|-------|
{story_results_table}
---
**Execution Statistics:**
| Metric | Value |
|--------|-------|
| Stories Completed | {count} |
| Stories Skipped/Aborted | {count} |
| Total Code Review Cycles | {count} |
| Escalations | {count} |
| Git Commits | {count} |
---
**Session Summary:**
| Session Type | Count | Avg Duration |
|--------------|-------|--------------|
| create-story | {count} | {avg} |
| dev-story | {count} | {avg} |
| automate | {count} | {avg} |
| code-review | {count} | {avg} |
---
**Escalations Encountered:**
{escalation_list_or_'None'}
**Issues Resolved:**
{issues_resolved_list_or_'None'}
```
---
## Learnings Entry Template
Append this to the sidecar learnings file:
```markdown
## Run: {timestamp}
**Epic:** {epic_name}
**Stories:** {story_range}
### Patterns Observed
- {pattern_1}
- {pattern_2}
### Code Review Insights
- Common issues: {list}
- Average cycles to clean: {avg}
### Timing Estimates
- create-story: ~{avg_time}
- dev-story: ~{avg_time}
- code-review: ~{avg_time} per cycle
### Recommendations for Future Runs
- {recommendation_1}
- {recommendation_2}
```
**Patterns to capture:**
- Common code review issues (what kept failing?)
- Steps that frequently needed escalation
- Stories that took longer than expected
- Successful patterns (what worked well?)
---
## Recommendations Template
```
**💡 Recommendations**
Based on this build cycle run:
**For Future Runs:**
{recommendations_based_on_patterns}
**Process Improvements:**
{suggestions_for_workflow_improvements}
**Technical Debt:**
{any_tech_debt_identified}
**Documentation Needs:**
{any_docs_that_should_be_updated}
```
---
## Completion Message Template
```
**✅ Story Automator Complete**
**Results saved to:**
- State document: `{state_document_path}`
- Learnings: `{sidecarFile}`
**Stories implemented:** {count}
**Git commits made:** {count}
Thank you for using Story Automator. The state document contains full history for reference.
To run another build cycle, invoke the story-automator workflow again.
```

View File

@@ -0,0 +1,28 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "story-automator"
version = "1.15.0"
description = "Python parity port of the BMAD story automator helper"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.11"
authors = [
{ name = "BMAD", email = "bmad.directory@gmail.com" }
]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13"
]
[project.scripts]
story-automator = "story_automator.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/story_automator"]

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKFLOW_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
export PYTHONPATH="$WORKFLOW_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
exec python3 -m story_automator "$@"

View File

@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "1.15.0"

View File

@@ -0,0 +1,5 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ..core.common import ensure_dir, run_cmd, trim_lines
from ..core.tmux_runtime import (
agent_cli,
agent_type,
detect_codex_session,
estimate_wait,
extract_active_task,
generate_session_name,
heartbeat_check,
load_session_state,
pane_status,
project_hash,
project_slug,
save_session_state,
session_status,
skill_prefix,
tmux_display,
tmux_has_session,
tmux_kill_session,
tmux_list_sessions as _tmux_list_sessions,
tmux_show_environment,
verify_or_create_output,
)
@dataclass
class TmuxStatus:
status: str
todos_done: int
todos_total: int
active_task: str
wait_estimate: int
session_state: str
def tmux_new_session(session: str, root: str | Path, selected_agent: str) -> tuple[str, int]:
return run_cmd(
"tmux",
"new-session",
"-d",
"-s",
session,
"-x",
"200",
"-y",
"50",
"-c",
str(root),
"-e",
"STORY_AUTOMATOR_CHILD=true",
"-e",
f"AI_AGENT={selected_agent}",
"-e",
"CLAUDECODE=",
)
def tmux_send_keys(session: str, command: str, enter: bool = True) -> tuple[str, int]:
args = ["tmux", "send-keys", "-t", session, command]
if enter:
args.append("Enter")
return run_cmd(*args)
def tmux_list_sessions(project_only: bool = False) -> list[str]:
sessions, _ = _tmux_list_sessions(project_only)
return sessions
def load_json_state(path: str | Path) -> dict[str, object]:
return load_session_state(path)
def save_json_state(path: str | Path, payload: dict[str, object]) -> None:
ensure_dir(Path(path).parent)
save_session_state(path, payload)
def count_rune(text: str, target: str) -> int:
return sum(1 for char in text if char == target)
def find_first_todo_line(capture: str) -> int:
for index, line in enumerate(trim_lines(capture), start=1):
if "" in line or "" in line:
return index
return 999

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import sys
from typing import Callable
from .commands.agent_config_cmd import cmd_agent_config
from .commands.basic import (
cmd_commit_story,
cmd_derive_project_slug,
cmd_ensure_marker_gitignore,
cmd_ensure_stop_hook,
cmd_list_sessions,
cmd_stop_hook,
)
from .commands.orchestrator import cmd_orchestrator_helper
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
from .core.common import help_flag, print_json
from .core.epic_parser import epic_complete, parse_epic_file, parse_story, parse_story_range
Command = Callable[[list[str]], int]
def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if not args:
_usage(sys.stderr)
return 1
if help_flag(args[0]):
_usage(sys.stdout)
return 0
command = args[0]
rest = args[1:]
commands: dict[str, Command] = {
"derive-project-slug": cmd_derive_project_slug,
"ensure-marker-gitignore": cmd_ensure_marker_gitignore,
"ensure-stop-hook": cmd_ensure_stop_hook,
"stop-hook": cmd_stop_hook,
"build-state-doc": cmd_build_state_doc,
"commit-story": cmd_commit_story,
"parse-epic": _cmd_parse_epic,
"parse-story": _cmd_parse_story,
"parse-story-range": _cmd_parse_story_range,
"epic-complete": _cmd_epic_complete,
"sprint-compare": cmd_sprint_compare,
"state-metrics": cmd_state_metrics,
"validate-state": cmd_validate_state,
"validate-story-creation": cmd_validate_story_creation,
"list-sessions": cmd_list_sessions,
"tmux-wrapper": cmd_tmux_wrapper,
"heartbeat-check": cmd_heartbeat_check,
"codex-status-check": cmd_codex_status_check,
"tmux-status-check": cmd_tmux_status_check,
"monitor-session": cmd_monitor_session,
"orchestrator-helper": cmd_orchestrator_helper,
"agent-config": cmd_agent_config,
}
handler = commands.get(command)
if not handler:
print(f"Unknown command: {command}", file=sys.stderr)
_usage(sys.stderr)
return 1
return handler(rest)
def _usage(stream: object) -> None:
print("story-automator <command> [args]", file=stream)
print("", file=stream)
print("Commands:", file=stream)
for name in (
"derive-project-slug",
"ensure-marker-gitignore",
"ensure-stop-hook",
"stop-hook",
"build-state-doc",
"commit-story",
"parse-epic",
"parse-story",
"parse-story-range",
"epic-complete",
"sprint-compare",
"state-metrics",
"validate-state",
"validate-story-creation",
"list-sessions",
"tmux-wrapper",
"heartbeat-check",
"codex-status-check",
"tmux-status-check",
"monitor-session",
"orchestrator-helper",
"agent-config",
):
print(f" {name}", file=stream)
def _cmd_parse_epic(args: list[str]) -> int:
epic_file = _arg_value(args, "--file")
if not epic_file:
print_json({"ok": False, "error": "epic_file_not_found"})
return 1
try:
print_json(parse_epic_file(epic_file))
return 0
except FileNotFoundError:
print_json({"ok": False, "error": "epic_file_not_found"})
return 1
def _cmd_parse_story(args: list[str]) -> int:
epic = _arg_value(args, "--epic")
story = _arg_value(args, "--story")
rules = _arg_value(args, "--rules")
if not epic or not story:
print_json({"ok": False, "error": "missing_epic_or_story"})
return 1
if not rules:
print_json({"ok": False, "error": "rules_file_not_found"})
return 1
try:
print_json(parse_story(epic, story, rules))
return 0
except FileNotFoundError:
print_json({"ok": False, "error": "missing_epic_or_story" if epic else "rules_file_not_found"})
return 1
except ValueError as exc:
print_json({"ok": False, "error": str(exc)})
return 1
def _cmd_parse_story_range(args: list[str]) -> int:
user_input = _arg_value(args, "--input")
total = int(_arg_value(args, "--total") or 0)
ids = _arg_value(args, "--ids") or ""
try:
print_json(parse_story_range(user_input, total, ids))
return 0
except ValueError:
print_json({"ok": False, "error": "missing_input_or_total"})
return 1
def _cmd_epic_complete(args: list[str]) -> int:
epic = _arg_value(args, "--epic")
range_csv = _arg_value(args, "--range") or ""
if not epic:
print_json({"ok": False, "error": "epic_file_not_found"})
return 1
try:
print_json(epic_complete(epic, range_csv))
return 0
except FileNotFoundError:
print_json({"ok": False, "error": "epic_file_not_found"})
return 1
except ValueError as exc:
print_json({"ok": False, "error": str(exc)})
return 1
def _arg_value(args: list[str], flag: str) -> str:
for index, value in enumerate(args):
if value == flag and index + 1 < len(args):
return args[index + 1]
return ""

View File

@@ -0,0 +1 @@
"""Command modules."""

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from ..core.agent_config import load_presets_file, save_presets_file
from ..core.common import iso_now, print_json
def cmd_agent_config(args: list[str]) -> int:
if not args:
print_json({"ok": False, "error": "missing_subcommand"})
return 1
action = args[0]
params = _flag_map(args[1:])
file_path = params.get("file", "")
name = params.get("name", "")
config_json = params.get("config-json", "")
if action == "list":
if not file_path:
print_json({"ok": False, "error": "missing_file"})
return 1
data = load_presets_file(file_path)
presets = [{"name": preset["name"], "createdAt": preset["createdAt"]} for preset in data.get("presets", [])]
print_json({"ok": True, "presets": presets, "count": len(presets)})
return 0
if action == "save":
if not file_path or not name.strip() or not config_json.strip():
print_json({"ok": False, "error": "missing_args"})
return 1
try:
config = json.loads(config_json)
except json.JSONDecodeError:
print_json({"ok": False, "error": "invalid_config_json"})
return 1
data = load_presets_file(file_path)
action_name = "created"
for preset in data["presets"]:
if preset["name"].lower() == name.lower():
preset["config"] = config
preset["createdAt"] = iso_now()
action_name = "updated"
break
else:
data["presets"].append({"name": name, "createdAt": iso_now(), "config": config})
save_presets_file(file_path, data)
print_json({"ok": True, "name": name, "action": action_name})
return 0
if action == "load":
if not file_path or not name.strip():
print_json({"ok": False, "error": "missing_args"})
return 1
for preset in load_presets_file(file_path)["presets"]:
if preset["name"].lower() == name.lower():
print_json({"ok": True, "name": preset["name"], "config": preset["config"]})
return 0
print_json({"ok": False, "error": "preset_not_found", "name": name})
return 1
if action == "delete":
if not file_path or not name.strip():
print_json({"ok": False, "error": "missing_args"})
return 1
data = load_presets_file(file_path)
filtered = [preset for preset in data["presets"] if preset["name"].lower() != name.lower()]
if len(filtered) == len(data["presets"]):
print_json({"ok": False, "error": "preset_not_found", "name": name})
return 1
data["presets"] = filtered
save_presets_file(file_path, data)
print_json({"ok": True, "name": name, "action": "deleted"})
return 0
print_json({"ok": False, "error": "unknown_subcommand", "subcommand": action})
return 1
def _flag_map(args: list[str]) -> dict[str, str]:
output: dict[str, str] = {}
index = 0
while index < len(args):
if args[index].startswith("--") and index + 1 < len(args):
output[args[index][2:]] = args[index + 1]
index += 2
continue
index += 1
return output

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
import json
import os
import shlex
import shutil
import sys
from pathlib import Path
from ..core.runtime_layout import active_marker_path, runtime_provider
from ..core.stop_hooks import HookConfigError, ensure_stop_hook
from ..core.utils import (
get_project_slug,
run_cmd,
write_json,
)
def _workflow_root() -> Path:
return Path(__file__).resolve().parents[3]
def _workflow_doc_relative(doc_name: str) -> str:
doc_path = _workflow_root() / "data" / doc_name
project_root = Path(os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
try:
return str(doc_path.resolve().relative_to(project_root))
except ValueError:
return str(doc_path.resolve())
def _stop_hook_command(command: str, project_root: Path) -> str:
command_parts = shlex.split(command)
if not command_parts:
return command
candidates = [
_workflow_root() / "scripts" / "story-automator",
Path(shutil.which("story-automator")) if shutil.which("story-automator") else None,
Path(sys.argv[0]).resolve() if Path(sys.argv[0]).exists() and os.access(Path(sys.argv[0]), os.X_OK) else None,
]
for candidate in candidates:
if candidate and candidate.exists() and os.access(candidate, os.X_OK):
command_parts[0] = str(candidate.resolve())
return shlex.join(["env", f"PROJECT_ROOT={project_root}", *command_parts])
return shlex.join(["env", f"PROJECT_ROOT={project_root}", shutil.which("python3") or "python3", "-m", "story_automator", *command_parts[1:]])
def cmd_derive_project_slug(args: list[str]) -> int:
if args and args[0] in {"--help", "-h"}:
print("Usage: derive-project-slug [--project-root PATH]")
return 0
project_root = os.getcwd()
for idx, arg in enumerate(args):
if arg == "--project-root" and idx + 1 < len(args):
project_root = args[idx + 1]
write_json({"ok": True, "slug": get_project_slug(project_root), "projectRoot": project_root})
return 0
def cmd_ensure_marker_gitignore(args: list[str]) -> int:
gitignore = ""
entry = ""
for idx, arg in enumerate(args):
if arg == "--gitignore" and idx + 1 < len(args):
gitignore = args[idx + 1]
if arg == "--entry" and idx + 1 < len(args):
entry = args[idx + 1]
if not gitignore or not entry:
write_json({"ok": False, "error": "missing_args"})
return 1
path = Path(gitignore)
if not path.exists():
path.write_text("")
content = path.read_text()
for line in content.replace("\r\n", "\n").split("\n"):
stripped = line.strip()
if stripped and not stripped.startswith("#") and stripped == entry:
write_json({"ok": True, "changed": False, "path": str(path)})
return 0
prefix = "" if not content or content.endswith("\n") else "\n"
with path.open("a") as handle:
handle.write(f"{prefix}{entry}\n")
write_json({"ok": True, "changed": True, "path": str(path)})
return 0
def cmd_ensure_stop_hook(args: list[str]) -> int:
settings = ""
command = ""
timeout = 10
idx = 0
while idx < len(args):
arg = args[idx]
if arg == "--settings" and idx + 1 < len(args):
settings = args[idx + 1]
idx += 2
elif arg == "--command" and idx + 1 < len(args):
idx += 1
command_parts: list[str] = []
while idx < len(args) and not args[idx].startswith("--"):
command_parts.append(args[idx])
idx += 1
if command_parts:
command = command_parts[0] if len(command_parts) == 1 else shlex.join(command_parts)
elif arg == "--timeout" and idx + 1 < len(args):
timeout = int(args[idx + 1])
idx += 2
else:
idx += 1
if not command:
write_json({"ok": False, "error": "missing_required_args"})
return 1
project_root = Path(os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
provider = runtime_provider(project_root)
if provider == "claude" and not settings:
write_json({"ok": False, "error": "missing_required_args"})
return 1
command = _stop_hook_command(command, project_root)
settings_path = Path(settings).expanduser().resolve() if settings else None
try:
result = ensure_stop_hook(
provider=provider,
project_root=project_root,
settings_path=settings_path,
command=command,
timeout=timeout,
)
except HookConfigError as exc:
write_json(
{
"ok": False,
"error": exc.code,
"path": str(exc.path),
"provider": provider,
"message": exc.message,
}
)
return 1
write_json({"ok": True, **result})
return 0
def cmd_stop_hook(_: list[str]) -> int:
sys.stdin.read()
if os.environ.get("STORY_AUTOMATOR_CHILD", "").lower() == "true":
return 0
marker = active_marker_path()
if not marker.exists():
return 0
try:
payload = json.loads(marker.read_text())
except json.JSONDecodeError:
return 0
remaining = payload.get("storiesRemaining", 0)
if isinstance(remaining, str) and remaining.isdigit():
remaining = int(remaining)
if not remaining:
return 0
reason = (
"Story Automator active "
f"({remaining} stories remaining). Read "
+ _workflow_doc_relative("stop-hook-recovery.md")
)
print(json.dumps({"decision": "block", "reason": reason}, indent=2))
return 0
def cmd_commit_story(args: list[str]) -> int:
repo = ""
story = ""
title = ""
for idx, arg in enumerate(args):
if arg == "--repo" and idx + 1 < len(args):
repo = args[idx + 1]
elif arg == "--story" and idx + 1 < len(args):
story = args[idx + 1]
elif arg == "--title" and idx + 1 < len(args):
title = args[idx + 1]
if not repo or not story or not title:
write_json({"ok": False, "error": "missing_args"})
return 1
if not Path(repo).is_dir():
write_json({"ok": False, "error": "repo_not_found"})
return 1
status = run_cmd("git", "-C", repo, "status", "--porcelain")
if status.exit_code != 0:
write_json({"ok": False, "error": "git_status_failed"})
return 1
lines = [line for line in status.output.strip().splitlines() if line.strip()]
if not lines:
write_json({"ok": False, "error": "no_changes"})
return 0
if run_cmd("git", "-C", repo, "add", "-A").exit_code != 0:
write_json({"ok": False, "error": "git_add_failed"})
return 1
message = f"feat(story-{story}): {title}"
commit = run_cmd("git", "-C", repo, "commit", "-m", message)
if commit.exit_code != 0:
write_json({"ok": False, "error": "commit_failed"})
return 1
sha = run_cmd("git", "-C", repo, "rev-parse", "HEAD").output.strip()
write_json({"ok": True, "commit": sha})
return 0
def cmd_list_sessions(args: list[str]) -> int:
if args and args[0] in {"--help", "-h"}:
print("Usage: list-sessions --slug SLUG")
return 0
slug = ""
for idx, arg in enumerate(args):
if arg == "--slug" and idx + 1 < len(args):
slug = args[idx + 1]
if not slug:
write_json({"ok": False, "error": "missing_slug"})
return 1
if shutil.which("tmux") is None:
write_json({"ok": False, "error": "tmux_not_found", "sessions": [], "count": 0})
return 0
result = run_cmd("tmux", "list-sessions", "-F", "#{session_name}")
if result.exit_code != 0:
write_json({"ok": True, "sessions": [], "count": 0})
return 0
prefix = f"sa-{slug}-"
sessions = [line for line in result.output.splitlines() if line.startswith(prefix)]
write_json({"ok": True, "sessions": sessions, "count": len(sessions)})
return 0

View File

@@ -0,0 +1,486 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from story_automator.core.frontmatter import (
extract_last_action,
find_frontmatter_value,
find_frontmatter_value_case,
parse_frontmatter,
parse_simple_frontmatter,
)
from story_automator.core.runtime_policy import (
PolicyError,
crash_max_retries,
load_runtime_policy,
review_max_cycles,
summarize_state_policy_fields,
)
from story_automator.core.review_verify import verify_code_review_completion
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.utils import (
atomic_write,
ensure_dir,
extract_json_line,
file_exists,
get_project_root,
iso_now,
print_json,
read_text,
run_cmd,
trim_lines,
)
from .orchestrator_epic_agents import (
agents_build_action,
agents_resolve_action,
check_blocking_action,
check_epic_complete_action,
get_epic_stories_action,
retro_agent_action,
)
from .orchestrator_parse import parse_output_action
def cmd_orchestrator_helper(args: list[str]) -> int:
if not args:
return _usage(1)
if args[0] in {"--help", "-h"}:
return _usage(0)
action = args[0]
dispatch = {
"sprint-status": _sprint_status,
"parse-output": parse_output_action,
"marker": _marker,
"state-list": _state_list,
"state-latest": _state_latest,
"state-latest-incomplete": _state_latest_incomplete,
"state-summary": _state_summary,
"state-update": _state_update,
"escalate": _escalate,
"commit-ready": _commit_ready,
"normalize-key": _normalize_key,
"story-file-status": _story_file_status,
"verify-step": _verify_step,
"verify-code-review": _verify_code_review,
"check-epic-complete": check_epic_complete_action,
"get-epic-stories": get_epic_stories_action,
"check-blocking": check_blocking_action,
"agents-build": agents_build_action,
"agents-resolve": agents_resolve_action,
"retro-agent": retro_agent_action,
}
handler = dispatch.get(action)
if handler is None:
return _usage(1)
return handler(args[1:])
def _usage(code: int) -> int:
target = __import__("sys").stderr if code else __import__("sys").stdout
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 exists", file=target)
print(" sprint-status check-epic <epic>", file=target)
print(" parse-output <file> <step>", file=target)
print(" marker path", file=target)
print(" marker create --epic E --story S --remaining N --state-file F", file=target)
print(" marker remove", file=target)
print(" marker check", file=target)
print(" marker heartbeat", file=target)
print(" state-list <folder>", file=target)
print(" state-latest <folder> [status]", file=target)
print(" state-latest-incomplete <folder>", file=target)
print(" state-summary <file>", file=target)
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(" 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)
print(" check-epic-complete <epic> <story> [--state-file path]", file=target)
print(" get-epic-stories <epic> [--state-file path]", file=target)
print(" check-blocking <story_id>", file=target)
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)
return code
def _sprint_status(args: list[str]) -> int:
if not args:
print("Usage: orchestrator-helper sprint-status <get|exists|check-epic> [args]", file=__import__("sys").stderr)
return 1
project_root = get_project_root()
if args[0] == "get":
if len(args) < 2:
print("Usage: orchestrator-helper sprint-status get <story_key>", file=__import__("sys").stderr)
return 1
status = sprint_status_get(project_root, args[1])
if not status.found and status.reason:
print_json({"found": False, "status": status.status, "reason": status.reason})
return 0
if not status.found:
print_json({"found": False, "story": args[1], "status": "not_found"})
return 0
print_json({"found": True, "story": status.story, "status": status.status, "done": status.done})
return 0
if args[0] == "exists":
print("true" if file_exists(sprint_status_file(project_root)) else "false")
return 0
if args[0] == "check-epic":
if len(args) < 2:
print("Usage: orchestrator-helper sprint-status check-epic <epic>", file=__import__("sys").stderr)
return 1
stories, done = sprint_status_epic(project_root, args[1])
if not stories:
print_json({"ok": False, "epic": args[1], "allStoriesDone": False, "reason": "no_stories_found", "count": 0})
return 0
print_json({"ok": True, "epic": args[1], "allStoriesDone": done == len(stories), "total": len(stories), "done": done, "count": len(stories), "stories": stories})
return 0
print("Usage: orchestrator-helper sprint-status <get|exists|check-epic> [args]", file=__import__("sys").stderr)
return 1
def _marker(args: list[str]) -> int:
if not args:
print("Usage: orchestrator-helper marker <path|create|remove|check|heartbeat> [args]", file=__import__("sys").stderr)
return 1
project_root = Path(get_project_root())
marker_file = active_marker_path(project_root)
if args[0] == "path":
print_json({"file": str(marker_file), "entry": active_marker_project_entry(project_root)})
return 0
if args[0] == "create":
options = {"epic": "", "story": "", "remaining": "0", "state-file": "", "project-slug": "", "pid": "0", "heartbeat": ""}
idx = 1
while idx < len(args):
key = args[idx].lstrip("-")
if idx + 1 < len(args):
options[key] = args[idx + 1]
idx += 2
else:
idx += 1
ensure_dir(marker_file.parent)
payload = {
"epic": options["epic"],
"currentStory": options["story"],
"storiesRemaining": int(options["remaining"] or "0"),
"stateFile": options["state-file"],
"createdAt": iso_now(),
"heartbeat": options["heartbeat"] or iso_now(),
"pid": int(options["pid"] or "0"),
"projectSlug": options["project-slug"],
}
atomic_write(marker_file, json.dumps(payload, indent=2) + "\n")
print(f"Marker created: {marker_file}")
return 0
if args[0] == "remove":
if marker_file.exists():
marker_file.unlink()
print("Marker removed")
return 0
if args[0] == "check":
if marker_file.exists():
print(f'{{"exists":true,"file":"{marker_file}"}}')
print(marker_file.read_text(encoding="utf-8"), end="")
return 0
print('{"exists":false}')
return 0
if args[0] == "heartbeat":
if not marker_file.exists():
print("No marker file to update")
return 1
payload = json.loads(marker_file.read_text(encoding="utf-8"))
payload["heartbeat"] = iso_now()
atomic_write(marker_file, json.dumps(payload, indent=2) + "\n")
print(f"Heartbeat updated: {payload['heartbeat']}")
return 0
print("Usage: orchestrator-helper marker <path|create|remove|check|heartbeat> [args]", file=__import__("sys").stderr)
return 1
def _state_list(args: list[str]) -> int:
if not args or not Path(args[0]).is_dir():
print_json({"ok": False, "error": "folder_not_found", "files": []})
return 1
files = []
for path in sorted(Path(args[0]).glob("orchestration-*.md")):
files.append({"path": str(path), "status": find_frontmatter_value(path, "status") or "unknown", "lastUpdated": find_frontmatter_value(path, "lastUpdated") or "unknown"})
print_json({"ok": True, "files": files})
return 0
def _state_latest(args: list[str]) -> int:
if not args or not Path(args[0]).is_dir():
print_json({"ok": False, "error": "folder_not_found"})
return 1
status_filter = args[1] if len(args) > 1 else ""
matches = []
for path in Path(args[0]).glob("orchestration-*.md"):
status = find_frontmatter_value(path, "status")
if status_filter and status != status_filter:
continue
matches.append((find_frontmatter_value(path, "lastUpdated"), str(path)))
if not matches:
print_json({"ok": False, "error": "no_match"})
return 0
updated, path = max(matches)
print_json({"ok": True, "path": path, "lastUpdated": updated})
return 0
def _state_latest_incomplete(args: list[str]) -> int:
if not args or not Path(args[0]).is_dir():
print_json({"ok": False, "error": "folder_not_found"})
return 1
matches = []
for path in Path(args[0]).glob("orchestration-*.md"):
status = find_frontmatter_value(path, "status")
if status == "COMPLETE":
continue
matches.append((find_frontmatter_value(path, "lastUpdated"), status, str(path)))
if not matches:
print_json({"ok": False, "error": "no_incomplete_state"})
return 0
updated, status, path = max(matches)
print_json({"ok": True, "path": path, "lastUpdated": updated, "status": status})
return 0
def _state_summary(args: list[str]) -> int:
if not args or not file_exists(args[0]):
print_json({"ok": False, "error": "file_not_found"})
return 1
fields = parse_simple_frontmatter(read_text(args[0]))
snapshot_file, snapshot_hash, policy_version, legacy_policy, policy_error = summarize_state_policy_fields(
fields,
project_root=get_project_root(),
)
payload = {
"ok": True,
"epic": str(fields.get("epic") or ""),
"epicName": str(fields.get("epicName") or ""),
"currentStory": str(fields.get("currentStory") or ""),
"currentStep": str(fields.get("currentStep") or ""),
"status": str(fields.get("status") or ""),
"lastUpdated": str(fields.get("lastUpdated") or ""),
"policyVersion": policy_version,
"policySnapshotFile": snapshot_file,
"policySnapshotHash": snapshot_hash,
"legacyPolicy": legacy_policy,
"lastAction": extract_last_action(args[0]),
}
if policy_error:
payload["policyError"] = policy_error
print_json(payload)
return 0
def _state_update(args: list[str]) -> int:
if not args or not file_exists(args[0]):
print_json({"ok": False, "error": "file_not_found"})
return 1
text = read_text(args[0])
updated: list[str] = []
idx = 1
while idx < len(args):
if args[idx] == "--set" and idx + 1 < len(args):
key, value = args[idx + 1].split("=", 1)
replaced, count = re.subn(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {v}", text)
if count:
text = replaced
updated.append(key)
idx += 2
continue
idx += 1
if not updated:
print_json({"ok": False, "error": "keys_not_found", "updated": []})
return 1
Path(args[0]).write_text(text, encoding="utf-8")
print_json({"ok": True, "updated": updated})
return 0
def _escalate(args: list[str]) -> int:
trigger = args[0] if args else ""
context = args[1] if len(args) > 1 else ""
state_file = ""
idx = 2
try:
while idx < len(args):
if args[idx] == "--state-file":
state_file = _flag_value(args, idx, "--state-file")
idx += 2
continue
idx += 1
except PolicyError as exc:
print_json({"escalate": True, "reason": str(exc)})
return 0
try:
policy = load_runtime_policy(get_project_root(), state_file=state_file)
except (FileNotFoundError, PolicyError) as exc:
print_json({"escalate": True, "reason": str(exc)})
return 0
if trigger == "review-loop":
cycles = _parse_context_int(context, "cycles")
limit = review_max_cycles(policy)
if cycles >= limit:
print_json({"escalate": True, "reason": f"Review loop exceeded max cycles ({cycles}/{limit})"})
else:
print_json({"escalate": False})
return 0
if trigger == "session-crash":
retries = _parse_context_int(context, "retries")
limit = crash_max_retries(policy)
if retries >= limit:
print_json({"escalate": True, "reason": f"Session crashed after {retries} retries"})
else:
print_json({"escalate": False, "action": "retry"})
return 0
if trigger == "story-validation":
created = _parse_context_int(context, "created")
if created != 1:
print_json({"escalate": True, "reason": "No story file created" if created == 0 else f"Runaway creation: {created} files"})
else:
print_json({"escalate": False})
return 0
print_json({"escalate": False, "reason": "Unknown trigger"})
return 0
def _commit_ready(args: list[str]) -> int:
if not args:
print_json({"ready": False, "reason": "story_id required"})
return 1
project_root = get_project_root()
status = sprint_status_get(project_root, args[0])
if status.done:
out, _ = run_cmd("git", "-C", project_root, "status", "--porcelain")
if out.strip():
print_json({"ready": True, "story": args[0], "status": "done", "uncommitted_changes": True})
return 0
print_json({"ready": False, "reason": "No uncommitted changes", "story": args[0]})
return 0
print_json({"ready": False, "reason": "Story not done yet", "story": args[0], "current_status": status.status})
return 0
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]
result = normalize_story_key(get_project_root(), args[0])
if result is None:
print_json({"ok": False, "error": "unrecognized format", "input": args[0]})
return 1
if fmt == "id":
print(result.id)
elif fmt == "prefix":
print(result.prefix)
elif fmt == "key":
print(result.key)
else:
print_json({"ok": True, "id": result.id, "prefix": result.prefix, "key": result.key})
return 0
def _story_file_status(args: list[str]) -> int:
if not args:
print_json({"ok": False, "error": "story input required"})
return 1
norm = normalize_story_key(get_project_root(), args[0])
if norm is None:
print_json({"ok": False, "error": "could not normalize story key", "input": args[0]})
return 1
matches = sorted((Path(get_project_root()) / "_bmad-output" / "implementation-artifacts").glob(f"{norm.prefix}-*.md"))
if not matches:
print_json({"ok": False, "error": "story file not found", "prefix": norm.prefix})
return 1
print_json({"ok": True, "story_key": norm.key, "file": str(matches[0]), "status": find_frontmatter_value_case(matches[0], "Status") or "unknown", "title": find_frontmatter_value_case(matches[0], "Title")})
return 0
def _verify_code_review(args: list[str]) -> int:
if not args:
print_json({"verified": False, "reason": "story_key_required"})
return 1
state_file = ""
tail = args[1:]
try:
idx = 0
while idx < len(tail):
if tail[idx] == "--state-file":
state_file = _flag_value(tail, idx, "--state-file")
idx += 2
continue
idx += 1
except PolicyError as exc:
print_json({"verified": False, "reason": "review_contract_invalid", "input": args[0], "error": str(exc)})
return 1
payload = verify_code_review_completion(get_project_root(), args[0], state_file=state_file or None)
print_json(payload)
return 0 if bool(payload.get("verified")) else 1
def _verify_step(args: list[str]) -> int:
if len(args) < 2:
print_json({"verified": False, "reason": "step_and_story_required"})
return 1
step, story_key = args[:2]
state_file = ""
output_file = ""
tail = args[2:]
try:
idx = 0
while idx < len(tail):
arg = tail[idx]
if arg in {"--state-file", "--output-file"}:
if idx + 1 >= len(tail) or not tail[idx + 1].strip() or tail[idx + 1].startswith("--"):
raise PolicyError(f"{arg} requires a value")
if arg == "--state-file":
state_file = tail[idx + 1]
else:
output_file = tail[idx + 1]
idx += 2
continue
idx += 1
contract = resolve_success_contract(get_project_root(), step, state_file=state_file or None)
verifier = str(contract.get("verifier") or "").strip()
if not verifier:
raise PolicyError(f"missing success verifier for {step}")
payload = run_success_verifier(
verifier,
project_root=get_project_root(),
story_key=story_key,
output_file=output_file,
contract=contract,
)
exit_code = 0
except (FileNotFoundError, PolicyError, ValueError) as exc:
payload = {"verified": False, "step": step, "input": story_key, "reason": "verifier_contract_invalid", "error": str(exc)}
exit_code = 1
print_json(payload)
return exit_code
def _parse_context_int(context: str, key: str) -> int:
match = re.search(rf"{re.escape(key)}=(\d+)", context)
return int(match.group(1)) if match else 0
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]

View File

@@ -0,0 +1,394 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from story_automator.core.frontmatter import extract_frontmatter, find_frontmatter_value, parse_frontmatter
from story_automator.core.runtime_layout import runtime_provider
from story_automator.core.sprint import sprint_status_epic
from story_automator.core.story_keys import normalize_story_key
from story_automator.core.utils import file_exists, get_project_root, iso_now, print_json, read_text, trim_lines, unquote_scalar
def check_epic_complete_action(args: list[str]) -> int:
if len(args) < 2:
print_json({"ok": False, "error": "epic_number and story_id required"})
return 1
epic, story = args[0], args[1]
state_file = ""
tail = args[2:]
for idx, arg in enumerate(tail):
if arg == "--state-file" and idx + 1 < len(tail):
state_file = tail[idx + 1]
if story.split(".", 1)[0] != epic:
print_json({"ok": True, "isLastStory": False, "epic": int(epic), "storyId": story, "reason": "story_not_in_epic"})
return 0
stories: list[str] = []
if state_file and file_exists(state_file):
story_range = parse_frontmatter(read_text(state_file)).get("storyRange", [])
stories = [sid for sid in story_range if isinstance(sid, str) and sid.startswith(f"{epic}.")]
source = "state_file"
else:
stories, _ = sprint_status_epic(get_project_root(), epic)
source = "sprint_status"
if stories:
stories = sorted(set(stories), key=lambda item: tuple(int(part) for part in item.replace("-", ".").split(".")[:2]))
last = stories[-1]
print_json({"ok": True, "isLastStory": story in {last, last.replace("-", ".")}, "epic": int(epic), "storyId": story, "lastInEpic": last, "epicStoryCount": len(stories), "source": source})
return 0
print_json({"ok": True, "isLastStory": False, "epic": int(epic), "storyId": story, "reason": "could_not_determine", "source": "fallback"})
return 0
def get_epic_stories_action(args: list[str]) -> int:
if not args:
print_json({"ok": False, "error": "epic_number_required"})
return 1
epic = args[0]
state_file = ""
tail = args[1:]
for idx, arg in enumerate(tail):
if arg == "--state-file" and idx + 1 < len(tail):
state_file = tail[idx + 1]
if state_file and file_exists(state_file):
stories = [sid for sid in parse_frontmatter(read_text(state_file)).get("storyRange", []) if isinstance(sid, str) and sid.startswith(f"{epic}.")]
if stories:
print_json({"ok": True, "epic": epic, "stories": stories, "count": len(stories), "source": "state_file"})
return 0
stories, _ = sprint_status_epic(get_project_root(), epic)
if stories:
print_json({"ok": True, "epic": epic, "stories": stories, "count": len(stories), "source": "sprint_status"})
return 0
epic_file = find_epic_file(epic)
if epic_file:
stories = sorted(set(re.findall(rf"\b{re.escape(epic)}\.\d+", read_text(epic_file))), key=lambda item: tuple(int(part) for part in item.split(".")))
if stories:
print_json({"ok": True, "epic": epic, "stories": stories, "count": len(stories), "source": "epic_file"})
return 0
print_json({"ok": False, "epic": epic, "error": "no_stories_found", "count": 0})
return 0
def check_blocking_action(args: list[str]) -> int:
if not args:
print_json({"ok": False, "error": "story_id_required"})
return 1
norm = normalize_story_key(get_project_root(), args[0])
if norm is None:
print_json({"ok": False, "error": "could_not_normalize_key", "input": args[0]})
return 1
epic = norm.id.split(".", 1)[0]
epic_file = find_epic_file(epic)
if not epic_file:
print_json({"ok": True, "blocking": True, "story": norm.id, "epic": epic, "dependents": [], "reason": "epic_file_not_found", "source": "unknown"})
return 0
dependents: list[str] = []
current_story = ""
for line in trim_lines(read_text(epic_file)):
match = re.match(r"^###\s+Story\s+(\d+\.\d+):", line)
if match:
current_story = match.group(1)
continue
if current_story and re.search(r"(?i)Dependencies:|\*\*Dependencies\*\*:", line):
if norm.id in line or norm.prefix in line:
dependents.append(current_story)
if dependents:
print_json({"ok": True, "blocking": True, "story": norm.id, "epic": epic, "dependents": sorted(set(dependents)), "reason": "dependent_stories", "source": "epic_file"})
return 0
print_json({"ok": True, "blocking": False, "story": norm.id, "epic": epic, "dependents": [], "reason": "no_dependents_found", "source": "epic_file"})
return 0
def agents_build_action(args: list[str]) -> int:
options = {"state-file": "", "complexity-file": "", "output": "", "config-json": ""}
idx = 0
while idx < len(args):
key = args[idx].lstrip("-")
if idx + 1 < len(args):
options[key] = args[idx + 1]
idx += 2
else:
idx += 1
if not all(options.values()) or not file_exists(options["state-file"]) or not file_exists(options["complexity-file"]):
print_json({"ok": False, "error": "missing_args" if not all(options.values()) else "file_not_found"})
return 1
config = parse_agent_config(options["config-json"])
complexity = json.loads(read_text(options["complexity-file"]))
state_fields = parse_frontmatter(read_text(options["state-file"]))
stories = []
for story in complexity.get("stories", []):
level = str(story.get("complexity", {}).get("level", "medium")).lower() or "medium"
tasks = {}
for task in ("create", "dev", "auto", "review"):
primary, fallback = resolve_agent(config, level, task)
tasks[task] = {"primary": primary, "fallback": False if fallback == "false" else fallback}
stories.append({"storyId": story["storyId"], "title": story.get("title", ""), "complexity": level, "tasks": tasks})
payload = {"version": "1.0.0", "stateFile": options["state-file"], "epic": state_fields.get("epic", ""), "epicName": state_fields.get("epicName", ""), "createdAt": iso_now(), "stories": stories}
header = f'---\nstateFile: "{payload["stateFile"]}"\ncreatedAt: "{payload["createdAt"]}"\n---\n\n# Agents Plan: {payload["epicName"]}\n\n'
content = header + "```json\n" + json.dumps(payload, indent=2) + "\n```\n"
Path(options["output"]).parent.mkdir(parents=True, exist_ok=True)
Path(options["output"]).write_text(content, encoding="utf-8")
print_json({"ok": True, "path": options["output"], "stories": len(stories)})
return 0
def agents_resolve_action(args: list[str]) -> int:
options = {"state-file": "", "agents-file": "", "story": "", "task": ""}
idx = 0
while idx < len(args):
key = args[idx].lstrip("-")
if idx + 1 < len(args):
options[key] = args[idx + 1]
idx += 2
else:
idx += 1
if not options["story"] or not options["task"] or (not options["state-file"] and not options["agents-file"]):
print_json({"ok": False, "error": "missing_args"})
return 1
agents_path = options["agents-file"] or find_frontmatter_value(options["state-file"], "agentsFile")
if not agents_path or not file_exists(agents_path):
print_json({"ok": False, "error": "agents_file_not_found"})
return 1
text = read_text(agents_path)
match = re.search(r"(?s)```json\s*(\{.*?\})\s*```", text)
block = match.group(1) if match else text.strip()
payload = json.loads(block)
for story in payload.get("stories", []):
if story.get("storyId") != options["story"]:
continue
selection = story.get("tasks", {}).get(options["task"])
if selection is None:
print_json({"ok": False, "error": "task_not_found"})
return 1
fallback = selection.get("fallback", "")
fallback = "false" if fallback in {False, "false", "none", "null"} else fallback
print_json({"ok": True, "story": options["story"], "task": options["task"], "primary": selection.get("primary", ""), "fallback": fallback, "complexity": story.get("complexity", "")})
return 0
print_json({"ok": False, "error": "story_not_found"})
return 1
def retro_agent_action(args: list[str]) -> int:
options = {"state-file": ""}
idx = 0
while idx < len(args):
key = args[idx].lstrip("-")
if idx + 1 < len(args):
options[key] = args[idx + 1]
idx += 2
else:
idx += 1
if not options["state-file"]:
print_json({"ok": False, "error": "missing_args"})
return 1
if not file_exists(options["state-file"]):
print_json({"ok": False, "error": "file_not_found"})
return 1
config = _load_agent_config_from_state(options["state-file"])
primary, fallback = resolve_agent(config, "medium", "retro")
print_json({"ok": True, "task": "retro", "primary": primary, "fallback": fallback})
return 0
def find_epic_file(epic: str) -> str:
root = Path(get_project_root())
for pattern in (f"_bmad-output/implementation-artifacts/epic-{epic}-*.md", f"docs/epics/epic-{epic}-*.md"):
matches = sorted(root.glob(pattern))
if matches:
return str(matches[0])
return ""
def parse_agent_config(raw: str) -> dict:
data = json.loads(raw)
per_task = data.get("perTask", {})
if not isinstance(per_task, dict):
per_task = {}
retro = data.get("retro")
if isinstance(retro, dict) and "retro" not in per_task:
per_task = {**per_task, "retro": retro}
complexity_overrides = data.get("complexityOverrides")
if not isinstance(complexity_overrides, dict):
complexity_overrides = {level: data[level] for level in ("low", "medium", "high") if isinstance(data.get(level), dict)}
if "defaultFallback" in data:
fallback_raw = data.get("defaultFallback")
elif "fallback" in data:
fallback_raw = data.get("fallback")
else:
fallback_raw = False
return {
"defaultPrimary": data.get("defaultPrimary") or data.get("primary") or "auto",
"defaultFallback": "false" if fallback_raw in {False, "false", "none", "null"} else (fallback_raw or "false"),
"perTask": per_task,
"complexityOverrides": complexity_overrides,
}
def resolve_agent(config: dict, level: str, task: str) -> tuple[str, str]:
primary = config["defaultPrimary"]
fallback = config["defaultFallback"]
if task in config["perTask"]:
entry = config["perTask"][task]
if isinstance(entry, dict):
primary = entry.get("primary", primary)
if "fallback" in entry:
fallback = "false" if entry["fallback"] in {False, "false", "none", "null"} else entry["fallback"]
level_map = config["complexityOverrides"].get(level, {})
if not isinstance(level_map, dict):
level_map = {}
if task in level_map:
entry = level_map[task]
if isinstance(entry, dict):
primary = entry.get("primary", primary)
if "fallback" in entry:
fallback = "false" if entry["fallback"] in {False, "false", "none", "null"} else entry["fallback"]
return (_resolve_primary_agent(primary), _resolve_fallback_agent(fallback))
def _resolve_primary_agent(raw: object) -> str:
value = str(raw or "").strip().lower()
if value in {"", "auto", "runtime"}:
return runtime_provider()
return value
def _resolve_fallback_agent(raw: object) -> str:
value = "false" if raw is False else str(raw or "")
normalized = value.strip().lower()
if normalized in {"", "auto", "runtime", "false", "none", "null"}:
return "false"
return normalized
def _load_agent_config_from_state(state_file: str) -> dict:
text = extract_frontmatter(read_text(state_file))
if not text:
return parse_agent_config("{}")
config: dict[str, object] = {}
in_agent_config = False
in_per_task = False
in_complexity_overrides = False
current_task = ""
current_level = ""
for raw_line in text.splitlines():
if not in_agent_config:
if raw_line.strip() == "agentConfig:":
in_agent_config = True
continue
if raw_line and not raw_line.startswith(" "):
break
stripped = raw_line.strip()
if not stripped or stripped.startswith("#"):
continue
indent = len(raw_line) - len(raw_line.lstrip(" "))
if indent == 2:
current_task = ""
current_level = ""
if stripped == "perTask:":
in_per_task = True
in_complexity_overrides = False
continue
if stripped == "complexityOverrides:":
in_complexity_overrides = True
in_per_task = False
continue
in_per_task = False
in_complexity_overrides = False
if stripped == "retro:":
config.setdefault("retro", {})
current_task = "retro"
continue
if ":" in stripped:
key, raw = stripped.split(":", 1)
config[key] = _parse_scalar(raw)
continue
if indent == 4 and in_per_task and stripped.endswith(":"):
current_task = stripped[:-1]
per_task = config.setdefault("perTask", {})
if isinstance(per_task, dict):
per_task.setdefault(current_task, {})
continue
if indent == 4 and in_complexity_overrides and stripped.endswith(":"):
current_level = stripped[:-1]
current_task = ""
overrides = config.setdefault("complexityOverrides", {})
if isinstance(overrides, dict):
overrides.setdefault(current_level, {})
continue
if indent == 4 and current_task == "retro" and ":" in stripped:
key, raw = stripped.split(":", 1)
retro = config.setdefault("retro", {})
if isinstance(retro, dict):
retro[key.strip()] = _parse_scalar(raw.strip())
continue
if indent == 6 and in_per_task and current_task and ":" in stripped:
key, raw = stripped.split(":", 1)
per_task = config.setdefault("perTask", {})
if isinstance(per_task, dict):
task_cfg = per_task.setdefault(current_task, {})
if isinstance(task_cfg, dict):
task_cfg[key.strip()] = _parse_scalar(raw.strip())
continue
if indent == 6 and in_complexity_overrides and current_level and stripped.endswith(":"):
current_task = stripped[:-1]
overrides = config.setdefault("complexityOverrides", {})
if isinstance(overrides, dict):
level_cfg = overrides.setdefault(current_level, {})
if isinstance(level_cfg, dict):
level_cfg.setdefault(current_task, {})
continue
if indent == 8 and in_complexity_overrides and current_level and current_task and ":" in stripped:
key, raw = stripped.split(":", 1)
overrides = config.setdefault("complexityOverrides", {})
if isinstance(overrides, dict):
level_cfg = overrides.setdefault(current_level, {})
if isinstance(level_cfg, dict):
task_cfg = level_cfg.setdefault(current_task, {})
if isinstance(task_cfg, dict):
task_cfg[key.strip()] = _parse_scalar(raw.strip())
return parse_agent_config(json.dumps(config))
def _parse_scalar(raw: str) -> object:
value = unquote_scalar(_strip_inline_yaml_comment(raw))
lower = value.lower()
if lower == "false":
return False
if lower == "true":
return True
return value
def _strip_inline_yaml_comment(raw: str) -> str:
text = raw.strip()
in_quote = ""
escaped = False
for idx, char in enumerate(text):
if escaped:
escaped = False
continue
if char == "\\" and in_quote == '"':
escaped = True
continue
if char in {'"', "'"}:
if in_quote == char:
in_quote = ""
elif not in_quote:
in_quote = char
continue
if char == "#" and not in_quote and (idx == 0 or text[idx - 1].isspace()):
return text[:idx].rstrip()
return text

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import json
from typing import Any
from story_automator.core.runtime_policy import PolicyError, load_runtime_policy, parser_runtime_config, step_contract
from story_automator.core.utils import COMMAND_TIMEOUT_EXIT, extract_json_line, print_json, read_text, run_cmd, trim_lines
def parse_output_action(args: list[str]) -> int:
if len(args) < 2:
print('{"status":"error","reason":"output file not found or empty"}')
return 1
output_file, step = args[:2]
state_file = ""
idx = 2
while idx < len(args):
if args[idx] == "--state-file":
if idx + 1 >= len(args) or not args[idx + 1].strip() or args[idx + 1].startswith("--"):
print_json({"status": "error", "reason": "parse_contract_invalid"})
return 1
state_file = args[idx + 1]
idx += 2
continue
idx += 1
try:
content = read_text(output_file)
except FileNotFoundError:
print('{"status":"error","reason":"output file not found or empty"}')
return 1
if not content.strip():
print('{"status":"error","reason":"output file not found or empty"}')
return 1
lines = trim_lines(content)[:150]
try:
policy = load_runtime_policy(state_file=state_file)
contract = step_contract(policy, step)
parse_contract = _load_parse_contract(contract)
parser_cfg = parser_runtime_config(policy)
except (FileNotFoundError, json.JSONDecodeError, ValueError, PolicyError):
print_json({"status": "error", "reason": "parse_contract_invalid"})
return 1
prompt = _build_parse_prompt(contract, parse_contract, "\n".join(lines))
result = run_cmd(
str(parser_cfg["provider"]),
"-p",
"--model",
str(parser_cfg["model"]),
prompt,
env={"STORY_AUTOMATOR_CHILD": "true", "CLAUDECODE": ""},
timeout=int(parser_cfg["timeoutSeconds"]),
)
if result.exit_code != 0:
reason = "sub-agent call timed out" if result.exit_code == COMMAND_TIMEOUT_EXIT else "sub-agent call failed"
print_json({"status": "error", "reason": reason})
return 1
json_line = extract_json_line(result.output)
if not json_line:
print_json({"status": "error", "reason": "sub-agent returned invalid json"})
return 1
try:
payload = json.loads(json_line)
except json.JSONDecodeError:
print_json({"status": "error", "reason": "sub-agent returned invalid json"})
return 1
if not _has_required_keys(payload, parse_contract.get("requiredKeys") or []):
print_json({"status": "error", "reason": "sub-agent returned invalid json"})
return 1
if not _matches_schema(payload, parse_contract.get("schema") or {}):
print_json({"status": "error", "reason": "sub-agent returned invalid json"})
return 1
print(json.dumps(payload, separators=(",", ":")))
return 0
def _load_parse_contract(contract: dict[str, object]) -> dict[str, object]:
parse = contract.get("parse") or {}
payload = json.loads(read_text(str(parse.get("schemaPath") or "")))
if not isinstance(payload, dict):
raise ValueError("invalid parse schema")
required_keys = payload.get("requiredKeys")
if not isinstance(required_keys, list):
raise ValueError("invalid parse schema")
if any(not isinstance(key, str) or not key.strip() for key in required_keys):
raise ValueError("invalid parse schema")
if not isinstance(payload.get("schema"), dict):
raise ValueError("invalid parse schema")
return payload
def _build_parse_prompt(contract: dict[str, object], parse_contract: dict[str, object], content: str) -> str:
label = str(contract.get("label") or "session")
schema = json.dumps(parse_contract.get("schema") or {}, separators=(",", ":"))
return f"Analyze this {label} session output. Return JSON only:\n{schema}\n\nSession output:\n---\n{content}\n---"
def _has_required_keys(payload: object, required_keys: list[Any]) -> bool:
if not isinstance(payload, dict):
return False
return all(isinstance(key, str) and key in payload for key in required_keys)
def _matches_schema(payload: object, schema: object) -> bool:
if isinstance(schema, dict):
if not isinstance(payload, dict):
return False
for key, child_schema in schema.items():
if key not in payload or not _matches_schema(payload[key], child_schema):
return False
return True
if not isinstance(schema, str):
return False
rule = schema.strip()
if rule == "integer":
return isinstance(payload, int) and not isinstance(payload, bool)
if rule == "true|false":
return isinstance(payload, bool)
if rule == "path or null":
return payload is None or (isinstance(payload, str) and bool(payload.strip()))
if "|" in rule and " " not in rule:
return isinstance(payload, str) and payload in rule.split("|")
return isinstance(payload, str) and bool(payload.strip())

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter
from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy
from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json
def cmd_build_state_doc(args: list[str]) -> int:
template = ""
output_folder = ""
config_file = ""
config_json = ""
for idx, arg in enumerate(args):
if arg == "--template" and idx + 1 < len(args):
template = args[idx + 1]
elif arg == "--output-folder" and idx + 1 < len(args):
output_folder = args[idx + 1]
elif arg == "--config-file" and idx + 1 < len(args):
config_file = args[idx + 1]
elif arg == "--config-json" and idx + 1 < len(args):
config_json = args[idx + 1]
if not template or not file_exists(template) or not output_folder:
write_json({"ok": False, "error": "missing_template_or_output"})
return 1
if config_file and file_exists(config_file):
config_json = read_text(config_file)
if not config_json.strip():
write_json({"ok": False, "error": "missing_config"})
return 1
try:
config = json.loads(config_json)
except json.JSONDecodeError:
write_json({"ok": False, "error": "missing_config"})
return 1
ensure_dir(output_folder)
now = now_utc_z()
stamp = now_utc().strftime("%Y%m%d-%H%M%S")
epic = str(config.get("epic") or "epic")
safe_epic = re.sub(r"[^a-zA-Z0-9]+", "-", epic).strip("-") or "epic"
output_path = Path(output_folder) / f"orchestration-{safe_epic}-{stamp}.md"
try:
snapshot = snapshot_effective_policy(get_project_root())
except (FileNotFoundError, PolicyError, ValueError) as exc:
write_json({"ok": False, "error": "policy_snapshot_failed", "reason": str(exc)})
return 1
text = read_text(template)
replacements: dict[str, Any] = {
"epic": config.get("epic", ""),
"epicName": config.get("epicName", ""),
"storyRange": config.get("storyRange", []),
"status": config.get("status", "READY"),
"currentStory": config.get("currentStory"),
"currentStep": config.get("currentStep"),
"stepsCompleted": config.get("stepsCompleted", []),
"lastUpdated": now,
"createdAt": now,
"aiCommand": config.get("aiCommand", ""),
"agentsFile": config.get("agentsFile", ""),
"complexityFile": config.get("complexityFile", ""),
"policyVersion": snapshot["policyVersion"],
"policySnapshotFile": snapshot["policySnapshotFile"],
"policySnapshotHash": snapshot["policySnapshotHash"],
"legacyPolicy": False,
}
overrides = config.get("overrides", {}) if isinstance(config.get("overrides"), dict) else {}
text = re.sub(
r"(?m)^overrides:\n(?:(?:\s{2}.*\n)*)",
"overrides:\n"
f" skipAutomate: {str(bool(overrides.get('skipAutomate', False))).lower()}\n"
f" maxParallel: {int(overrides.get('maxParallel', 1) or 1)}\n",
text,
)
custom_instructions = json.dumps(config.get("customInstructions", ""))
text = re.sub(r"(?m)^customInstructions:.*$", lambda m: f"customInstructions: {custom_instructions}", text)
agent_config = config.get("agentConfig")
if isinstance(agent_config, dict):
per_task = agent_config.get("perTask", {})
if not isinstance(per_task, dict):
per_task = {}
legacy_retro = agent_config.get("retro")
if isinstance(legacy_retro, dict) and "retro" not in per_task:
per_task = {**per_task, "retro": legacy_retro}
default_fallback = agent_config.get("defaultFallback")
if "defaultFallback" not in agent_config:
default_fallback = agent_config.get("fallback", False)
if default_fallback is None:
default_fallback = False
default_primary = agent_config.get("defaultPrimary")
if default_primary is None:
default_primary = agent_config.get("primary") or "auto"
lines = [
"agentConfig:",
f" defaultPrimary: {json.dumps(default_primary)}",
f" defaultFallback: {json.dumps(default_fallback)}",
]
if isinstance(per_task, dict) and per_task:
lines.append(" perTask:")
for task in sorted(per_task):
entry = per_task[task]
if not isinstance(entry, dict):
continue
lines.append(f" {task}:")
if "primary" in entry:
lines.append(f" primary: {json.dumps(entry['primary'])}")
if "fallback" in entry:
value = entry["fallback"]
lines.append(f" fallback: {'false' if value is False else json.dumps(value)}")
complexity_overrides = agent_config.get("complexityOverrides", {})
if isinstance(complexity_overrides, dict) and complexity_overrides:
lines.append(" complexityOverrides:")
for level in sorted(complexity_overrides):
task_map = complexity_overrides[level]
if not isinstance(task_map, dict) or not task_map:
continue
lines.append(f" {level}:")
for task in sorted(task_map):
entry = task_map[task]
if not isinstance(entry, dict):
continue
lines.append(f" {task}:")
if "primary" in entry:
lines.append(f" primary: {json.dumps(entry['primary'])}")
if "fallback" in entry:
value = entry["fallback"]
lines.append(f" fallback: {'false' if value is False else json.dumps(value)}")
block = "\n".join(lines) + "\n"
text = re.sub(r"(?m)^agentConfig:\n(?:(?:\s{2}.*\n)*)", block, text)
for key, value in replacements.items():
text = re.sub(rf"(?m)^{re.escape(key)}:.*$", lambda m, k=key, v=value: f"{k}: {json.dumps(v)}", text)
story_range = [item for item in config.get("storyRange", []) if isinstance(item, str)]
progress_rows = "\n".join(f"| {story_id} | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | pending |" for story_id in story_range)
body = {
"{{epicName}}": str(config.get("epicName", "")),
"{{epic}}": str(config.get("epic", "")),
"{{storyRange}}": ", ".join(story_range),
"{{createdAt}}": now,
"{{overrides.skipAutomate}}": str(bool(overrides.get("skipAutomate", False))).lower(),
"{{overrides.maxParallel}}": str(int(overrides.get("maxParallel", 1) or 1)),
"{{customInstructions}}": str(config.get("customInstructions", "")),
}
for key, value in body.items():
text = text.replace(key, value)
text = text.replace("<!-- Progress rows will be appended here -->", progress_rows)
output_path.write_text(text)
write_json({"ok": True, "path": str(output_path), "createdAt": now})
return 0
def cmd_sprint_compare(args: list[str]) -> int:
state = ""
sprint = ""
for idx, arg in enumerate(args):
if arg == "--state" and idx + 1 < len(args):
state = args[idx + 1]
elif arg == "--sprint" and idx + 1 < len(args):
sprint = args[idx + 1]
if not state or not file_exists(state):
write_json({"ok": False, "error": "state_not_found"})
return 1
if not sprint or not file_exists(sprint):
write_json({"ok": False, "error": "sprint_not_found"})
return 1
fields = parse_simple_frontmatter(read_text(state))
story_range = fields.get("storyRange", []) if isinstance(fields.get("storyRange"), list) else []
current_story = fields.get("currentStory")
before = list(story_range)
if isinstance(current_story, str) and current_story in story_range:
before = story_range[: story_range.index(current_story)]
sprint_text = read_text(sprint)
incomplete = []
for story_id in before:
match = re.search(rf"(?m)^\s*{re.escape(story_id)}:\s*(\S+)", sprint_text)
if not match or match.group(1) != "done":
incomplete.append(story_id)
write_json({"ok": True, "incomplete": incomplete, "checked": before})
return 0
def cmd_state_metrics(args: list[str]) -> int:
state = ""
for idx, arg in enumerate(args):
if arg == "--state" and idx + 1 < len(args):
state = args[idx + 1]
if not state or not file_exists(state):
write_json({"ok": False, "error": "state_not_found"})
return 1
total = 0
completed = 0
in_table = False
for line in read_text(state).splitlines():
if line.startswith("| Story "):
in_table = True
continue
if in_table and re.match(r"^\|[- ]*\|", line):
continue
if in_table and line.startswith("|"):
parts = [part.strip() for part in line.split("|")]
if len(parts) >= 8 and parts[1]:
total += 1
if any(token in parts[7].lower() for token in ("done", "complete", "completed")):
completed += 1
continue
if in_table and not line.startswith("|"):
in_table = False
print(
json.dumps(
{
"ok": True,
"storiesCompleted": completed,
"total": total,
"reviewCycles": count_matches(read_text(state), r"review cycle|code review cycle"),
"escalations": count_matches(read_text(state), r"escalation|escalated"),
},
separators=(",", ":"),
)
)
return 0
def cmd_validate_state(args: list[str]) -> int:
if args and args[0] in {"--help", "-h"}:
print("Usage: validate-state --state PATH")
return 0
state = ""
for idx, arg in enumerate(args):
if arg == "--state" and idx + 1 < len(args):
state = args[idx + 1]
if not state or not file_exists(state):
write_json({"ok": False, "error": "state_not_found"})
return 1
text = read_text(state)
frontmatter = extract_frontmatter(text)
fields = parse_simple_frontmatter(text)
issues: list[str] = []
def required(key: str, validator: Any = None) -> None:
value = fields.get(key)
if value in ("", [], None):
issues.append(f"Missing or empty {key}")
return
if validator and not validator(value):
issues.append(f"Invalid {key}")
allowed = {"INITIALIZING", "READY", "IN_PROGRESS", "PAUSED", "EXECUTION_COMPLETE", "COMPLETE", "ABORTED"}
required("epic")
required("epicName")
required("storyRange")
required("status", lambda value: isinstance(value, str) and value in allowed)
required("lastUpdated", lambda value: isinstance(value, str) and re.search(r"\d{4}-\d{2}-\d{2}T", value))
if not _has_runtime_command_config(fields, frontmatter):
issues.append("Missing or empty aiCommand")
try:
load_policy_for_state(state)
except PolicyError as exc:
issues.append(str(exc))
write_json({"ok": True, "structure": "issues" if issues else "ok", "issues": issues})
return 0
def _has_runtime_command_config(fields: dict[str, Any], frontmatter: str) -> bool:
ai_command = fields.get("aiCommand")
if ai_command not in ("", [], None):
return True
return _has_agent_config_block(frontmatter)
def _has_agent_config_block(frontmatter: str) -> bool:
in_agent_config = False
for raw_line in frontmatter.splitlines():
stripped = raw_line.strip()
if not in_agent_config:
if re.match(r"^agentConfig:\s*(?:#.*)?$", stripped):
in_agent_config = True
continue
if raw_line and not raw_line.startswith(" "):
break
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, raw = stripped.split(":", 1)
if key.strip() in {"defaultPrimary", "defaultFallback", "perTask", "complexityOverrides", "retro"}:
if key.strip() in {"perTask", "complexityOverrides", "retro"} or raw.strip():
return True
return False

View File

@@ -0,0 +1,489 @@
from __future__ import annotations
import os
import shlex
import time
from pathlib import Path
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.success_verifiers import resolve_success_contract, run_success_verifier
from story_automator.core.tmux_runtime import (
agent_cli,
agent_type,
generate_session_name,
heartbeat_check,
runtime_mode,
session_status,
skill_prefix,
spawn_session,
tmux_has_session,
tmux_kill_session,
tmux_list_sessions,
)
from story_automator.core.utils import (
get_project_root,
print_json,
project_hash,
project_slug,
read_text,
)
def cmd_tmux_wrapper(args: list[str]) -> int:
if not args:
return _usage(1)
if args[0] in {"--help", "-h"}:
return _usage(0)
action = args[0]
if action == "spawn":
return _spawn(args[1:])
if action == "name":
if len(args) < 4:
return _usage(1)
cycle = args[4] if len(args) > 4 else ""
print(generate_session_name(args[1], args[2], args[3], cycle))
return 0
if action == "list":
sessions, _ = tmux_list_sessions("--project-only" in args[1:])
print("\n".join(sessions))
return 0
if action == "kill":
if len(args) < 2:
return _usage(1)
tmux_kill_session(args[1])
return 0
if action == "kill-all":
sessions, _ = tmux_list_sessions("--project-only" in args[1:])
for session in sessions:
tmux_kill_session(session)
print(f"Killed {len(sessions)} sessions")
return 0
if action == "exists":
if len(args) < 2:
return _usage(1)
if tmux_has_session(args[1]):
print("true")
return 0
print("false")
return 1
if action == "build-cmd":
return _build_cmd(args[1:])
if action == "project-slug":
print(project_slug())
return 0
if action == "project-hash":
print(project_hash())
return 0
if action == "story-suffix":
if len(args) < 2:
return _usage(1)
print(args[1].replace(".", "-"))
return 0
if action == "agent-type":
print(agent_type())
return 0
if action == "agent-cli":
print(agent_cli(agent_type()))
return 0
if action == "skill-prefix":
print(skill_prefix(agent_type()))
return 0
return _usage(1)
def _usage(code: int) -> int:
target = __import__("sys").stderr if code else __import__("sys").stdout
print("Usage: tmux-wrapper <action> [args...]", file=target)
print("", file=target)
print("Actions:", file=target)
print(' spawn <step> <epic> <story_id> --command "..." [--cycle N] [--agent TYPE]', file=target)
print(" name <step> <epic> <story_id> [--cycle N]", file=target)
print(" list [--project-only]", file=target)
print(" kill <session_name>", file=target)
print(" kill-all [--project-only]", file=target)
print(" exists <session_name>", file=target)
print(" build-cmd <step> <story_id> [--agent TYPE] [--state-file PATH] [extra_instruction]", file=target)
print(" project-slug", file=target)
print(" project-hash", file=target)
print(" story-suffix <story_id>", file=target)
print(" agent-type", file=target)
print(" agent-cli", file=target)
print(" skill-prefix", file=target)
return code
def _spawn(args: list[str]) -> int:
if args and args[0] in {"--help", "-h"}:
return _usage(0)
if len(args) < 3:
return _usage(1)
step, epic, story_id = args[:3]
command = ""
cycle = ""
agent = _raw_agent_selection()
tail = args[3:]
for idx, arg in enumerate(tail):
if arg == "--command" and idx + 1 < len(tail):
command = tail[idx + 1]
elif arg == "--cycle" and idx + 1 < len(tail):
cycle = tail[idx + 1]
elif arg == "--agent" and idx + 1 < len(tail):
agent = tail[idx + 1]
root = get_project_root()
agent = _resolve_agent_selection(agent, root)
if not command:
print("--command is required", file=__import__("sys").stderr)
return 1
session = generate_session_name(step, epic, story_id, cycle)
out, code = spawn_session(session, command, agent, root, mode=runtime_mode())
if code != 0:
print(out.strip(), file=__import__("sys").stderr)
return 1
print(session)
return 0
def _build_cmd(args: list[str]) -> int:
if args and args[0] in {"--help", "-h"}:
return _usage(0)
if len(args) < 2:
return _usage(1)
step, story_id = args[:2]
agent = ""
extra = ""
tail = args[2:]
idx = 0
state_file = ""
try:
while idx < len(tail):
if tail[idx] == "--agent":
agent = _flag_value(tail, idx, "--agent")
idx += 2
continue
if tail[idx] == "--state-file":
state_file = _flag_value(tail, idx, "--state-file")
idx += 2
continue
extra = f"{extra} {tail[idx]}".strip()
idx += 1
except PolicyError as exc:
print(str(exc), file=__import__("sys").stderr)
return 1
agent = agent or _raw_agent_selection()
story_prefix = story_id.replace(".", "-")
root = get_project_root()
agent = _resolve_agent_selection(agent, root)
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)
except (OSError, PolicyError) as exc:
print(str(exc), file=__import__("sys").stderr)
return 1
ai_command = os.environ.get("AI_COMMAND", "").strip()
if ai_command and not os.environ.get("AI_AGENT"):
cli = ai_command
elif agent != "codex":
cli = agent_cli(agent)
else:
cli = "codex exec"
quoted_prompt = shlex.quote(prompt)
if agent == "codex" and not ai_command:
codex_home = f"/tmp/sa-codex-home-{project_hash(root)}"
auth_src = os.path.expanduser("~/.codex/auth.json")
print(
f'mkdir -p "{codex_home}"'
+ f' && if [ -f "{auth_src}" ]; then ln -sf "{auth_src}" "{codex_home}/auth.json"; fi'
+ f' && CODEX_HOME="{codex_home}" codex exec -s workspace-write -c \'approval_policy="never"\''
+ f' -c \'model_reasoning_effort="high"\''
+ f" --disable plugins --disable sqlite --disable shell_snapshot {quoted_prompt}"
)
else:
print(f"unset CLAUDECODE && {cli} {quoted_prompt}")
return 0
def _render_step_prompt(contract: dict[str, object], story_id: str, story_prefix: str, extra_instruction: str) -> str:
prompt_cfg = contract.get("prompt") or {}
assets = (contract.get("assets") or {}).get("files") or {}
template = read_text(str(prompt_cfg.get("templatePath") or ""))
replacements = {
"{{story_id}}": story_id,
"{{story_prefix}}": story_prefix,
"{{label}}": str(contract.get("label") or ""),
"{{skill_line}}": _prompt_line("READ this skill first", str(assets.get("skill") or "")),
"{{workflow_line}}": _prompt_line("READ this workflow file next", str(assets.get("workflow") or "")),
"{{instructions_line}}": _prompt_line("Then read", str(assets.get("instructions") or "")),
"{{checklist_line}}": _prompt_line("Validate with", str(assets.get("checklist") or "")),
"{{template_line}}": _prompt_line("Use template", str(assets.get("template") or "")),
"{{extra_instruction}}": extra_instruction.strip() or str(prompt_cfg.get("defaultExtraInstruction") or ""),
}
for key, value in replacements.items():
template = template.replace(key, value)
return template
def _prompt_line(prefix: str, value: str) -> str:
return f"{prefix}: {value}\n" if value else ""
def cmd_heartbeat_check(args: list[str]) -> int:
if not args:
print("error,0.0,,no_session")
return 0
session = args[0]
agent = "auto"
tail = args[1:]
for idx, arg in enumerate(tail):
if arg == "--agent" and idx + 1 < len(tail):
agent = tail[idx + 1]
status, cpu, pid, prompt = heartbeat_check(session, agent, project_root=get_project_root(), mode=runtime_mode())
print(f"{status},{cpu:.1f},{pid},{prompt}")
return 0
def cmd_codex_status_check(args: list[str]) -> int:
return _status_check(args, codex=True)
def cmd_tmux_status_check(args: list[str]) -> int:
return _status_check(args, codex=False)
def _status_check(args: list[str], codex: bool) -> int:
if not args:
print("error,0,0,no_session,30,error")
return 0 if codex else 1
session = args[0]
full = "--full" in args[1:]
project_root: str | None = None
tail = args[1:]
idx = 0
while idx < len(tail):
if tail[idx] == "--project-root" and idx + 1 < len(tail):
project_root = tail[idx + 1]
idx += 2
continue
idx += 1
status = session_status(session, full=full, codex=codex, project_root=project_root, mode=runtime_mode())
print(",".join([status["status"], str(status["todos_done"]), str(status["todos_total"]), status["active_task"], str(status["wait_estimate"]), status["session_state"]]))
return 0 if codex else (0 if status["status"] != "error" else 1)
def cmd_monitor_session(args: list[str]) -> int:
if not args:
print("Usage: monitor-session <session_name> [options]", file=__import__("sys").stderr)
return 1
if args[0] in {"--help", "-h"}:
print("Usage: monitor-session <session_name> [options]")
print("Options: --max-polls N --initial-wait N --project-root PATH --timeout MIN --verbose --json --agent TYPE --workflow TYPE --story-key KEY --state-file PATH")
return 0
session = args[0]
max_polls = 30
initial_wait = 5
timeout_minutes = 60
json_output = False
workflow = "dev"
story_key = ""
state_file = ""
project_root = get_project_root()
agent = _raw_agent_selection()
idx = 1
while idx < len(args):
arg = args[idx]
if arg == "--max-polls" and idx + 1 < len(args):
max_polls = int(args[idx + 1])
idx += 2
continue
if arg == "--initial-wait" and idx + 1 < len(args):
initial_wait = int(args[idx + 1])
idx += 2
continue
if arg == "--timeout" and idx + 1 < len(args):
timeout_minutes = int(args[idx + 1])
idx += 2
continue
if arg == "--json":
json_output = True
elif arg == "--agent" and idx + 1 < len(args):
agent = args[idx + 1]
idx += 2
continue
elif arg == "--workflow" and idx + 1 < len(args):
workflow = args[idx + 1]
idx += 2
continue
elif arg == "--story-key" and idx + 1 < len(args):
story_key = args[idx + 1]
idx += 2
continue
elif arg == "--state-file":
try:
state_file = _flag_value(args, idx, "--state-file")
except PolicyError as exc:
print(str(exc), file=__import__("sys").stderr)
return 1
idx += 2
continue
elif arg == "--project-root" and idx + 1 < len(args):
project_root = args[idx + 1]
idx += 2
continue
idx += 1
agent = _resolve_agent_selection(agent, project_root)
if agent == "codex":
timeout_minutes = timeout_minutes * 3 // 2
time.sleep(max(0, initial_wait))
start = time.time()
last_done = 0
last_total = 0
for _poll in range(1, max_polls + 1):
if time.time() - start >= timeout_minutes * 60:
return _emit_monitor(json_output, "timeout", last_done, last_total, "", f"exceeded_{timeout_minutes}m")
status = session_status(session, full=False, codex=agent == "codex", project_root=project_root, mode=runtime_mode())
if int(status["todos_done"]) or int(status["todos_total"]):
last_done = int(status["todos_done"])
last_total = int(status["todos_total"])
state = str(status["session_state"])
if state == "completed":
output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"]
verification = _verify_monitor_completion(
workflow,
project_root=project_root,
story_key=story_key,
output_file=str(output),
state_file=state_file or None,
)
if verification is not None:
verified, verifier_name = verification
if bool(verified.get("verified")):
reason = "normal_completion" if verifier_name == "session_exit" else "verified_complete"
return _emit_monitor(
json_output,
"completed",
last_done,
last_total,
str(output),
reason,
output_verified=bool(verified.get("verified")),
)
return _emit_monitor(
json_output,
"incomplete",
last_done,
last_total,
str(output),
str(verified.get("reason") or "workflow_not_verified"),
output_verified=bool(verified.get("verified")),
)
return _emit_monitor(json_output, "completed", last_done, last_total, str(output), "normal_completion")
if state == "crashed":
crashed = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())
return _emit_monitor(
json_output,
"crashed",
last_done,
last_total,
str(crashed["active_task"]),
f"exit_code_{int(crashed['wait_estimate'])}",
)
if state == "stuck":
output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"]
return _emit_monitor(json_output, "stuck", 0, 0, str(output), "never_active")
if state == "not_found":
return _emit_monitor(json_output, "not_found", last_done, last_total, "", "session_gone")
time.sleep(min(180 if agent == "codex" else 120, max(5, int(status["wait_estimate"]))))
output = session_status(session, full=True, codex=agent == "codex", project_root=project_root, mode=runtime_mode())["active_task"]
return _emit_monitor(json_output, "timeout", last_done, last_total, str(output), "max_polls_exceeded")
def _emit_monitor(
json_output: bool,
state: str,
done: int,
total: int,
output_file: str,
reason: str,
*,
output_verified: bool | None = None,
) -> int:
if json_output:
print_json(
{
"final_state": state,
"todos_done": done,
"todos_total": total,
"output_file": output_file,
"exit_reason": reason,
"output_verified": False if output_verified is None else output_verified,
}
)
else:
print(f"{state},{done},{total},{output_file},{reason}")
return 0
def _verify_monitor_completion(
workflow: str,
*,
project_root: str,
story_key: str,
output_file: str,
state_file: str | Path | None = None,
) -> tuple[dict[str, object], str] | None:
try:
contract = resolve_success_contract(project_root, workflow, state_file=state_file)
except (FileNotFoundError, PolicyError):
return ({"verified": False, "reason": "verifier_contract_invalid"}, "")
verifier_name = str(contract.get("verifier") or "").strip()
if not verifier_name:
return ({"verified": False, "reason": "verifier_contract_invalid"}, "")
if verifier_name in {"create_story_artifact", "review_completion", "epic_complete"} and not story_key.strip():
return ({"verified": False, "reason": "story_key_required", "verifier": verifier_name}, verifier_name)
try:
result = run_success_verifier(
verifier_name,
project_root=project_root,
story_key=story_key,
output_file=output_file,
contract=contract,
)
except (FileNotFoundError, IsADirectoryError, NotADirectoryError, PolicyError):
return ({"verified": False, "reason": "verifier_contract_invalid"}, verifier_name)
return (result, verifier_name)
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 _raw_agent_selection() -> str:
value = os.environ.get("AI_AGENT", "").strip().lower()
if not value:
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"
def _resolve_agent_selection(agent: str, project_root: str) -> str:
value = str(agent or "").strip().lower()
if value in {"", "auto", "runtime"}:
return runtime_provider(project_root)
return value
def _infer_agent_from_command(command: str) -> str:
value = command.strip()
if not value:
return ""
try:
executable = Path(shlex.split(value)[0]).name.lower()
except ValueError:
return ""
if "codex" in executable:
return "codex"
if "claude" in executable:
return "claude"
return ""

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from story_automator.core.runtime_policy import PolicyError
from story_automator.core.success_verifiers import create_story_artifact, resolve_success_contract
def cmd_validate_story_creation(args: list[str]) -> int:
action = args[0] if args else ""
rest = args[1:] if args else []
project_root = os.environ.get("PROJECT_ROOT", os.getcwd())
default_artifacts_dir = Path(project_root) / "_bmad-output" / "implementation-artifacts"
artifacts_dir = default_artifacts_dir
def story_prefix(story_id: str) -> str:
return story_id.replace(".", "-")
def count_files(story_id: str, folder: Path) -> int:
return len(list(folder.glob(f"{story_prefix(story_id)}-*.md")))
def create_check_payload(story_id: str, state_file: str) -> dict[str, object]:
contract = resolve_success_contract(project_root, "create", state_file=state_file or None)
return create_story_artifact(project_root=project_root, story_key=story_id, contract=contract)
def expected_matches(payload: dict[str, object] | None) -> int:
if payload is None:
return 1
return int(payload.get("expectedMatches", 1))
def count_reason(created: int, expected: int) -> str:
if created == expected:
return "Exactly 1 story file created as expected" if expected == 1 else f"Exactly {expected} story files created as expected"
if created == 0:
return "No story file created - session may have failed"
if created < 0:
return f"Story files decreased ({created}) - unexpected deletion"
if created > expected:
return f"RUNAWAY CREATION: {created} files created instead of {expected}"
return f"Unexpected story artifact count: {created} files instead of {expected}"
def build_check_response(
story_id: str,
payload: dict[str, object] | None,
*,
before_count: int | None = None,
after_count: int | None = None,
valid_override: bool | None = None,
reason_override: str | None = None,
) -> dict[str, object]:
expected = expected_matches(payload)
created = int(payload.get("actualMatches", 0)) if payload is not None else 0
valid = bool(payload.get("verified")) if payload is not None else False
reason = count_reason(created, expected)
if before_count is not None and after_count is not None:
created = after_count - before_count
valid = created == expected
reason = count_reason(created, expected)
if valid_override is not None:
valid = valid_override
if reason_override is not None:
reason = reason_override
response: dict[str, object] = {
"valid": valid,
"verified": valid,
"created_count": created,
"expected": expected,
"prefix": story_prefix(story_id),
"action": "proceed" if valid else "escalate",
"reason": reason,
"source": payload.get("source", "") if payload is not None else "",
"pattern": payload.get("pattern", "") if payload is not None else "",
"matches": payload.get("matches", []) if payload is not None else [],
}
if before_count is not None and after_count is not None:
response["before"] = before_count
response["after"] = after_count
if payload is not None and payload.get("story"):
response["story"] = payload["story"]
return response
def print_check_error(
story_id: str,
*,
reason: str,
before_count: int | None = None,
after_count: int | None = None,
) -> int:
response = build_check_response(
story_id,
None,
before_count=before_count,
after_count=after_count,
valid_override=False,
reason_override=reason,
)
print(json.dumps(response, separators=(",", ":")))
return 1
def parsed_delta_counts(before_value: str | None, after_value: str | None) -> tuple[int | None, int | None]:
if before_value is None or after_value is None:
return None, None
try:
return int(before_value or ""), int(after_value or "")
except ValueError:
return None, None
if action == "count":
if not rest:
print("Usage: validate-story-creation count <story_id>", file=os.sys.stderr)
return 1
story_id = rest[0]
for idx, arg in enumerate(rest[1:]):
if arg == "--artifacts-dir" and idx + 2 < len(rest):
artifacts_dir = Path(rest[idx + 2])
print(count_files(story_id, artifacts_dir))
return 0
if action == "check":
if not rest:
return print_check_error("", reason="story_id required")
story_id = rest[0]
state_file = ""
before_value = after_value = None
before_seen = after_seen = False
idx = 1
while idx < len(rest):
if rest[idx] == "--before":
before_seen = True
if idx + 1 < len(rest):
before_value = rest[idx + 1]
idx += 2
else:
before_count, after_count = parsed_delta_counts(before_value, after_value)
return print_check_error(story_id, reason="--before requires a value", before_count=before_count, after_count=after_count)
continue
if rest[idx] == "--after":
after_seen = True
if idx + 1 < len(rest):
after_value = rest[idx + 1]
idx += 2
else:
before_count, after_count = parsed_delta_counts(before_value, after_value)
return print_check_error(story_id, reason="--after requires a value", before_count=before_count, after_count=after_count)
continue
if rest[idx] == "--artifacts-dir" and idx + 1 < len(rest):
artifacts_dir = Path(rest[idx + 1])
idx += 2
continue
if rest[idx] == "--artifacts-dir":
before_count, after_count = parsed_delta_counts(before_value, after_value)
return print_check_error(story_id, reason="--artifacts-dir requires a value", before_count=before_count, after_count=after_count)
if rest[idx] == "--state-file" and idx + 1 < len(rest):
state_file = rest[idx + 1]
idx += 2
continue
if rest[idx] == "--state-file":
before_count, after_count = parsed_delta_counts(before_value, after_value)
return print_check_error(story_id, reason="--state-file requires a value", before_count=before_count, after_count=after_count)
before_count, after_count = parsed_delta_counts(before_value, after_value)
return print_check_error(story_id, reason=f"unsupported check argument: {rest[idx]}", before_count=before_count, after_count=after_count)
if before_seen != after_seen:
return print_check_error(story_id, reason="both --before and --after are required together")
before_count = after_count = None
if before_seen and after_seen:
try:
before_count = int(before_value or "")
after_count = int(after_value or "")
except ValueError:
return print_check_error(story_id, reason="before/after must be integers")
if artifacts_dir != default_artifacts_dir:
return print_check_error(
story_id,
reason="validate-story-creation check no longer supports --artifacts-dir overrides; use count/list for custom folders",
before_count=before_count,
after_count=after_count,
)
try:
payload = create_check_payload(story_id, state_file)
response = build_check_response(story_id, payload, before_count=before_count, after_count=after_count)
except (FileNotFoundError, PolicyError, ValueError) as exc:
return print_check_error(story_id, reason=str(exc), before_count=before_count, after_count=after_count)
print(json.dumps(response, separators=(",", ":")))
return 0
if action == "list":
if not rest:
print("Usage: validate-story-creation list <story_id>", file=os.sys.stderr)
return 1
story_id = rest[0]
print(f"Story files matching {story_prefix(story_id)}-*.md:")
matches = list(artifacts_dir.glob(f"{story_prefix(story_id)}-*.md"))
if not matches:
print(" (none found)")
return 0
for match in matches:
info = match.stat()
print(f"-rw-r--r-- 1 {info.st_mode} {info.st_size} {match}")
return 0
if action == "prefix":
if not rest:
return 1
print(story_prefix(rest[0]))
return 0
if action and action not in {"count", "check", "list", "prefix"}:
if not rest:
return print_check_error(action, reason="both --before and --after are required together")
if len(rest) == 1:
return cmd_validate_story_creation(["check", action, "--before", rest[0]])
return cmd_validate_story_creation(["check", action, "--before", rest[0], "--after", rest[1], *rest[2:]])
print("Usage: validate-story-creation <action> [args]", file=os.sys.stderr)
print("", file=os.sys.stderr)
print("Actions:", file=os.sys.stderr)
print(" count <story_id> - Count current story files", file=os.sys.stderr)
print(" check <story_id> [--state-file PATH] - Compatibility wrapper for create verifier", file=os.sys.stderr)
print(" list <story_id> - List matching files", file=os.sys.stderr)
print(" prefix <story_id> - Convert story ID to file prefix", file=os.sys.stderr)
return 1

View File

@@ -0,0 +1,198 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .common import ensure_dir, file_exists, iso_now, read_text, write_atomic
from .frontmatter import find_frontmatter_value
from .runtime_layout import runtime_provider
@dataclass
class AgentTaskConfig:
primary: str = ""
fallback: Any = None
@dataclass
class AgentConfigResolved:
default_primary: str = "auto"
default_fallback: str = "false"
per_task: dict[str, AgentTaskConfig] = field(default_factory=dict)
complexity_overrides: dict[str, dict[str, AgentTaskConfig]] = field(default_factory=dict)
def load_presets_file(path: str | Path) -> dict[str, Any]:
preset_path = Path(path)
if not file_exists(preset_path):
return {"version": "1.0.0", "presets": []}
data = json.loads(read_text(preset_path))
data.setdefault("version", "1.0.0")
data.setdefault("presets", [])
return data
def save_presets_file(path: str | Path, data: dict[str, Any]) -> None:
ensure_dir(Path(path).parent)
write_atomic(path, json.dumps(data, indent=2) + "\n")
def parse_agent_config_json(raw: str) -> AgentConfigResolved:
data = json.loads(raw)
config = AgentConfigResolved()
config.default_primary = data.get("defaultPrimary") or data.get("primary") or "auto"
if "defaultFallback" in data:
fallback_raw = data.get("defaultFallback")
elif "fallback" in data:
fallback_raw = data.get("fallback")
else:
fallback_raw = False
normalized_fallback = normalize_fallback_value(fallback_raw)
config.default_fallback = normalized_fallback or "false"
config.per_task = _parse_task_map(data.get("perTask"))
retro_task = _parse_task_entry(data.get("retro"))
if retro_task is not None:
config.per_task.setdefault("retro", retro_task)
for level, value in (data.get("complexityOverrides") or {}).items():
config.complexity_overrides[level] = _parse_task_map(value)
for level in ("low", "medium", "high"):
if level not in config.complexity_overrides and level in data:
parsed = _parse_task_map(data[level])
if parsed:
config.complexity_overrides[level] = parsed
return config
def _parse_task_map(raw: Any) -> dict[str, AgentTaskConfig]:
if not isinstance(raw, dict):
return {}
output: dict[str, AgentTaskConfig] = {}
for task, entry in raw.items():
parsed = _parse_task_entry(entry)
if parsed is None:
continue
output[task] = parsed
return output
def _parse_task_entry(raw: Any) -> AgentTaskConfig | None:
if not isinstance(raw, dict):
return None
return AgentTaskConfig(primary=str(raw.get("primary", "")), fallback=raw.get("fallback"))
def normalize_fallback_value(raw: Any) -> str:
if isinstance(raw, str):
lower = raw.strip().lower()
if lower in {"false", "none", "null"}:
return "false"
return lower
if isinstance(raw, bool):
return "true" if raw else "false"
return ""
def resolve_agent_for_task(config: AgentConfigResolved, complexity: str, task: str) -> tuple[str, str]:
primary = config.default_primary or "auto"
fallback = config.default_fallback or "false"
per_task = config.per_task.get(task)
if per_task:
if per_task.primary:
primary = per_task.primary
if per_task.fallback is not None:
fallback = normalize_fallback_value(per_task.fallback)
by_level = config.complexity_overrides.get(complexity, {})
override = by_level.get(task)
if override:
if override.primary:
primary = override.primary
if override.fallback is not None:
fallback = normalize_fallback_value(override.fallback)
return _resolve_primary_agent(primary), _resolve_fallback_agent(fallback)
def _resolve_primary_agent(raw: Any) -> str:
value = str(raw or "").strip().lower()
if value in {"", "auto", "runtime"}:
return runtime_provider()
return value
def _resolve_fallback_agent(raw: Any) -> str:
value = normalize_fallback_value(raw)
normalized = str(value).strip().lower()
if normalized in {"", "auto", "runtime"}:
return "false"
return normalized
def extract_json_block(text: str) -> str:
match = re.search(r"(?s)```json\s*(\{.*?\})\s*```", text)
if match:
return match.group(1)
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return stripped
return ""
def build_agents_file(state_file: str | Path, complexity_file: str | Path, output_path: str | Path, config_json: str) -> dict[str, Any]:
config = parse_agent_config_json(config_json)
complexity_payload = json.loads(read_text(complexity_file))
stories = []
for story in complexity_payload.get("stories", []):
level = str(((story.get("complexity") or {}).get("level")) or "medium").strip().lower() or "medium"
tasks = {}
for task in ("create", "dev", "auto", "review"):
primary, fallback = resolve_agent_for_task(config, level, task)
tasks[task] = {"primary": primary, "fallback": False if fallback == "false" else fallback}
stories.append(
{
"storyId": story.get("storyId"),
"title": story.get("title"),
"complexity": level,
"tasks": tasks,
}
)
payload = {
"version": "1.0.0",
"stateFile": str(state_file),
"epic": find_frontmatter_value(state_file, "epic"),
"epicName": find_frontmatter_value(state_file, "epicName"),
"createdAt": iso_now(),
"stories": stories,
}
header = (
f"---\nstateFile: {json.dumps(str(state_file))}\ncreatedAt: {json.dumps(payload['createdAt'])}\n---\n\n"
f"# Agents Plan: {payload['epicName']}\n\n```json\n{json.dumps(payload, indent=2)}\n```\n"
)
ensure_dir(Path(output_path).parent)
write_atomic(output_path, header)
return {"ok": True, "path": str(output_path), "stories": len(stories)}
def resolve_agents(agents_file: str | Path, story_id: str, task: str) -> dict[str, Any]:
text = read_text(agents_file)
block = extract_json_block(text)
if not block:
return {"ok": False, "error": "agents_json_missing"}
payload = json.loads(block)
for story in payload.get("stories", []):
if story.get("storyId") != story_id:
continue
selection = (story.get("tasks") or {}).get(task)
if not selection:
return {"ok": False, "error": "task_not_found"}
fallback = normalize_fallback_value(selection.get("fallback"))
return {
"ok": True,
"story": story_id,
"task": task,
"primary": selection.get("primary"),
"fallback": fallback,
"complexity": story.get("complexity"),
}
return {"ok": False, "error": "story_not_found"}

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
import contextlib
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_COMMAND_TIMEOUT = 600
COMMAND_TIMEOUT_EXIT = 124
def now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def iso_now() -> str:
return now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
def compact_json(value: Any) -> str:
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def print_json(value: Any) -> None:
print(compact_json(value))
def read_text(path: str | Path) -> str:
return Path(path).read_text(encoding="utf-8")
def read_text_if_exists(path: str | Path) -> str:
file_path = Path(path)
if not file_path.exists():
return ""
return read_text(file_path)
def write_atomic(path: str | Path, data: str | bytes) -> None:
target = Path(path)
ensure_dir(target.parent)
fd, tmp_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent))
try:
with os.fdopen(fd, "wb") as handle:
payload = data.encode("utf-8") if isinstance(data, str) else data
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, target)
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(tmp_name)
def ensure_dir(path: str | Path) -> None:
Path(path).mkdir(parents=True, exist_ok=True)
def file_exists(path: str | Path) -> bool:
return Path(path).is_file()
def dir_exists(path: str | Path) -> bool:
return Path(path).is_dir()
def pwd() -> str:
return os.getcwd()
def project_root() -> Path:
return Path(os.environ.get("PROJECT_ROOT") or pwd()).resolve()
def md5_hex8(value: str) -> str:
return hashlib.md5(value.encode("utf-8")).hexdigest()[:8]
def run_cmd(*args: str, timeout: int = DEFAULT_COMMAND_TIMEOUT, env: dict[str, str] | None = None, cwd: str | Path | None = None) -> tuple[str, int]:
proc_env = os.environ.copy()
if env:
proc_env.update(env)
completed = subprocess.run(
list(args),
capture_output=True,
text=True,
timeout=timeout,
env=proc_env,
cwd=str(cwd) if cwd else None,
check=False,
)
output = (completed.stdout or "") + (completed.stderr or "")
return output, completed.returncode
def command_exists(name: str) -> bool:
return shutil.which(name) is not None
def trim_lines(text: str) -> list[str]:
return [line.rstrip("\r") for line in text.splitlines()]
def filter_input_box(text: str) -> str:
lines = text.splitlines()
start_re = re.compile(r"^\s*[╭┌]")
end_re = re.compile(r"^\s*[╰└]")
box_re = re.compile(r"^\s*[│|]")
in_box = False
kept: list[str] = []
for line in lines:
if start_re.match(line):
in_box = True
continue
if end_re.match(line):
in_box = False
continue
if in_box and box_re.match(line):
continue
kept.append(line)
return "\n".join(kept)
def unquote_scalar(value: str) -> str:
raw = value.strip()
if len(raw) < 2:
return raw
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
try:
return json.loads(raw) if raw.startswith('"') else raw[1:-1]
except json.JSONDecodeError:
return raw[1:-1]
return raw
def parse_string_list_literal(raw: str) -> list[str] | None:
text = raw.strip()
if not text:
return None
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return None
if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
return parsed
return None
def contains_any_prefix(value: str, prefixes: list[str]) -> bool:
return any(value.startswith(prefix) for prefix in prefixes)
def clamp_int(value: int, minimum: int, maximum: int) -> int:
return max(minimum, min(maximum, value))
def help_flag(value: str) -> bool:
return value in {"--help", "-h"}
def safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def default_string(value: str, default: str) -> str:
return value or default

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from .common import read_text, trim_lines
def parse_epic_file(epic_file: str | Path) -> dict[str, Any]:
content = read_text(epic_file)
lines = trim_lines(content)
epic_title = ""
for line in lines:
if line.startswith("# "):
epic_title = line.removeprefix("# ").strip()
break
story_re = re.compile(r"^###\s+Story\s+(\d+)\.(\d+):\s*(.*)$")
epic_re = re.compile(r"^##\s+Epic\s+(\d+):\s*(.*)$")
current_epic_title = ""
stories: list[dict[str, str]] = []
for line in lines:
epic_match = epic_re.match(line)
if epic_match:
current_epic_title = epic_match.group(2).strip()
continue
story_match = story_re.match(line)
if story_match:
epic_num, story_num, title = story_match.groups()
story_id = f"{epic_num}.{story_num}"
stories.append(
{
"epicNum": epic_num,
"epicTitle": current_epic_title,
"storyNum": story_num,
"storyId": story_id,
"title": title.strip(),
}
)
return {"ok": True, "epicTitle": epic_title, "stories": stories, "count": len(stories), "file": str(epic_file)}
def parse_story(epic_file: str | Path, story_id: str, rules_file: str | Path) -> dict[str, Any]:
content = read_text(epic_file)
lines = trim_lines(content)
header_re = re.compile(rf"^###\s+Story\s+{re.escape(story_id)}:\s*(.*)$")
start_index = -1
title = ""
for index, line in enumerate(lines):
match = header_re.match(line)
if match:
start_index = index
title = match.group(1).strip()
break
if start_index < 0:
raise ValueError("story_not_found")
description_lines: list[str] = []
acceptance_criteria: list[str] = []
dependencies = ""
in_ac = False
for line in lines[start_index + 1 :]:
if line.startswith("### Story ") or line.startswith("## Epic "):
break
if "Acceptance Criteria" in line:
in_ac = True
continue
stripped = line.strip()
if not stripped:
continue
if "Dependencies:" in line or "**Dependencies**:" in line:
dep = line.replace("**Dependencies**:", "").replace("Dependencies:", "").strip()
if not dependencies:
dependencies = dep
if in_ac:
acceptance_criteria.append(stripped)
else:
description_lines.append(stripped)
description = " ".join(" ".join(description_lines).split())
rules = json.loads(read_text(rules_file))
content_for_score = " ".join(part for part in [title, description, " ".join(acceptance_criteria)] if part).strip()
score = 0
reasons: list[str] = []
for rule in rules.get("rules", []):
pattern = rule.get("pattern", "")
if pattern and re.search(pattern, content_for_score, re.IGNORECASE):
score += int(rule.get("score", 0))
reasons.append(str(rule.get("label", "")))
structural = rules.get("structural_rules", {})
ac_count = len(acceptance_criteria)
if structural.get("ac_count_high", 0) and ac_count > int(structural["ac_count_high"]):
score += int(structural.get("ac_count_high_score", 0))
reasons.append(f"High AC count ({ac_count})")
elif structural.get("ac_count_medium", 0) and ac_count > int(structural["ac_count_medium"]):
score += int(structural.get("ac_count_medium_score", 0))
reasons.append(f"Elevated AC count ({ac_count})")
if structural.get("dependency_score", 0) and dependencies and dependencies.lower() != "none":
score += int(structural.get("dependency_score", 0))
reasons.append("Has explicit dependencies")
word_threshold = int(structural.get("large_story_word_threshold", 0))
if word_threshold:
word_count = len(content_for_score.split())
if word_count > word_threshold:
score += int(structural.get("large_story_score", 0))
reasons.append(f"Large story ({word_count} words)")
low_max = int(rules.get("thresholds", {}).get("low_max", 0))
medium_max = int(rules.get("thresholds", {}).get("medium_max", low_max))
level = "High"
if score <= low_max:
level = "Low"
elif score <= medium_max:
level = "Medium"
return {
"ok": True,
"storyId": story_id,
"title": title,
"description": description,
"acceptanceCriteria": acceptance_criteria,
"dependencies": dependencies,
"complexity": {"score": score, "level": level, "reasons": reasons},
}
def parse_story_range(user_input: str, total: int, ids_csv: str = "") -> dict[str, Any]:
if not user_input or total <= 0:
raise ValueError("missing_input_or_total")
ids = [part.strip() for part in ids_csv.split(",")] if ids_csv else []
selected: set[int] = set()
normalized = user_input.lower().replace(" ", "")
if normalized == "all":
selected = set(range(1, total + 1))
else:
for part in normalized.split(","):
if not part:
continue
if "-" in part:
start_raw, end_raw = part.split("-", 1)
if start_raw.isdigit() and end_raw.isdigit():
start = int(start_raw)
end = int(end_raw)
low, high = sorted((start, end))
selected.update(range(low, high + 1))
elif part.isdigit():
selected.add(int(part))
indices = sorted(index for index in selected if 1 <= index <= total)
story_ids = [ids[index - 1] for index in indices if index - 1 < len(ids)]
return {"ok": True, "indices": indices, "storyIds": story_ids, "count": len(indices)}
def epic_complete(epic_file: str | Path, range_csv: str) -> dict[str, Any]:
story_ids = [story["storyId"] for story in parse_epic_file(epic_file)["stories"]]
if not story_ids:
raise ValueError("no_stories_found")
max_epic_story = max(story_ids, key=lambda value: tuple(int(part) for part in value.split(".", 1)))
selected = [part.strip() for part in range_csv.split(",") if part.strip()]
max_range_story = max(selected, key=lambda value: tuple(int(part) for part in value.split(".", 1))) if selected else "0.0"
return {"ok": True, "epicComplete": max_range_story == max_epic_story, "maxEpicStory": max_epic_story}

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from .utils import parse_string_list_literal, read_text, trim_lines, unquote_scalar, write_atomic
def extract_frontmatter(text: str) -> str:
if not text.startswith("---"):
return ""
parts = text.split("---", 2)
if len(parts) < 3:
return ""
return parts[1].lstrip("\n")
def split_frontmatter(text: str) -> tuple[str, str]:
if not text.startswith("---"):
return "", text
parts = text.split("---", 2)
if len(parts) < 3:
return "", text
return parts[1].lstrip("\n"), parts[2].lstrip("\n")
def parse_simple_frontmatter(text: str) -> dict[str, Any]:
front = extract_frontmatter(text)
if not front:
return {}
fields: dict[str, Any] = {}
current_key = ""
for line in trim_lines(front):
if line.strip().startswith("#"):
continue
if re.match(r"^\S[^:]*:", line):
key, raw = line.split(":", 1)
key = key.strip()
raw = raw.strip()
if raw == "":
fields[key] = []
current_key = key
continue
parsed_list = parse_string_list_literal(raw)
if parsed_list is not None:
fields[key] = parsed_list
else:
fields[key] = unquote_scalar(raw)
current_key = ""
continue
if current_key and line.strip().startswith("-"):
fields.setdefault(current_key, [])
fields[current_key].append(unquote_scalar(line.strip()[1:].strip()))
return fields
def parse_frontmatter(text: str) -> dict[str, Any]:
return parse_simple_frontmatter(text)
def find_frontmatter_value(path: str | Path, key: str) -> str:
fields = parse_simple_frontmatter(read_text(path))
value = fields.get(key, "")
if isinstance(value, list):
return ""
return str(value)
def find_frontmatter_value_case(path: str | Path, key: str) -> str:
front = extract_frontmatter(read_text(path))
for line in trim_lines(front):
if ":" not in line:
continue
left, raw = line.split(":", 1)
if left.strip().lower() == key.lower():
return unquote_scalar(raw.strip())
return ""
def extract_last_action(path: str | Path) -> str:
lines = trim_lines(read_text(path))
for index, line in enumerate(lines):
if line.startswith("## Action Log") and index + 2 < len(lines):
return lines[index + 2].strip().lstrip("* ").strip()
return ""
def read_story_range_from_state(path: str | Path) -> list[str]:
text = read_text(path)
for block in (extract_frontmatter(text), text):
if not block.strip():
continue
lines = trim_lines(block)
in_range = False
story_range: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("storyRange:"):
raw = stripped.split(":", 1)[1].strip()
parsed = parse_string_list_literal(raw)
if parsed is not None:
return parsed
in_range = True
continue
if in_range and stripped.startswith("-"):
story_range.append(unquote_scalar(stripped[1:].strip()))
continue
if in_range and re.match(r"^\S[^:]*:", line):
break
if story_range:
return story_range
return []
def update_simple_frontmatter(path: str | Path, updates: dict[str, str]) -> list[str]:
path = Path(path)
lines = trim_lines(read_text(path))
updated: list[str] = []
for idx, line in enumerate(lines):
for key, value in updates.items():
if line.startswith(f"{key}:"):
lines[idx] = f"{key}: {value}"
updated.append(key)
if updated:
write_atomic(path, "\n".join(lines) + "\n")
return updated
def extract_json_block(text: str) -> str:
match = re.search(r"```json\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
if match:
return match.group(1)
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
return stripped
return ""
def dump_json_pretty(payload: Any) -> str:
return json.dumps(payload, indent=2) + "\n"

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .runtime_policy import PolicyError
from .success_verifiers import resolve_success_contract, review_completion
def verify_code_review_completion(
project_root: str,
story_key: str,
*,
success_contract: dict[str, Any] | None = None,
state_file: str | Path | None = None,
) -> dict[str, object]:
try:
contract = resolve_success_contract(project_root, "review", state_file=state_file) if success_contract is None else success_contract
return review_completion(project_root=project_root, story_key=story_key, contract=contract)
except (FileNotFoundError, ValueError, PolicyError) as exc:
return {"verified": False, "reason": "review_contract_invalid", "input": story_key, "error": str(exc)}

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
import os
from pathlib import Path, PurePosixPath
STORY_SKILL_NAME = "bmad-story-automator"
ACTIVE_MARKER_NAME = ".story-automator-active"
def _project_root(project_root: str | Path | None = None) -> Path:
return Path(project_root or os.environ.get("PROJECT_ROOT") or os.getcwd()).expanduser().resolve()
def _configured_skills_root() -> Path | None:
raw = os.environ.get("BMAD_SKILLS_ROOT", "").strip()
if not raw:
return None
path = Path(raw).expanduser()
if path.name == STORY_SKILL_NAME:
return path.parent.resolve()
return path.resolve()
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"}:
return skills_root
return None
def candidate_skills_roots(project_root: str | Path | None = None) -> list[Path]:
root = _project_root(project_root)
candidates: list[Path] = []
explicit = _configured_skills_root()
if explicit:
candidates.append(explicit)
current = _current_skills_root()
if current:
candidates.append(current)
candidates.extend(
[
root / ".agents" / "skills",
root / ".claude" / "skills",
root / ".codex" / "skills",
Path.home() / ".codex" / "skills",
Path.home() / ".claude" / "skills",
]
)
seen: set[str] = set()
unique: list[Path] = []
for candidate in candidates:
key = str(candidate.expanduser().resolve())
if key not in seen:
seen.add(key)
unique.append(Path(key))
return unique
def _skill_present(skill_dir: Path, *, policy: bool = False) -> bool:
if policy:
return (skill_dir / "data" / "orchestration-policy.json").is_file()
return (skill_dir / "SKILL.md").is_file() or (skill_dir / "data" / "orchestration-policy.json").is_file()
def resolve_skills_root(
project_root: str | Path | None = None,
*,
skill_name: str = "",
policy: bool = False,
) -> Path:
explicit = _configured_skills_root()
for candidate in candidate_skills_roots(project_root):
if skill_name:
skill_dir = (candidate / skill_name).resolve()
if _skill_present(skill_dir, policy=policy):
return candidate.resolve()
if explicit and candidate.resolve() == explicit:
return candidate.resolve()
continue
if candidate.is_dir() or (explicit and candidate.resolve() == explicit):
return candidate.resolve()
return (_project_root(project_root) / ".claude" / "skills").resolve()
def resolve_skill_dir(project_root: str | Path | None, skill_name: str) -> Path:
requested = PurePosixPath(str(skill_name or "").replace("\\", "/"))
if requested.is_absolute() or ".." in requested.parts or not requested.parts:
raise ValueError(f"invalid skill name: {skill_name}")
root = resolve_skills_root(project_root, skill_name=skill_name)
return (root / skill_name).resolve()
def bundled_story_skill_root(project_root: str | Path | None = None) -> Path:
explicit = _configured_skills_root()
for skills_root in candidate_skills_roots(project_root):
candidate = (skills_root / STORY_SKILL_NAME).resolve()
if _skill_present(candidate, policy=True):
return candidate
if explicit and skills_root.resolve() == explicit:
break
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / STORY_SKILL_NAME
if _skill_present(candidate, policy=True):
return candidate.resolve()
raise FileNotFoundError("bundled story automator policy not found")
def _infer_provider_from_root(skills_root: Path | None) -> str:
if not skills_root:
return ""
resolved = skills_root.resolve()
parts = set(resolved.parts)
parent = resolved.parent.name
if parent == ".claude" or ".claude" in parts:
return "claude"
if parent in {".agents", ".codex"} or ".agents" in parts or ".codex" in parts:
return "codex"
return ""
def runtime_provider(project_root: str | Path | None = None) -> str:
# Provider controls hook/config syntax: Claude writes settings.json, Codex writes
# hooks.json plus config.toml. Marker paths are resolved separately so they can
# 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"}:
return raw
inferred = _infer_provider_from_root(_configured_skills_root())
if inferred:
return inferred
inferred = _infer_provider_from_root(_current_skills_root())
if inferred:
return inferred
root = _project_root(project_root)
if (root / ".agents" / "skills" / STORY_SKILL_NAME).exists() or (root / ".codex" / "skills" / STORY_SKILL_NAME).exists():
return "codex"
return "claude"
def active_marker_path(project_root: str | Path | None = None) -> Path:
root = _project_root(project_root)
for name in ("BMAD_STORY_AUTOMATOR_ACTIVE_MARKER", "STORY_AUTOMATOR_ACTIVE_MARKER"):
raw = os.environ.get(name, "").strip()
if raw:
marker = Path(raw).expanduser()
return (marker if marker.is_absolute() else root / marker).resolve()
provider = runtime_provider(root)
skills_root = resolve_skills_root(root, skill_name=STORY_SKILL_NAME)
try:
# Prefer the active project-local skill root over the provider fallback. This
# keeps the hook and orchestrator looking at the same marker in mixed or
# explicitly configured workspaces.
skills_root.relative_to(root)
explicit = _configured_skills_root()
current = _current_skills_root()
story_skill_dir = skills_root / STORY_SKILL_NAME
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):
return (skills_root.parent / ACTIVE_MARKER_NAME).resolve()
except ValueError:
pass
if provider == "codex":
return (root / ".agents" / ACTIVE_MARKER_NAME).resolve()
return (root / ".claude" / ACTIVE_MARKER_NAME).resolve()
def active_marker_project_entry(project_root: str | Path | None = None) -> str:
root = _project_root(project_root)
marker = active_marker_path(root)
try:
return str(marker.relative_to(root))
except ValueError:
return str(marker)
def resolve_portable_path(path_value: str, project_root: str | Path | None = None) -> Path | None:
normalized = str(path_value or "").replace("\\", "/").strip()
neutral_prefixes = ("<skills-root>/", "$SKILLS_ROOT/", "skills://")
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/"):
if not normalized.startswith(prefix):
continue
return _resolve_skill_relative_path(normalized[len(prefix) :], project_root)
return None
def _resolve_skill_relative_path(path_value: str, project_root: str | Path | None = None) -> Path | None:
relative = PurePosixPath(path_value)
parts = relative.parts
if not parts:
return None
skill_name = parts[0]
try:
skill_dir = resolve_skill_dir(project_root, skill_name)
candidate = (skill_dir / Path(*parts[1:])).resolve()
candidate.relative_to(skill_dir)
return candidate
except ValueError:
return None

View File

@@ -0,0 +1,554 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from .frontmatter import parse_simple_frontmatter
from .runtime_layout import active_marker_path, bundled_story_skill_root, resolve_portable_path, resolve_skill_dir
from .utils import ensure_dir, get_project_root, iso_now, md5_hex8, read_text, write_atomic
VALID_TOP_LEVEL_KEYS = {"version", "snapshot", "runtime", "workflow", "steps"}
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"}
def load_bundled_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
bundle_root = bundled_skill_root(root)
policy = _read_json(bundle_root / "data" / "orchestration-policy.json")
_validate_policy_shape(policy)
if resolve_assets:
_resolve_policy_paths(policy, project_root=root, bundle_root=bundle_root)
else:
_resolve_success_paths(policy, project_root=root, bundle_root=bundle_root)
return policy
class PolicyError(ValueError):
pass
def load_effective_policy(project_root: str | None = None, *, resolve_assets: bool = True) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
bundled = load_bundled_policy(str(root), resolve_assets=False)
override_path = root / "_bmad" / "bmm" / "story-automator.policy.json"
override = _read_json(override_path) if override_path.is_file() else {}
policy = _deep_merge(bundled, override)
_apply_legacy_env(policy)
_validate_policy_shape(policy)
_clear_resolved_fields(policy)
if resolve_assets:
_resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root))
else:
_resolve_success_paths(policy, project_root=root, bundle_root=bundled_skill_root(root))
return policy
def load_runtime_policy(
project_root: str | None = None,
state_file: str | Path | None = None,
*,
resolve_assets: bool = True,
) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
resolved_state, source = resolve_policy_state_file(root, state_file)
if resolved_state:
state_path = Path(resolved_state)
if source in {"env", "marker"} and not state_path.is_file():
raise PolicyError(f"{source} state file missing: {state_path}")
if source != "explicit" and not state_path.is_file():
return load_effective_policy(str(root), resolve_assets=resolve_assets)
return load_policy_for_state(str(state_path), project_root=str(root), resolve_assets=resolve_assets)
return load_effective_policy(str(root), resolve_assets=resolve_assets)
def snapshot_effective_policy(project_root: str | None = None) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
policy = load_effective_policy(str(root))
snapshot_dir = _resolve_snapshot_dir(policy, root)
ensure_dir(snapshot_dir)
stable_json = _stable_policy_json(policy)
snapshot_hash = md5_hex8(stable_json)
stamp = iso_now().replace("-", "").replace(":", "").replace("T", "-").replace("Z", "")
snapshot_path = snapshot_dir / f"{stamp}-{snapshot_hash}.json"
write_atomic(snapshot_path, stable_json)
return {
"policy": policy,
"policyVersion": policy.get("version", 1),
"policySnapshotHash": snapshot_hash,
"policySnapshotFile": _display_path(snapshot_path, root),
}
def load_policy_snapshot(
snapshot_file: str,
*,
project_root: str | None = None,
expected_hash: str = "",
resolve_assets: bool = True,
) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
path = Path(snapshot_file)
if not path.is_absolute():
path = root / path
path = _ensure_within(path, root, "policy snapshot")
if not path.is_file():
raise PolicyError(f"policy snapshot missing: {path}")
try:
raw = read_text(path)
except OSError as exc:
raise PolicyError(f"policy snapshot unreadable: {path}") from exc
actual_hash = md5_hex8(raw)
if expected_hash and actual_hash != expected_hash:
raise PolicyError(f"policy snapshot hash mismatch: expected {expected_hash}, got {actual_hash}")
try:
policy = json.loads(raw)
except json.JSONDecodeError as exc:
raise PolicyError(f"policy json invalid: {path}") from exc
_validate_policy_shape(policy)
if resolve_assets:
_resolve_policy_paths(policy, project_root=root, bundle_root=bundled_skill_root(root))
else:
_resolve_success_paths(policy, project_root=root, bundle_root=bundled_skill_root(root))
return policy
def load_policy_for_state(
state_file: str | Path,
project_root: str | None = None,
*,
resolve_assets: bool = True,
) -> dict[str, Any]:
root = Path(project_root or get_project_root()).resolve()
try:
fields = parse_simple_frontmatter(read_text(state_file))
except OSError as exc:
raise PolicyError(f"state file unreadable: {state_file}") from exc
snapshot_file, snapshot_hash, legacy_mode = _state_policy_mode(fields)
if not legacy_mode:
return load_policy_snapshot(
snapshot_file,
project_root=str(root),
expected_hash=snapshot_hash,
resolve_assets=resolve_assets,
)
return load_bundled_policy(str(root), resolve_assets=resolve_assets)
def summarize_state_policy_fields(fields: dict[str, Any], *, project_root: str | Path | None = None) -> tuple[str, str, str, str, str]:
policy_version = str(fields.get("policyVersion") or "").strip()
try:
snapshot_file, snapshot_hash, legacy_mode = _state_policy_mode(fields)
if snapshot_file and snapshot_hash:
load_policy_snapshot(
snapshot_file,
project_root=str(Path(project_root or get_project_root()).resolve()),
expected_hash=snapshot_hash,
resolve_assets=False,
)
except PolicyError as exc:
return "", "", policy_version, "false", str(exc)
return snapshot_file, snapshot_hash, policy_version, "true" if legacy_mode else "false", ""
def resolve_policy_state_file(project_root: str | Path | None = None, state_file: str | Path | None = None) -> tuple[str, str]:
root = Path(project_root or get_project_root()).resolve()
explicit = Path(state_file).expanduser() if state_file else None
if explicit:
return str(_resolve_state_path(root, explicit)), "explicit"
env_state = os.environ.get("STORY_AUTOMATOR_STATE_FILE", "").strip()
if env_state:
return str(_resolve_state_path(root, Path(env_state).expanduser(), allow_outside=False, label="env state file")), "env"
marker = active_marker_path(root)
if marker.is_file():
try:
payload = _read_json(marker)
except PolicyError as exc:
raise PolicyError(f"active-run marker invalid: {exc}") from exc
marker_state = str(payload.get("stateFile") or "").strip()
if not marker_state:
raise PolicyError("active-run marker missing stateFile")
return str(_resolve_state_path(root, Path(marker_state).expanduser(), allow_outside=False, label="marker state file")), "marker"
return "", ""
def step_contract(policy: dict[str, Any], step: str) -> dict[str, Any]:
contract = (policy.get("steps") or {}).get(step)
if not isinstance(contract, dict):
raise PolicyError(f"unknown step: {step}")
return contract
def review_max_cycles(policy: dict[str, Any]) -> int:
repeat = ((policy.get("workflow") or {}).get("repeat") or {}).get("review") or {}
return int(repeat.get("maxCycles", 5))
def crash_max_retries(policy: dict[str, Any]) -> int:
crash = ((policy.get("workflow") or {}).get("crash")) or {}
return int(crash.get("maxRetries", 2))
def parser_runtime_config(policy: dict[str, Any]) -> dict[str, object]:
runtime = _expect_optional_dict(policy, "runtime")
parser = _expect_optional_nested_dict(runtime, "parser", "runtime")
provider = str(parser.get("provider") or "").strip()
model = str(parser.get("model") or "").strip()
timeout = parser.get("timeoutSeconds")
if provider not in VALID_PARSER_PROVIDERS:
raise PolicyError(f"runtime.parser.provider must be one of: {', '.join(sorted(VALID_PARSER_PROVIDERS))}")
if not model:
raise PolicyError("runtime.parser.model must be a string")
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout <= 0:
raise PolicyError("runtime.parser.timeoutSeconds must be a positive integer")
return {"provider": provider, "model": model, "timeoutSeconds": timeout}
def bundled_skill_root(project_root: str | Path | None = None) -> Path:
root = Path(project_root or get_project_root()).resolve()
try:
return bundled_story_skill_root(root)
except FileNotFoundError as exc:
raise PolicyError("bundled policy not found") from exc
def _read_json(path: str | Path) -> dict[str, Any]:
try:
payload = json.loads(read_text(path))
except json.JSONDecodeError as exc:
raise PolicyError(f"policy json invalid: {path}") from exc
if not isinstance(payload, dict):
raise PolicyError(f"policy json must be an object: {path}")
return payload
def _deep_merge(base: Any, override: Any) -> Any:
if isinstance(base, dict) and isinstance(override, dict):
merged = dict(base)
for key, value in override.items():
merged[key] = _deep_merge(merged[key], value) if key in merged else value
return merged
if isinstance(override, list):
return list(override)
return override
def _clear_resolved_fields(policy: dict[str, Any]) -> None:
for contract in (policy.get("steps") or {}).values():
if not isinstance(contract, dict):
continue
assets = contract.get("assets")
if isinstance(assets, dict):
assets.pop("files", None)
prompt = contract.get("prompt")
if isinstance(prompt, dict):
prompt.pop("templatePath", None)
prompt.pop("templateHash", None)
parse = contract.get("parse")
if isinstance(parse, dict):
parse.pop("schemaPath", None)
parse.pop("schemaHash", None)
success = contract.get("success")
if isinstance(success, dict):
success.pop("contractPath", None)
success.pop("contractHash", None)
def _apply_legacy_env(policy: dict[str, Any]) -> None:
review_cycles = os.environ.get("MAX_REVIEW_CYCLES")
crash_retries = os.environ.get("MAX_CRASH_RETRIES")
if review_cycles:
policy.setdefault("workflow", {}).setdefault("repeat", {}).setdefault("review", {})["maxCycles"] = _legacy_env_int(
"MAX_REVIEW_CYCLES",
review_cycles,
)
if crash_retries:
policy.setdefault("workflow", {}).setdefault("crash", {})["maxRetries"] = _legacy_env_int(
"MAX_CRASH_RETRIES",
crash_retries,
)
def _legacy_env_int(name: str, raw: str) -> int:
try:
return int(raw)
except ValueError as exc:
raise PolicyError(f"{name} must be an integer") from exc
def _validate_policy_shape(policy: dict[str, Any]) -> None:
unknown_keys = sorted(set(policy) - VALID_TOP_LEVEL_KEYS)
if unknown_keys:
raise PolicyError(f"unknown top-level policy keys: {', '.join(unknown_keys)}")
snapshot = _expect_optional_dict(policy, "snapshot")
if "snapshot" in policy and "relativeDir" in snapshot and not isinstance(snapshot.get("relativeDir"), str):
raise PolicyError("snapshot.relativeDir must be a string")
runtime = _expect_optional_dict(policy, "runtime")
_expect_optional_nested_dict(runtime, "merge", "runtime")
parser_runtime_config(policy)
workflow = _expect_optional_dict(policy, "workflow")
repeat = _expect_optional_nested_dict(workflow, "repeat", "workflow")
review = _expect_optional_nested_dict(repeat, "review", "workflow.repeat")
crash = _expect_optional_nested_dict(workflow, "crash", "workflow")
steps = policy.get("steps")
if not isinstance(steps, dict):
raise PolicyError("steps must be an object")
unknown_steps = sorted(set(steps) - VALID_STEP_NAMES)
if unknown_steps:
raise PolicyError(f"unknown step names: {', '.join(unknown_steps)}")
sequence = (workflow.get("sequence")) or []
if not isinstance(sequence, list) or not all(isinstance(item, str) for item in sequence):
raise PolicyError("workflow.sequence must be a string array")
if "maxCycles" in review and not isinstance(review.get("maxCycles"), int):
raise PolicyError("workflow.repeat.review.maxCycles must be an integer")
if "maxRetries" in crash and not isinstance(crash.get("maxRetries"), int):
raise PolicyError("workflow.crash.maxRetries must be an integer")
for step in sequence:
if step not in steps:
raise PolicyError(f"workflow.sequence references missing step: {step}")
for name, contract in steps.items():
if not isinstance(contract, dict):
raise PolicyError(f"step contract must be an object: {name}")
assets = _expect_step_dict(contract, "assets", name)
_expect_step_dict(contract, "prompt", name)
_expect_step_dict(contract, "parse", name)
_expect_step_dict(contract, "success", name)
verifier = str(((contract.get("success") or {}).get("verifier")) or "")
if verifier not in VALID_VERIFIERS:
raise PolicyError(f"invalid verifier for {name}: {verifier}")
required = (assets.get("required")) or []
if not isinstance(required, list) or any(item not in VALID_ASSET_NAMES for item in required):
raise PolicyError(f"invalid required assets for {name}")
def _resolve_policy_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None:
for name, contract in (policy.get("steps") or {}).items():
assets = contract.setdefault("assets", {})
assets["files"] = _resolve_step_assets(name, assets, project_root)
prompt = contract.setdefault("prompt", {})
template_file = str(prompt.get("templateFile") or "").strip()
if not template_file:
raise PolicyError(f"missing prompt template for {name}")
prompt["templatePath"] = _resolve_data_path(template_file, project_root=project_root, bundle_root=bundle_root)
_set_or_verify_hash(prompt, path_key="templatePath", hash_key="templateHash", label="policy template")
parse = contract.setdefault("parse", {})
schema_file = str(parse.get("schemaFile") or "").strip()
if not schema_file:
raise PolicyError(f"missing parse schema for {name}")
parse["schemaPath"] = _resolve_data_path(schema_file, project_root=project_root, bundle_root=bundle_root)
_set_or_verify_hash(parse, path_key="schemaPath", hash_key="schemaHash", label="policy parse schema")
success = contract.setdefault("success", {})
contract_file = str(success.get("contractFile") or "").strip()
if contract_file:
success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root)
_set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract")
def _resolve_success_paths(policy: dict[str, Any], *, project_root: Path, bundle_root: Path) -> None:
for contract in (policy.get("steps") or {}).values():
success = contract.setdefault("success", {})
contract_file = str(success.get("contractFile") or "").strip()
if contract_file:
success["contractPath"] = _resolve_data_path(contract_file, project_root=project_root, bundle_root=bundle_root)
_set_or_verify_hash(success, path_key="contractPath", hash_key="contractHash", label="policy success contract")
def _resolve_step_assets(step: str, assets: dict[str, Any], project_root: Path) -> dict[str, str]:
skill_name = str(assets.get("skillName") or "").strip()
if not skill_name:
raise PolicyError(f"missing skillName for {step}")
try:
skill_dir = resolve_skill_dir(project_root, skill_name)
except ValueError as exc:
raise PolicyError(str(exc)) from exc
skills_root = skill_dir.parent
required = set(assets.get("required") or [])
files = {
"skill": _resolve_required_file(skill_dir / "SKILL.md", project_root, required, "skill", step),
"workflow": _resolve_candidate_file(skill_dir, assets.get("workflowCandidates"), project_root, required, "workflow", step),
"instructions": _resolve_candidate_file(skill_dir, assets.get("instructionsCandidates"), project_root, required, "instructions", step),
"checklist": _resolve_candidate_file(skill_dir, assets.get("checklistCandidates"), project_root, required, "checklist", step),
"template": _resolve_candidate_file(skill_dir, assets.get("templateCandidates"), project_root, required, "template", step),
}
if not files["skill"]:
files["workflow"] = ""
files["instructions"] = ""
files["checklist"] = ""
files["template"] = ""
return files
def _resolve_required_file(path: Path, project_root: Path, required: set[str], asset: str, step: str) -> str:
if path.is_file():
return _display_path(path, project_root)
if asset in required:
raise PolicyError(f"missing required {asset} asset for {step}: {path}")
return ""
def _resolve_candidate_file(
skill_dir: Path,
candidates: Any,
project_root: Path,
required: set[str],
asset: str,
step: str,
) -> str:
if not isinstance(candidates, list):
candidates = []
for name in candidates:
if not isinstance(name, str) or not name:
continue
path = _ensure_within(skill_dir / name, skill_dir, f"{asset} candidate for {step}")
if path.is_file():
return _display_path(path, project_root)
if asset == "workflow" and asset in required:
skill_file = skill_dir / "SKILL.md"
if skill_file.is_file():
return _display_path(skill_file, project_root)
if asset in required:
searched = ", ".join(str(skill_dir / str(name)) for name in candidates if isinstance(name, str) and name)
raise PolicyError(f"missing required {asset} asset for {step}: {searched}")
return ""
def _resolve_data_path(path_value: str, *, project_root: Path, bundle_root: Path) -> str:
portable = resolve_portable_path(path_value, project_root)
if portable:
if not portable.is_file():
raise PolicyError(f"policy data file missing: {path_value}")
return str(portable)
raw = Path(path_value)
allowed_roots = (bundle_root.resolve(), project_root.resolve())
if raw.is_absolute():
resolved = raw.resolve()
if not _is_within_any(resolved, allowed_roots):
raise PolicyError(f"policy data path escapes allowed roots: {path_value}")
if not resolved.is_file():
raise PolicyError(f"policy data file missing: {raw}")
return str(resolved)
escaped_all = True
for base in allowed_roots:
candidate = (base / raw).resolve()
if not _is_within(candidate, base):
continue
escaped_all = False
if candidate.is_file():
return str(candidate)
if escaped_all:
raise PolicyError(f"policy data path escapes allowed roots: {path_value}")
raise PolicyError(f"policy data file missing: {path_value}")
def _snapshot_relative_dir(policy: dict[str, Any]) -> str:
snapshot = _expect_optional_dict(policy, "snapshot")
relative_dir = str(snapshot.get("relativeDir") or "").strip()
if not relative_dir:
raise PolicyError("snapshot.relativeDir missing")
return relative_dir
def _resolve_snapshot_dir(policy: dict[str, Any], project_root: Path) -> Path:
raw = Path(_snapshot_relative_dir(policy))
candidate = raw if raw.is_absolute() else project_root / raw
return _ensure_within(candidate, project_root.resolve(), "snapshot.relativeDir")
def _stable_policy_json(policy: dict[str, Any]) -> str:
return json.dumps(policy, indent=2, sort_keys=True) + "\n"
def _display_path(path: Path, project_root: Path) -> str:
try:
return str(path.resolve().relative_to(project_root.resolve()))
except ValueError:
return str(path.resolve())
def _resolve_state_path(project_root: Path, path: Path, *, allow_outside: bool = True, label: str = "state file") -> Path:
candidate = path if path.is_absolute() else project_root / path
if allow_outside:
return candidate.resolve()
return _ensure_within(candidate, project_root.resolve(), label)
def _set_or_verify_hash(payload: dict[str, Any], *, path_key: str, hash_key: str, label: str) -> None:
path = str(payload.get(path_key) or "").strip()
if not path:
return
actual = md5_hex8(read_text(path))
expected = str(payload.get(hash_key) or "").strip()
if expected and expected != actual:
raise PolicyError(f"{label} hash mismatch: {path}")
payload[hash_key] = actual
def _ensure_within(path: Path, root: Path, label: str) -> Path:
resolved = path.resolve()
root_resolved = root.resolve()
try:
resolved.relative_to(root_resolved)
except ValueError as exc:
raise PolicyError(f"{label} escapes allowed root: {path}") from exc
return resolved
def _is_within(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def _is_within_any(path: Path, roots: tuple[Path, ...]) -> bool:
return any(_is_within(path, root) for root in roots)
def _state_policy_mode(fields: dict[str, Any]) -> tuple[str, str, bool]:
snapshot_file = str(fields.get("policySnapshotFile") or "").strip()
snapshot_hash = str(fields.get("policySnapshotHash") or "").strip()
policy_version = str(fields.get("policyVersion") or "").strip()
legacy_policy = str(fields.get("legacyPolicy") or "").strip().lower()
if snapshot_file or snapshot_hash:
if not snapshot_file or not snapshot_hash:
raise PolicyError("state policy metadata incomplete")
if legacy_policy == "true":
raise PolicyError("state policy metadata contradictory")
return snapshot_file, snapshot_hash, False
if legacy_policy == "false" or policy_version:
raise PolicyError("state policy snapshot missing")
if legacy_policy == "true":
return "", "", True
return "", "", True
def _expect_optional_dict(payload: dict[str, Any], key: str) -> dict[str, Any]:
value = payload.get(key)
if value is None:
return {}
if not isinstance(value, dict):
raise PolicyError(f"{key} must be an object")
return value
def _expect_step_dict(contract: dict[str, Any], key: str, step: str) -> dict[str, Any]:
value = contract.get(key)
if value is None:
return {}
if not isinstance(value, dict):
raise PolicyError(f"{step}.{key} must be an object")
return value
def _expect_optional_nested_dict(payload: dict[str, Any], key: str, label: str) -> dict[str, Any]:
value = payload.get(key)
if value is None:
return {}
if not isinstance(value, dict):
raise PolicyError(f"{label}.{key} must be an object")
return value

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from .story_keys import sprint_status_file
from .utils import file_exists, read_text, trim_lines
@dataclass(frozen=True)
class SprintStatus:
found: bool
story: str
status: str
done: bool
reason: str = ""
def sprint_status_get(project_root: str, story_key: str) -> SprintStatus:
status_file = sprint_status_file(project_root)
if not file_exists(status_file):
return SprintStatus(False, story_key, "unknown", False, "sprint-status.yaml not found")
content = read_text(status_file)
match = re.search(rf"(?m)^\s*{re.escape(story_key)}:\s*(\S+)", content)
if match:
status = match.group(1).strip()
return SprintStatus(True, story_key, status, status == "done")
prefix = story_key
if "." in story_key:
prefix = story_key.replace(".", "-")
elif re.fullmatch(r"\d+-\d+-.+", story_key):
prefix = "-".join(story_key.split("-", 2)[:2])
if re.fullmatch(r"\d+-\d+", prefix):
prefix_match = re.search(rf"(?m)^\s*({re.escape(prefix)}-[^:\s]+)\s*:\s*(\S+)", content)
if prefix_match:
status = prefix_match.group(2).strip()
return SprintStatus(True, prefix_match.group(1), status, status == "done")
return SprintStatus(False, story_key, "not_found", False)
def sprint_status_epic(project_root: str, epic: str) -> tuple[list[str], int]:
status_file = sprint_status_file(project_root)
if not file_exists(status_file):
return ([], 0)
stories: list[str] = []
seen: set[str] = set()
done_count = 0
for line in trim_lines(read_text(status_file)):
line = line.strip()
if not line or line.startswith("#"):
continue
if not (line.startswith(f"{epic}.") or line.startswith(f"{epic}-")):
continue
parts = line.split(":", 1)
if len(parts) < 2:
continue
key = parts[0].strip()
if key in seen:
continue
stories.append(key)
seen.add(key)
status = parts[1].strip().split()
if status and status[0] == "done":
done_count += 1
return (stories, done_count)

View File

@@ -0,0 +1,489 @@
from __future__ import annotations
import json
import re
import shlex
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .utils import ensure_dir, write_atomic
CODEX_HOOK_STATUS_MESSAGE = "Checking story automator state"
STOP_HOOK_EVENT = "Stop"
class HookConfigError(Exception):
def __init__(self, code: str, path: Path, message: str = "") -> None:
super().__init__(message or code)
self.code = code
self.path = path
self.message = message or code
@dataclass(frozen=True)
class HookInstallResult:
changed: bool
reason: str
path: Path
written: bool = False
@dataclass(frozen=True)
class HookFileUpdate:
result: HookInstallResult
data: str | None = None
def ensure_stop_hook(
*,
provider: str,
project_root: Path,
settings_path: Path | None,
command: str,
timeout: int,
) -> dict[str, Any]:
if provider == "codex":
return ensure_codex_stop_hook(project_root=project_root, command=command, timeout=timeout)
if not settings_path:
raise HookConfigError("missing_settings", project_root / ".claude" / "settings.json")
return ensure_claude_stop_hook(settings_path=settings_path, command=command, timeout=timeout)
def ensure_claude_stop_hook(*, settings_path: Path, command: str, timeout: int) -> dict[str, Any]:
result = _ensure_json_stop_hook(settings_path, command=command, timeout=timeout)
return {
"changed": result.changed,
"reason": result.reason,
"provider": "claude",
"path": str(result.path),
"message": _claude_hook_message(result.changed),
}
def ensure_codex_stop_hook(*, project_root: Path, command: str, timeout: int) -> dict[str, Any]:
codex_dir = project_root / ".codex"
hooks_path = codex_dir / "hooks.json"
config_path = codex_dir / "config.toml"
config_update = _prepare_codex_hooks_feature(config_path)
hook_update = _prepare_json_stop_hook(
hooks_path,
command=command,
timeout=timeout,
status_message=CODEX_HOOK_STATUS_MESSAGE,
)
_write_prepared_update(config_update)
_write_prepared_update(hook_update)
hook_result = hook_update.result
config_result = config_update.result
changed = hook_result.changed or config_result.changed
trusted = _codex_project_is_trusted(config_path, project_root)
if changed:
reason = "codex_hook_configured"
elif not trusted:
reason = "pending_trust"
elif hook_result.written:
reason = hook_result.reason
else:
reason = "already_configured"
return {
"changed": changed,
"reason": reason,
"provider": "codex",
"path": str(hooks_path),
"hooksPath": str(hooks_path),
"configPath": str(config_path),
"hooksChanged": hook_result.changed,
"configChanged": config_result.changed,
"hooksReason": hook_result.reason,
"configReason": config_result.reason,
"trusted": trusted,
"verificationState": _codex_verification_state(changed, trusted),
"message": _codex_hook_message(changed, trusted),
}
def _codex_verification_state(changed: bool, trusted: bool) -> str:
if changed:
return "configured"
if trusted:
return "verified"
return "pending_trust"
def _codex_hook_message(changed: bool, trusted: bool) -> str:
if changed:
suffix = (
"Restart Codex from this trusted project session for the hook to load."
if trusted
else "Trust this project in Codex, then restart Codex so the hook can load."
)
return "Codex Stop hook configured in .codex/hooks.json and codex_hooks enabled in .codex/config.toml. " + suffix
if trusted:
return "Codex Stop hook verified."
return "Codex Stop hook is configured on disk, but this project is not yet trusted in Codex."
def _claude_hook_message(changed: bool) -> str:
if changed:
return "Claude Stop hook configured in .claude/settings.json. Restart Claude for the hook to load."
return "Claude Stop hook verified."
def _ensure_json_stop_hook(
path: Path,
*,
command: str,
timeout: int,
status_message: str | None = None,
) -> HookInstallResult:
update = _prepare_json_stop_hook(path, command=command, timeout=timeout, status_message=status_message)
_write_prepared_update(update)
return update.result
def _prepare_json_stop_hook(
path: Path,
*,
command: str,
timeout: int,
status_message: str | None = None,
) -> HookFileUpdate:
payload = _stop_hook_payload(command=command, timeout=timeout, status_message=status_message)
if not path.exists():
return HookFileUpdate(
result=HookInstallResult(changed=True, reason="created", path=path, written=True),
data=json.dumps(payload, indent=2) + "\n",
)
root = _read_json_object(path)
hooks = _object_child(root, "hooks", path)
stop_hooks = _list_child(hooks, STOP_HOOK_EVENT, path)
exists = False
needs_update = False
for entry in stop_hooks:
if not isinstance(entry, dict):
continue
handlers = entry.get("hooks", [])
if not isinstance(handlers, list):
continue
for hook in handlers:
if not isinstance(hook, dict):
continue
existing = hook.get("command")
if not _is_story_automator_stop_hook(existing, command):
continue
exists = True
if hook.get("type") != "command":
hook["type"] = "command"
needs_update = True
if existing != command:
hook["command"] = command
needs_update = True
if hook.get("timeout") != timeout:
hook["timeout"] = timeout
needs_update = True
if status_message is not None and hook.get("statusMessage") != status_message:
hook["statusMessage"] = status_message
needs_update = True
if exists and not needs_update:
return HookFileUpdate(result=HookInstallResult(changed=False, reason="already_configured", path=path))
if exists:
return HookFileUpdate(
result=HookInstallResult(changed=True, reason="hook_normalized", path=path, written=True),
data=json.dumps(root, indent=2) + "\n",
)
stop_hooks.append(payload["hooks"][STOP_HOOK_EVENT][0])
return HookFileUpdate(
result=HookInstallResult(changed=True, reason="added", path=path, written=True),
data=json.dumps(root, indent=2) + "\n",
)
def _write_prepared_update(update: HookFileUpdate) -> None:
if not update.result.written:
return
if update.data is None:
raise HookConfigError("missing_prepared_data", update.result.path)
ensure_dir(update.result.path.parent)
write_atomic(update.result.path, update.data)
def _read_json_object(path: Path) -> dict[str, Any]:
try:
root = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise HookConfigError("invalid_json", path, str(exc)) from exc
if not isinstance(root, dict):
raise HookConfigError("invalid_json_object", path)
return root
def _object_child(root: dict[str, Any], key: str, path: Path) -> dict[str, Any]:
value = root.setdefault(key, {})
if not isinstance(value, dict):
raise HookConfigError(f"invalid_{key}_object", path)
return value
def _list_child(root: dict[str, Any], key: str, path: Path) -> list[Any]:
value = root.setdefault(key, [])
if not isinstance(value, list):
raise HookConfigError(f"invalid_{key.lower()}_hooks", path)
return value
def _stop_hook_payload(*, command: str, timeout: int, status_message: str | None = None) -> dict[str, Any]:
hook: dict[str, Any] = {
"type": "command",
"command": command,
"timeout": timeout,
}
if status_message is not None:
hook["statusMessage"] = status_message
return {"hooks": {STOP_HOOK_EVENT: [{"hooks": [hook]}]}}
def _is_story_automator_stop_hook(existing: Any, command: str) -> bool:
if existing == command:
return True
return _is_story_automator_stop_hook_command(str(existing))
def _is_story_automator_stop_hook_command(value: str) -> bool:
try:
parts = shlex.split(value)
except ValueError:
return False
if not parts:
return False
parts = _strip_env_prefix(parts)
if not parts:
return False
command_name = Path(parts[0]).name
if command_name == "story-automator":
return len(parts) > 1 and parts[1] == "stop-hook"
return (
_is_python_command(command_name)
and len(parts) > 3
and parts[1] == "-m"
and parts[2] == "story_automator"
and parts[3] == "stop-hook"
)
def _strip_env_prefix(parts: list[str]) -> list[str]:
if Path(parts[0]).name != "env":
return parts
idx = 1
while idx < len(parts):
part = parts[idx]
if part in {"-i", "-0"}:
idx += 1
continue
if part in {"-u", "--unset"} and idx + 1 < len(parts):
idx += 2
continue
if part.startswith("--unset="):
idx += 1
continue
if "=" in part and not part.startswith("-"):
idx += 1
continue
break
return parts[idx:]
def _is_python_command(command_name: str) -> bool:
return bool(re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", command_name))
def _ensure_codex_hooks_feature(path: Path) -> HookInstallResult:
update = _prepare_codex_hooks_feature(path)
_write_prepared_update(update)
return update.result
def _prepare_codex_hooks_feature(path: Path) -> HookFileUpdate:
if not path.exists():
return HookFileUpdate(
result=HookInstallResult(changed=True, reason="created", path=path, written=True),
data="[features]\ncodex_hooks = true\n",
)
text = path.read_text(encoding="utf-8")
parsed = _parse_toml(text, path)
features = parsed.get("features", {})
if features is None:
features = {}
if not isinstance(features, dict):
raise HookConfigError("invalid_features_table", path)
if features.get("codex_hooks") is True:
return HookFileUpdate(result=HookInstallResult(changed=False, reason="already_enabled", path=path))
updated = _set_features_codex_hooks(text)
_parse_toml(updated, path)
return HookFileUpdate(
result=HookInstallResult(changed=True, reason="codex_hooks_enabled", path=path, written=True),
data=updated,
)
def _parse_toml(text: str, path: Path) -> dict[str, Any]:
try:
return tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise HookConfigError("invalid_toml", path, str(exc)) from exc
def _codex_project_is_trusted(config_path: Path, project_root: Path) -> bool:
if not config_path.exists():
return False
parsed = _parse_toml(config_path.read_text(encoding="utf-8"), config_path)
projects = parsed.get("projects", {})
if not isinstance(projects, dict):
return False
resolved_root = project_root.resolve()
for raw_key, raw_config in projects.items():
if not isinstance(raw_key, str) or not isinstance(raw_config, dict):
continue
try:
if Path(raw_key).expanduser().resolve() != resolved_root:
continue
except OSError:
if raw_key != str(resolved_root):
continue
trust_level = str(raw_config.get("trust_level") or "").strip().lower()
if trust_level == "trusted":
return True
return False
def _set_features_codex_hooks(text: str) -> str:
lines = text.splitlines()
if not lines:
return "[features]\ncodex_hooks = true\n"
table_start = _find_table_start(lines, "features")
if table_start is None:
return _set_top_level_features_codex_hooks(text, lines)
table_end = _find_table_end(lines, table_start)
key_pattern = re.compile(r"^(\s*)codex_hooks\s*=.*$")
for index in range(table_start + 1, table_end):
match = key_pattern.match(lines[index])
if match:
lines[index] = f"{match.group(1)}codex_hooks = true"
return "\n".join(lines) + "\n"
lines.insert(table_start + 1, "codex_hooks = true")
return "\n".join(lines) + "\n"
def _set_top_level_features_codex_hooks(text: str, lines: list[str]) -> str:
root_end = _find_first_table_start(lines)
exact_dotted = re.compile(r"^(\s*)features\.codex_hooks\s*=.*$")
for index, line in enumerate(lines[:root_end]):
match = exact_dotted.match(line)
if match:
lines[index] = f"{match.group(1)}features.codex_hooks = true"
return "\n".join(lines) + "\n"
inline_features = re.compile(r"^(\s*)features\s*=\s*\{(.*)\}\s*(#.*)?$")
for index, line in enumerate(lines[:root_end]):
match = inline_features.match(line)
if match:
lines[index] = _set_inline_features_table_line(match)
return "\n".join(lines) + "\n"
last_dotted_index: int | None = None
dotted_features = re.compile(r"^\s*features\.[A-Za-z0-9_-]+(?:\.[^=]+)?\s*=.*$")
for index, line in enumerate(lines[:root_end]):
if dotted_features.match(line):
last_dotted_index = index
if last_dotted_index is not None:
lines.insert(last_dotted_index + 1, "features.codex_hooks = true")
return "\n".join(lines) + "\n"
separator = "\n" if text.endswith("\n") else "\n\n"
return f"{text}{separator}[features]\ncodex_hooks = true\n"
def _find_first_table_start(lines: list[str]) -> int:
table_pattern = re.compile(r"^\s*\[.+\]\s*(?:#.*)?$")
for index, line in enumerate(lines):
if table_pattern.match(line):
return index
return len(lines)
def _set_inline_features_table_line(match: re.Match[str]) -> str:
indent, inner, comment = match.group(1), match.group(2), match.group(3) or ""
items = [item.strip() for item in _split_inline_table_items(inner) if item.strip()]
updated_items: list[str] = []
replaced = False
for item in items:
if re.match(r"^codex_hooks\s*=", item):
updated_items.append("codex_hooks = true")
replaced = True
else:
updated_items.append(item)
if not replaced:
updated_items.append("codex_hooks = true")
return f"{indent}features = {{ {', '.join(updated_items)} }}{comment}"
def _split_inline_table_items(inner: str) -> list[str]:
items: list[str] = []
start = 0
depth = 0
quote = ""
escaped = False
for index, char in enumerate(inner):
if quote:
if quote == '"' and char == "\\" and not escaped:
escaped = True
continue
if char == quote and not escaped:
quote = ""
escaped = False
continue
if char in {"'", '"'}:
quote = char
continue
if char in "{[(":
depth += 1
continue
if char in "}])" and depth > 0:
depth -= 1
continue
if char == "," and depth == 0:
items.append(inner[start:index])
start = index + 1
items.append(inner[start:])
return items
def _find_table_start(lines: list[str], table_name: str) -> int | None:
pattern = re.compile(rf"^\s*\[{re.escape(table_name)}\]\s*(?:#.*)?$")
for index, line in enumerate(lines):
if pattern.match(line):
return index
return None
def _find_table_end(lines: list[str], table_start: int) -> int:
table_pattern = re.compile(r"^\s*\[.+\]\s*(?:#.*)?$")
for index in range(table_start + 1, len(lines)):
if table_pattern.match(lines[index]):
return index
return len(lines)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from .utils import file_exists, read_text
@dataclass(frozen=True)
class StoryKey:
id: str
prefix: str
key: str
def sprint_status_file(project_root: str) -> str:
preferred = Path(project_root) / "_bmad-output" / "implementation-artifacts" / "sprint-status.yaml"
if preferred.is_file():
return str(preferred)
legacy = Path(project_root) / "_bmad-output" / "sprint-status.yaml"
if legacy.is_file():
return str(legacy)
return str(preferred)
def normalize_story_key(project_root: str, value: str) -> StoryKey | None:
if re.fullmatch(r"\d+\.\d+", value):
story_id = value
prefix = value.replace(".", "-")
key = ""
elif re.fullmatch(r"\d+-\d+", value):
prefix = value
story_id = value.replace("-", ".")
key = ""
elif re.fullmatch(r"\d+-\d+-.+", value):
key = value
prefix = "-".join(value.split("-", 2)[:2])
story_id = prefix.replace("-", ".")
else:
return None
artifacts = Path(project_root) / "_bmad-output" / "implementation-artifacts"
if not key:
matches = sorted(artifacts.glob(f"{prefix}-*.md"))
if matches:
key = matches[0].stem
if not key:
status_file = sprint_status_file(project_root)
if file_exists(status_file):
content = read_text(status_file)
match = re.search(rf"(?m)^\s*({re.escape(prefix)}-[^:\s]+)\s*:", content)
if match:
key = match.group(1).strip()
if not key:
key = prefix
return StoryKey(id=story_id, prefix=prefix, key=key)

View File

@@ -0,0 +1,293 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Callable
from .frontmatter import find_frontmatter_value_case
from .runtime_policy import PolicyError, load_runtime_policy, step_contract
from .sprint import sprint_status_epic, sprint_status_get
from .story_keys import normalize_story_key
from .utils import read_text
ALLOWED_REVIEW_CONTRACT_KEYS = {"blockingSeverity", "doneValues", "inProgressValues", "sourceOrder", "syncSprintStatus"}
ALLOWED_REVIEW_SOURCES = {"sprint-status.yaml", "story-file"}
DEFAULT_REVIEW_CONTRACT = {
"blockingSeverity": ["critical"],
"doneValues": ["done"],
"inProgressValues": ["in-progress", "in_progress", "review", "qa"],
"sourceOrder": ["sprint-status.yaml", "story-file"],
"syncSprintStatus": True,
}
def resolve_success_contract(project_root: str, step: str, *, state_file: str | Path | None = None) -> dict[str, Any]:
policy = load_runtime_policy(project_root, state_file=state_file, resolve_assets=False)
success = step_contract(policy, step).get("success") or {}
if not isinstance(success, dict):
raise PolicyError(f"invalid success contract for {step}")
return success
def run_success_verifier(
name: str,
*,
project_root: str,
story_key: str = "",
output_file: str = "",
contract: dict[str, Any] | None = None,
) -> dict[str, object]:
verifier = VERIFIERS.get(name)
if verifier is None:
raise PolicyError(f"unknown success verifier: {name}")
return verifier(project_root=project_root, story_key=story_key, output_file=output_file, contract=contract or {})
def session_exit(
*,
project_root: str,
story_key: str = "",
output_file: str = "",
contract: dict[str, Any] | None = None,
) -> dict[str, object]:
payload: dict[str, object] = {"verified": True, "source": "session_exit"}
if story_key:
payload["story"] = story_key
if output_file:
payload["outputFile"] = output_file
return payload
def create_story_artifact(
*,
project_root: str,
story_key: str,
output_file: str = "",
contract: dict[str, Any] | None = None,
) -> dict[str, object]:
norm = normalize_story_key(project_root, story_key)
if norm is None:
return {"verified": False, "reason": "could_not_normalize_key", "input": story_key}
config = _success_config(contract)
raw_glob = str(config.get("glob") or "_bmad-output/implementation-artifacts/{story_prefix}-*.md")
expected = _parse_int(config.get("expectedMatches", 1), "success.config.expectedMatches", minimum=0)
pattern = _format_story_pattern(raw_glob, norm)
root, safe_pattern = _resolve_artifact_glob(project_root, pattern)
matches = sorted(root.glob(safe_pattern))
payload: dict[str, object] = {
"verified": len(matches) == expected,
"story": norm.key,
"source": "artifact_glob",
"pattern": safe_pattern,
"expectedMatches": expected,
"actualMatches": len(matches),
"matches": [str(match) for match in matches],
}
if not bool(payload["verified"]):
payload["reason"] = "unexpected_story_artifact_count"
return payload
def review_completion(
*,
project_root: str,
story_key: str,
output_file: str = "",
contract: dict[str, Any] | None = None,
) -> dict[str, object]:
norm = normalize_story_key(project_root, story_key)
if norm is None:
return {"verified": False, "reason": "could_not_normalize_key", "input": story_key}
review_contract = _load_review_contract(project_root, contract or {})
done_values = {value.lower() for value in review_contract["doneValues"]}
sprint = sprint_status_get(project_root, norm.id)
story_file = _story_artifact_path(project_root, norm.prefix)
story_status = find_frontmatter_value_case(story_file, "Status") if story_file else ""
for source in review_contract["sourceOrder"]:
if source == "sprint-status.yaml" and sprint.status.lower() in done_values:
return {
"verified": True,
"story": norm.key,
"sprint_status": sprint.status,
"story_file_status": story_status or "unknown",
"source": "sprint-status.yaml",
}
if source == "story-file" and story_status.lower() in done_values:
payload: dict[str, object] = {
"verified": True,
"story": norm.key,
"sprint_status": sprint.status,
"story_file_status": story_status,
"source": "story-file",
}
if review_contract["syncSprintStatus"] and not sprint.done:
payload["note"] = "sprint_status_not_updated"
return payload
return {
"verified": False,
"story": norm.key,
"sprint_status": sprint.status,
"story_file_status": story_status or "unknown",
"reason": "workflow_not_complete",
}
def epic_complete(
*,
project_root: str,
story_key: str,
output_file: str = "",
contract: dict[str, Any] | None = None,
) -> dict[str, object]:
epic = _epic_identifier(project_root, story_key)
if not epic:
return {"verified": False, "reason": "could_not_normalize_key", "input": story_key}
stories, done = sprint_status_epic(project_root, epic)
if not stories:
return {"verified": False, "epic": epic, "reason": "no_stories_found", "source": "sprint-status.yaml"}
return {
"verified": done == len(stories),
"epic": epic,
"story": story_key,
"totalStories": len(stories),
"doneStories": done,
"source": "sprint-status.yaml",
**({} if done == len(stories) else {"reason": "epic_incomplete"}),
}
def _success_config(contract: dict[str, Any] | None) -> dict[str, Any]:
config = (contract or {}).get("config") or {}
if not isinstance(config, dict):
raise PolicyError("success.config must be an object")
return config
def _format_story_pattern(pattern: str, story) -> str:
return (
pattern.replace("{story_prefix}", story.prefix)
.replace("{story_id}", story.id)
.replace("{story_key}", story.key)
)
def _story_artifact_path(project_root: str, story_prefix: str) -> Path | None:
matches = sorted((Path(project_root) / "_bmad-output" / "implementation-artifacts").glob(f"{story_prefix}-*.md"))
return matches[0] if matches else None
def _resolve_artifact_glob(project_root: str, pattern: str) -> tuple[Path, str]:
root = Path(project_root).resolve()
artifacts_root = (root / "_bmad-output" / "implementation-artifacts").resolve()
raw = Path(pattern)
if raw.is_absolute():
raise PolicyError("success.config.glob must be relative to _bmad-output/implementation-artifacts")
resolved = (root / raw).resolve()
try:
relative = resolved.relative_to(root)
except ValueError as exc:
raise PolicyError("success.config.glob escapes project root") from exc
try:
resolved.relative_to(artifacts_root)
except ValueError as exc:
raise PolicyError("success.config.glob must stay within _bmad-output/implementation-artifacts") from exc
return root, str(relative)
def _load_review_contract(project_root: str, contract: dict[str, Any]) -> dict[str, Any]:
merged = dict(DEFAULT_REVIEW_CONTRACT)
contract_path = str(contract.get("contractPath") or "").strip()
if contract_path:
path = Path(contract_path)
if not path.is_absolute():
path = Path(project_root) / path
try:
payload = json.loads(read_text(path))
except json.JSONDecodeError as exc:
raise PolicyError(f"review contract json invalid: {path}") from exc
if not isinstance(payload, dict):
raise PolicyError(f"review contract must be an object: {path}")
merged.update(payload)
inline = _inline_review_contract(contract)
merged.update(inline)
_validate_review_contract(merged)
return _sanitize_review_contract(merged)
def _inline_review_contract(contract: dict[str, Any]) -> dict[str, Any]:
inline: dict[str, Any] = {}
config = contract.get("config")
if isinstance(config, dict):
for key in ALLOWED_REVIEW_CONTRACT_KEYS:
if key in config:
inline[key] = config[key]
for key in ALLOWED_REVIEW_CONTRACT_KEYS:
if key in contract:
inline[key] = contract[key]
return inline
def _validate_review_contract(contract: dict[str, Any]) -> None:
unknown_keys = sorted(set(contract) - ALLOWED_REVIEW_CONTRACT_KEYS)
if unknown_keys:
raise PolicyError(f"unknown review contract keys: {', '.join(unknown_keys)}")
for key in ("blockingSeverity", "doneValues", "inProgressValues", "sourceOrder"):
values = contract.get(key)
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
raise PolicyError(f"review contract {key} must be a string array")
if not isinstance(contract.get("syncSprintStatus"), bool):
raise PolicyError("review contract syncSprintStatus must be a boolean")
if not _sanitize_string_list(contract["doneValues"]):
raise PolicyError("review contract doneValues must not be empty")
source_order = _sanitize_string_list(contract["sourceOrder"])
if not source_order:
raise PolicyError("review contract sourceOrder must not be empty")
invalid_sources = sorted({value for value in source_order if value not in ALLOWED_REVIEW_SOURCES})
if invalid_sources:
raise PolicyError(f"review contract sourceOrder contains unknown sources: {', '.join(invalid_sources)}")
def _parse_int(value: Any, field: str, *, minimum: int | None = None) -> int:
if isinstance(value, bool):
raise PolicyError(f"{field} must be an integer")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise PolicyError(f"{field} must be an integer") from exc
if minimum is not None and parsed < minimum:
raise PolicyError(f"{field} must be >= {minimum}")
return parsed
def _epic_identifier(project_root: str, story_key: str) -> str:
if re.fullmatch(r"\d+", story_key):
return story_key
norm = normalize_story_key(project_root, story_key)
if norm is None:
return ""
return norm.id.split(".", 1)[0]
def _sanitize_review_contract(contract: dict[str, Any]) -> dict[str, Any]:
return {
"blockingSeverity": _sanitize_string_list(contract["blockingSeverity"]),
"doneValues": _sanitize_string_list(contract["doneValues"]),
"inProgressValues": _sanitize_string_list(contract["inProgressValues"]),
"sourceOrder": _sanitize_string_list(contract["sourceOrder"]),
"syncSprintStatus": contract["syncSprintStatus"],
}
def _sanitize_string_list(values: list[str]) -> list[str]:
return [value.strip() for value in values if value.strip()]
VerifierFn = Callable[..., dict[str, object]]
VERIFIERS: dict[str, VerifierFn] = {
"create_story_artifact": create_story_artifact,
"session_exit": session_exit,
"review_completion": review_completion,
"epic_complete": epic_complete,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_COMMAND_TIMEOUT = 600
COMMAND_TIMEOUT_EXIT = 124
@dataclass
class CommandResult:
output: str
exit_code: int
error: Exception | None = None
def __iter__(self):
yield self.output
yield self.exit_code
def __getitem__(self, index: int):
if index == 0:
return self.output
if index == 1:
return self.exit_code
raise IndexError(index)
def now_utc() -> datetime:
return datetime.now(timezone.utc)
def now_utc_z() -> str:
return now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
def write_json(payload: Any) -> None:
print(json.dumps(payload, separators=(",", ":")))
def read_text(path: str | Path) -> str:
return Path(path).read_text(encoding="utf-8")
def file_exists(path: str | Path) -> bool:
return Path(path).is_file()
def dir_exists(path: str | Path) -> bool:
return Path(path).is_dir()
def ensure_dir(path: str | Path) -> None:
Path(path).mkdir(parents=True, exist_ok=True)
def write_atomic(path: str | Path, data: str | bytes) -> None:
path = Path(path)
ensure_dir(path.parent)
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
mode = "wb" if isinstance(data, bytes) else "w"
with os.fdopen(fd, mode) as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(tmp)
def atomic_write(path: str | Path, data: str | bytes) -> None:
write_atomic(path, data)
def run_cmd(
*args: str,
cwd: str | Path | None = None,
env: dict[str, str] | None = None,
timeout: int = DEFAULT_COMMAND_TIMEOUT,
) -> CommandResult:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
try:
completed = subprocess.run(
args,
cwd=str(cwd) if cwd else None,
env=merged_env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout,
check=False,
)
return CommandResult(completed.stdout, completed.returncode)
except subprocess.TimeoutExpired as exc:
output = ""
if exc.stdout:
output = exc.stdout.decode() if isinstance(exc.stdout, bytes) else exc.stdout
return CommandResult(output, COMMAND_TIMEOUT_EXIT, exc)
except FileNotFoundError as exc:
return CommandResult("", 127, exc)
def get_pwd() -> str:
return os.getcwd()
def get_project_root() -> str:
return os.environ.get("PROJECT_ROOT", get_pwd())
def get_project_slug(project_root: str | None = None) -> str:
root = Path(project_root or get_project_root())
value = re.sub(r"[^a-z0-9]", "", root.name.lower())[:8]
return value or "project"
def md5_hex8(text: str) -> str:
return hashlib.md5(text.encode(), usedforsecurity=False).hexdigest()[:8]
def get_project_hash(project_root: str | None = None) -> str:
return md5_hex8(str(Path(project_root or get_project_root()).resolve()))
def project_slug(project_root: str | None = None) -> str:
return get_project_slug(project_root)
def project_hash(project_root: str | None = None) -> str:
return get_project_hash(project_root)
def trim_lines(text: str) -> list[str]:
return [line.rstrip("\r") for line in text.splitlines()]
def filter_input_box(text: str) -> str:
lines = text.splitlines()
output: list[str] = []
in_box = False
for line in lines:
if re.match(r"^\s*[╭┌]", line):
in_box = True
continue
if re.match(r"^\s*[╰└]", line):
in_box = False
continue
if in_box and re.match(r"^\s*[│|]", line):
continue
output.append(line)
return "\n".join(output)
def is_help_flag(value: str) -> bool:
return value in {"--help", "-h"}
def help_flag(value: str) -> bool:
return is_help_flag(value)
def unquote_scalar(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value
def parse_string_list_literal(raw: str) -> list[str] | None:
raw = raw.strip()
if not raw:
return None
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return None
if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
return parsed
return None
def default_string(value: str | None, default: str) -> str:
return value if value else default
def count_matches(text: str, pattern: str) -> int:
return len(re.findall(pattern, text, flags=re.IGNORECASE | re.MULTILINE))
def extract_json_line(text: str) -> str:
for line in trim_lines(text):
for match in re.findall(r"\{.*\}", line):
try:
json.loads(match)
except json.JSONDecodeError:
continue
return match
return ""
def truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return False
def iso_now() -> str:
return now_utc_z()
def print_json(payload: Any) -> None:
write_json(payload)
def command_exists(name: str) -> bool:
return shutil.which(name) is not None

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
from story_automator.core.runtime_policy import load_effective_policy, step_contract
@dataclass(frozen=True)
class WorkflowPaths:
skill: str = ""
workflow: str = ""
instructions: str = ""
checklist: str = ""
template: str = ""
def _paths_for_step(step: str, project_root: str | None = None) -> WorkflowPaths:
files = (step_contract(load_effective_policy(project_root), step).get("assets") or {}).get("files") or {}
return WorkflowPaths(
skill=str(files.get("skill") or ""),
workflow=str(files.get("workflow") or ""),
instructions=str(files.get("instructions") or ""),
checklist=str(files.get("checklist") or ""),
template=str(files.get("template") or ""),
)
def create_story_workflow_paths(project_root: str | None = None) -> WorkflowPaths:
return _paths_for_step("create", project_root)
def dev_story_workflow_paths(project_root: str | None = None) -> WorkflowPaths:
return _paths_for_step("dev", project_root)
def retrospective_workflow_paths(project_root: str | None = None) -> WorkflowPaths:
return _paths_for_step("retro", project_root)
def review_workflow_paths(project_root: str | None = None) -> WorkflowPaths:
return _paths_for_step("review", project_root)
def testarch_automate_workflow_paths(project_root: str | None = None) -> WorkflowPaths:
return _paths_for_step("auto", project_root)

View File

@@ -0,0 +1,139 @@
---
name: 'step-01-init'
description: 'Check for existing state and route appropriately'
nextStep: './step-02-preflight.md'
continueStep: './step-01b-continue.md'
outputFolder: '{output_folder}/story-automator'
outputFile: '{outputFolder}/init-log-{timestamp}.md'
rules: '../data/orchestrator-rules.md'
scripts: '../scripts/story-automator'
ensureStopHook: '../scripts/story-automator'
stateHelper: '../scripts/story-automator'
settingsFile: '{project-root}/.claude/settings.json'
---
# Step 1: Initialize
**Goal:** Verify safeguards, check for existing state → resume or start fresh.
---
## Do
### 1. Verify Stop Hook Installation
**CRITICAL:** The Stop hook prevents premature stopping during orchestration.
Use script to ensure the Stop hook exists:
```bash
result=$("{ensureStopHook}" ensure-stop-hook --settings "{settingsFile}" \
--command "{scripts} stop-hook" --timeout 10)
ok=$(echo "$result" | jq -r '.ok')
changed=$(echo "$result" | jq -r '.changed')
verification_state=$(echo "$result" | jq -r '.verificationState // "verified"')
message=$(echo "$result" | jq -r '.message // ""') # Helper returns provider-specific restart/setup guidance for Claude or Codex.
```
The settings path is used for Claude; Codex resolves `.codex/hooks.json` and `.codex/config.toml` from the project root.
**IF ok == false:** Report error and STOP.
**IF changed == true:**
Display:
```
**Stop Hook Installed**
<message from helper>
This prevents the orchestrator from randomly stopping mid-workflow.
⚠️ **Please restart this active agent session** for the hook to take effect.
After restarting, run the story-automator workflow again.
```
**HALT** - Do not proceed until user restarts
**IF verification_state == "pending_trust":**
Display:
```
**Stop Hook Pending Codex Trust**
<message from helper>
Trust this project in Codex, then restart Codex and run the story-automator workflow again.
```
**HALT** - Do not proceed until Codex can run the hook
**IF changed == false:**
Display: "✓ Stop hook verified"
Continue to step 2
### 2. Load Rules
Load `{rules}` once. These apply to all subsequent steps.
### 3. Check for Existing State
Search `{outputFolder}` for `orchestration-*.md` files.
Use deterministic state listing:
```bash
state_list=$("{stateHelper}" orchestrator-helper state-list "{outputFolder}")
latest_incomplete=$(echo "$state_list" | jq -r '.files | map(select(.status == "COMPLETE" | not)) | sort_by(.lastUpdated) | last | .path // empty')
```
**IF latest_incomplete is non-empty:**
- Display: "**Found existing orchestration in progress.**"
- Show: epic name, current story, current step, last updated
- → Load `{continueStep}`
- **STOP** (don't continue below)
**IF none found:**
- Continue to step 4
### 4. Welcome
Display:
```
**Welcome to Story Automator.**
I'll automate story implementation by spawning isolated sessions,
handling code review loops, and committing completed stories.
Everything is logged for full resumability.
```
### 5. Check Sprint Status (MANDATORY)
```bash
has_status=$("{stateHelper}" orchestrator-helper sprint-status exists)
sprint_ok=$(echo "$has_status" | jq -r '.exists')
```
**IF sprint_ok == false:** ABORT immediately.
Display:
```
**❌ Sprint status file not found.**
Expected: `_bmad-output/implementation-artifacts/sprint-status.yaml`
This file is required before running the story automator.
Please run the **sprint-planning** workflow first to generate it.
```
**HALT** - Do not proceed.
**IF sprint_ok == true:**
- Store for later reference during preflight
- Will be used to check if earlier stories need completion
### 6. Setup
Ensure `{outputFolder}` exists.
Append an initialization entry to `{outputFile}`:
```bash
printf \"[%s] init: stop-hook=%s existing_state=%s\\n\" \
\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"${changed}\" \"${latest_incomplete}\" >> \"{outputFile}\"
```
**Note:** Marker file path is resolved by `orchestrator-helper marker path` in step-02b-preflight-finalize after epic/story context is established.
---
## Then
→ Load `{nextStep}`

View File

@@ -0,0 +1,200 @@
---
name: 'step-01b-continue'
description: 'Handle workflow continuation from previous session'
outputFolder: '{output_folder}/story-automator'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
preflightStep: './step-02-preflight.md'
preflightConfigStep: './step-02a-preflight-config.md'
preflightFinalizeStep: './step-02b-preflight-finalize.md'
executeStep: './step-03-execute.md'
executeReviewStep: './step-03a-execute-review.md'
executeFinishStep: './step-03b-execute-finish.md'
executeCompleteStep: './step-03c-execute-complete.md'
wrapupStep: './step-04-wrapup.md'
stateFilePattern: '{outputFolder}/orchestration-*.md'
stateHelper: '../scripts/story-automator'
ensureMarkerGitignore: '../scripts/story-automator'
deriveProjectSlug: '../scripts/story-automator'
listSessions: '../scripts/story-automator'
sprintCompare: '../scripts/story-automator'
tmuxCommands: '../data/tmux-commands.md'
# Optional: provided by workflow.md when using Resume mode (skips state search)
resumeStatePath: ''
---
# Step 1b: Continue Previous Session
**Goal:** Load existing state and let user choose how to proceed.
---
## Do
### 1. Load State Document
**IF `{resumeStatePath}` is provided (from workflow.md Resume routing):**
Use it directly: `state_file="{resumeStatePath}"`
**ELSE (called from step-01-init or no path provided):**
Find the most recent incomplete state document using `{stateFilePattern}`:
```bash
result=$("{stateHelper}" orchestrator-helper state-latest-incomplete "{outputFolder}")
state_file=$(echo "$result" | jq -r '.path // empty')
```
**IF state_file is empty:** Display "No incomplete orchestration found." and HALT.
**Then extract from state_file:**
- `epic`, `epicName`, `storyRange`
- `currentStep`, `status`
- `stepsCompleted`, `storiesCompleted`
- Last action from action log
Use deterministic summary:
```bash
summary=$("{stateHelper}" orchestrator-helper state-summary "$state_file")
```
### 2. Verify Against Sprint Status
Load `_bmad-output/implementation-artifacts/sprint-status.yaml`.
**Compare with state document (run in parallel with session inventory):**
- Check if earlier stories (before `currentStory`) are marked `done` in sprint-status
- If any earlier stories are NOT `done`:
```
**Warning:** Stories {X, Y} are not complete in sprint-status.yaml.
[B]atch them first - Add to queue before continuing
[S]kip - Continue from current story anyway
```
**Wait.**
- If B: Add incomplete stories to beginning of queue
- If S: Note skip in action log, continue
Use deterministic parallel baseline:
```bash
tmp_compare=$(mktemp)
tmp_sessions=$(mktemp)
("{sprintCompare}" sprint-compare --state "$state_file" --sprint "_bmad-output/implementation-artifacts/sprint-status.yaml" > "$tmp_compare") &
compare_pid=$!
project_slug=$(echo "$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}")" | jq -r '.slug')
("{listSessions}" list-sessions --slug "$project_slug" > "$tmp_sessions") &
sessions_pid=$!
wait "$compare_pid"
wait "$sessions_pid"
compare=$(cat "$tmp_compare")
sessions=$(cat "$tmp_sessions")
rm -f "$tmp_compare" "$tmp_sessions"
incomplete=$(echo "$compare" | jq -r '.incomplete | join(", ")')
session_count=$(echo "$sessions" | jq -r '.count')
```
### 3. Check Active Sessions
Using `{tmuxCommands}`, check for existing T-Mux sessions for THIS PROJECT ONLY.
**Generate project slug first:**
```bash
project_slug=$(echo "$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}")" | jq -r '.slug')
```
**Then list sessions matching:** `sa-{project_slug}-*`
This ensures we only see sessions spawned by THIS project's story-automator, not sessions from other projects.
Use `sessions` and `session_count` from step 2 parallel baseline.
### 4. Present Status
```
**Resuming: {epicName}**
Status: {status}
Progress: {storiesCompleted}/{totalStories} stories
Current: Story {N}, Step: {currentStep}
Last action: {lastAction}
Active sessions: {count or 'None'}
```
### 5. Present Options
```
[R]esume - Continue from where you left off
[V]iew - See action log details
[M]odify - Change overrides or context
[S]tart Over - Restart this epic (keeps backup)
[X]Abort - Cancel orchestration
```
**Wait for user input.**
#### Menu Handling Logic:
- IF R: Create marker file, then route based on `status` and `currentStep`:
- READY → `{preflightFinalizeStep}`
- INITIALIZING → `{preflightConfigStep}`
- IN_PROGRESS / PAUSED → route by `currentStep`:
- `step-03-execute` or `create` or `dev` → `{executeStep}`
- `step-03a-execute-review` or `auto` or `review` → `{executeReviewStep}`
- `step-03b-execute-finish` or `commit` or `retro` → `{executeFinishStep}`
- `step-03c-execute-complete` → `{executeCompleteStep}`
- (default) → `{executeStep}`
- EXECUTION_COMPLETE → `{wrapupStep}`
- COMPLETE → `{wrapupStep}`
- ABORTED → display warning and redisplay this menu
- IF V: Show last 20 action log entries, then redisplay this menu
- IF M: Allow override changes, save, then redisplay this menu
- IF S: Rename state to `.backup-{timestamp}` then load `{preflightStep}` (new state will be created at `{outputFile}`)
- IF X: Set status="ABORTED", display confirmation, end workflow
- IF Any other: help user respond, then redisplay this menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- ONLY route to a step after handling the selected option
- After non-routing options, return to this menu
- Keep prompts concise; if user is unsure, ask one clarifying question before redisplaying options
### 6. Handle Choice
| Choice | Action |
|--------|--------|
| **R** | **First:** Create marker file (see below), **then** route based on `status` |
| **V** | Show last 20 action log entries → redisplay options |
| **M** | Allow override changes, save → redisplay options |
| **S** | Rename state to `.backup-{timestamp}` → `{preflightStep}` |
| **X** | Set status="ABORTED", display confirmation, end workflow |
#### On [R]esume: Create Marker File BEFORE Routing
**CRITICAL:** Only create marker file when user confirms resume. This prevents stop hook from firing during menu wait.
Create the active runtime marker with orchestration context:
```json
{
"epic": "{epic}",
"currentStory": "{currentStory}",
"storiesRemaining": {remaining_count},
"stateFile": "{state_document_path}",
"startedAt": "{timestamp}"
}
```
Use deterministic marker creation:
```bash
marker_info=$("{stateHelper}" orchestrator-helper marker path)
marker_entry=$(echo "$marker_info" | jq -r '.entry')
"{ensureMarkerGitignore}" ensure-marker-gitignore --gitignore ".gitignore" --entry "$marker_entry"
"{stateHelper}" orchestrator-helper marker create --epic "{epic}" --story "{currentStory}" \
--remaining {remaining_count} --state-file "{state_document_path}" \
--project-slug "$project_slug" --pid "$$" --heartbeat "{timestamp}"
```
**Then** route per Menu Handling Logic in section 5 above.
---
## Then
→ Load appropriate step based on choice

View File

@@ -0,0 +1,200 @@
---
name: 'step-02-preflight'
description: 'Gather epic, story selection, and complexity analysis'
nextStep: './step-02a-preflight-config.md'
outputFolder: '{output_folder}/story-automator'
outputFile: '{outputFolder}/preflight-{epic_id}-{timestamp}.md'
parseEpic: '../scripts/story-automator'
parseStoryRange: '../scripts/story-automator'
parseStory: '../scripts/story-automator'
stateHelper: '../scripts/story-automator'
defaultEpicPath: '{output_folder}/planning-artifacts/epics.md'
defaultSprintStatusFile: '{output_folder}/implementation-artifacts/sprint-status.yaml'
complexityRules: '../data/complexity-rules.json'
complexityScoring: '../data/complexity-scoring.md'
preflightRequirements: '../data/preflight-requirements.md'
---
# Step 2: Pre-flight (Epic + Complexity)
**Goal:** Gather epic, story range, complexity analysis, and custom instructions.
**Interaction mode:** Collaborative discovery and clarification.
---
## 🚨 BEFORE STARTING: Load Requirements
**CRITICAL:** Load and read `{preflightRequirements}` FIRST. It contains MANDATORY sequence rules, FORBIDDEN patterns, and verification gates that MUST be followed.
---
## Do
### 1. Confirm Epic File
```
**Epic source**
Default epic file: `{defaultEpicPath}`
Use this file? [Y/n]
```
If user confirms (Y/Enter), set `epic_path="{defaultEpicPath}"`.
If user says no, ask for epic file path and set `epic_path` from response.
If confirmed default does not exist, tell user and request explicit path.
**Wait.**
### 2. Review Epic
Parse epic file deterministically:
```bash
epic_json=$("{parseEpic}" parse-epic --file "{epic_path}")
epic_name=$(echo "$epic_json" | jq -r '.epicTitle')
story_count=$(echo "$epic_json" | jq -r '.count')
story_titles=$(echo "$epic_json" | jq -r '.stories[] | "\(.storyId) \(.title)"')
story_ids_csv=$(echo "$epic_json" | jq -r '.stories[] | .storyId' | paste -sd, -)
sprint_exists=$("{stateHelper}" orchestrator-helper sprint-status exists)
story_status_rows="(sprint-status unavailable at {defaultSprintStatusFile})"
if [ "$sprint_exists" = "true" ]; then
story_status_rows=$(echo "$epic_json" | jq -r '.stories[] | .storyId' | while read -r sid; do
status_json=$("{stateHelper}" orchestrator-helper sprint-status get "$sid")
st=$(echo "$status_json" | jq -r '.status // "unknown"')
printf -- "- %s | %s\n" "$sid" "$st"
done)
fi
```
Display:
```
**Epic:** {epic_name}
Stories found:
1. {storyId} {title}
2. {storyId} {title}
...
Total: {story_count}
Current sprint-status ({defaultSprintStatusFile}):
{story_status_rows}
Which stories? (e.g., `1-3`, `all`, `1,3,5`)
```
If user hesitates, suggest `all` as default and confirm.
**Wait.**
### 3. Read Stories and Compute Complexity (MANDATORY - DO NOT SKIP)
> **🚨 CRITICAL:** This step MUST use the Python helper for complexity scoring. NEVER manually assess complexity by reading story content.
For each story in range, extract complexity **programmatically**:
**3a. Parse story range:**
```bash
range_json=$("{parseStoryRange}" parse-story-range --input "{user_selection}" --total "$story_count" --ids "$story_ids_csv")
selected_ids=$(echo "$range_json" | jq -r '.storyIds[]')
selected_count=$(echo "$range_json" | jq -r '.count')
first_story_id=$(echo "$range_json" | jq -r '.storyIds[0]')
epic_id=$(echo "$first_story_id" | cut -d. -f1)
```
**3b. Get complexity for EACH story using Python helper:**
```bash
# Initialize accumulator - REQUIRED
stories_json='[]'
# For EACH story_id in selected_ids, run:
story_json=$("{parseStory}" parse-story --epic "{epic_path}" --story "$story_id" --rules "{complexityRules}")
# Extract and accumulate - REQUIRED
story_title=$(echo "$story_json" | jq -r '.title')
story_level=$(echo "$story_json" | jq -r '.complexity.level')
story_score=$(echo "$story_json" | jq -r '.complexity.score')
story_reasons=$(echo "$story_json" | jq -r '.complexity.reasons // []')
stories_json=$(echo "$stories_json" | jq -c --arg id "$story_id" --arg title "$story_title" --arg level "$story_level" --argjson score "$story_score" --argjson reasons "$story_reasons" \
'. + [{storyId:$id,title:$title,complexity:{level:$level,score:$score,reasons:$reasons}}]')
```
Refer to `{complexityScoring}` for scoring criteria and thresholds.
**Parallelism Policy (MANDATORY):**
- If `selected_count >= 4`: run per-story complexity parsing in parallel subprocesses (max 4 workers).
- If `selected_count < 4`: run sequentially.
- In both modes, return only summary fields to parent context: `storyId`, `title`, `complexity.level`, `complexity.score`, `complexity.reasons`.
```bash
# Deterministic threshold
if [ "$selected_count" -ge 4 ]; then
# Parallel mode (max 4 workers)
tmp_story_complexity=$(mktemp)
printf "%s\n" $selected_ids | xargs -I{} -P 4 sh -c '
"{parseStory}" parse-story --epic "{epic_path}" --story "{}" --rules "{complexityRules}" \
| jq -c "{storyId:.storyId,title:.title,complexity:.complexity}"
' > "$tmp_story_complexity"
stories_json=$(jq -s '.' "$tmp_story_complexity")
rm -f "$tmp_story_complexity"
else
# Sequential mode
stories_json='[]'
for story_id in $selected_ids; do
story_json=$("{parseStory}" parse-story --epic "{epic_path}" --story "$story_id" --rules "{complexityRules}")
stories_json=$(echo "$stories_json" | jq -c --argjson s "$(echo "$story_json" | jq -c '{storyId:.storyId,title:.title,complexity:.complexity}')" '. + [$s]')
done
fi
```
**3c. Display Complexity Matrix (REQUIRED):**
Display the Complexity Matrix using the template from `{preflightRequirements}`.
**3d. VERIFICATION GATE:**
Follow the verification gate from `{preflightRequirements}` before proceeding.
---
### 4. Custom Instructions
```
**Any custom instructions?**
Examples:
- "Always run tests after changes"
- "Prioritize stories 3 and 5"
- "Be extra careful with database migrations"
- "Use strict typing throughout"
Enter instructions or 'none':
```
If user is unsure, recommend `none` and continue.
**Wait.**
Store response as `custom_instructions` (use "" for none).
### 5. Proceed to Configuration
Persist preflight snapshot before continuing:
```bash
mkdir -p "{outputFolder}"
cat > "{outputFile}" <<EOF
# Preflight Snapshot
- Timestamp: {timestamp}
- Epic path: {epic_path}
- Epic name: {epic_name}
- Story count: {story_count}
- Selected count: {selected_count}
- Selected IDs: {selected_ids}
- Custom instructions: {custom_instructions}
## Complexity Summary
$(echo "$stories_json" | jq -r '.[] | "- \(.storyId) | \(.complexity.level) | score=\(.complexity.score)"')
EOF
```
Carry forward: `epic_path`, `epic_name`, `story_count`, `story_ids_csv`, `range_json`, `selected_ids`, `selected_count`, `stories_json`, `epic_id`, `first_story_id`, `custom_instructions`.
---
## Then
→ Load and execute `{nextStep}`

View File

@@ -0,0 +1,162 @@
---
name: 'step-02a-preflight-config'
description: 'Configure agents and execution settings, then create state document'
nextStep: './step-02b-preflight-finalize.md'
stateTemplate: '../templates/state-document.md'
outputFolder: '{output_folder}/story-automator'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
buildStateDoc: '../scripts/story-automator'
agentConfigPrompts: '../data/agent-config-prompts.md'
agentConfigPresets: '../data/agent-config-presets.json'
---
# Step 2a: Pre-flight Configuration
**Goal:** Configure agents and execution settings, then create the orchestration state document.
**Interaction mode:** Guided configuration (collaborative inputs, deterministic state creation).
---
## Prerequisites
- Step 2 completed.
- Variables available: `epic_id`, `epic_name`, `range_json`, `stories_json`, `selected_count`, `custom_instructions`.
---
## Do
### 1. Configure Execution Preferences
> **PREREQUISITE:** Step 2 (preflight) MUST be complete. The Complexity Matrix MUST have been displayed. If not, STOP and complete step 2 first.
```
**Execution Settings:**
1. **Skip the 'automate' step (test automation)?** [N]o (default) / [Y]es
2. **Max parallel sessions?** (tmux sessions running concurrently, default: 1)
Enter choices (e.g., `N 1` or `Y 3`):
```
**Wait.**
Store responses as `skip_automate` (true/false) and `max_parallel` (integer).
### 2. Configure Agent (Complexity-Aware)
Using the complexity data from `stories_json`, present agent configuration options that reference the actual complexity breakdown.
**2a. Check for Saved Presets**
```bash
presets_result=$("{buildStateDoc}" agent-config list --file "{agentConfigPresets}")
preset_count=$(echo "$presets_result" | jq -r '.count')
```
Store `preset_count` — this determines whether [L]oad option appears in the menu.
**2b. Present Complexity-Based Agent Options**
Display prompts from `{agentConfigPrompts}`, selecting the appropriate table variant:
- If `skip_automate` is false: show table WITH `auto` column
- If `skip_automate` is true: show table WITHOUT `auto` column
- If `preset_count > 0`: include [L]oad saved option
- If `preset_count == 0`: omit [L] option
**Wait.**
**2c. Handle Selection**
- **IF S:** Build `agent_config_json` from defaults (no save prompt).
- **IF U or C:** Follow Uniform/Custom prompts from `{agentConfigPrompts}`, build `agent_config_json`, then proceed to **2d (Save Prompt)**.
- **IF L:** Follow Load Saved Preset prompt from `{agentConfigPrompts}`. Load preset config as `agent_config_json` (no save prompt).
```bash
# Example shape with complexity-based config (auto column included when not skipped)
agent_config_json='{
"complexityBased": true,
"low": {"create":{"primary":"...","fallback":"..."},"dev":{...},"auto":{...},"review":{...}},
"medium": {"create":{...},"dev":{...},"auto":{...},"review":{...}},
"high": {"create":{...},"dev":{...},"auto":{...},"review":{...}},
"auto": {"skip": $skip_automate}
}'
```
Store:
- `agent_config_json` = full config object
- `primary_agent` = default primary (for backwards compatibility)
**2d. Save Prompt (U/C only)**
Only when user chose **[U]niform** or **[C]ustom**, follow the Save Configuration prompt from `{agentConfigPrompts}`:
```bash
# If user provides a name:
"{buildStateDoc}" agent-config save --file "{agentConfigPresets}" --name "$save_name" --config-json "$agent_config_json"
```
### 3. Review
Display configuration summary:
- Epic and story range
- Custom instructions (if any)
- Agent configuration
- Execution settings
Pause for confirmation before starting execution.
### 3b. Confirm Autonomous Start (Optional Checkpoint)
Before creating state and launching autonomous phases, confirm:
```
Proceed with autonomous execution after preflight? [Y/n]
```
**Wait.**
- If `Y`/Enter: continue.
- If `n`: return to Step 1 (settings) for adjustments.
### 4. Create State Document
From `{stateTemplate}`:
- Generate: `orchestration-{epic_id}-{timestamp}.md`
- Fill frontmatter with all config
- Initialize story progress table
- Set status: "READY"
- Save to `{outputFolder}`
Deterministic creation:
```bash
agent_cmd="claude --dangerously-skip-permissions"
if [ "$primary_agent" = "codex" ]; then agent_cmd="codex exec --full-auto"; fi
config_json=$(jq -n \
--arg epic "$epic_id" \
--arg epicName "$epic_name" \
--argjson storyRange "$(echo "$range_json" | jq '.storyIds')" \
--arg status "READY" \
--arg currentStory "null" \
--arg currentStep "preflight" \
--arg aiCommand "$agent_cmd" \
--arg customInstructions "$custom_instructions" \
--argjson overrides "{\"skipAutomate\":$skip_automate,\"maxParallel\":$max_parallel}" \
--argjson agentConfig "$agent_config_json" \
'{epic:$epic,epicName:$epicName,storyRange:$storyRange,status:$status,currentStory:null,currentStep:$currentStep,aiCommand:$aiCommand,customInstructions:$customInstructions,overrides:$overrides,agentConfig:$agentConfig}'
)
state_result=$("{buildStateDoc}" build-state-doc --template "{stateTemplate}" --output-folder "{outputFolder}" --config-json "$config_json")
state_path=$(echo "$state_result" | jq -r '.path')
```
Display: "**State document created.**"
Record: `state_path` is the resolved `{outputFile}` for this run.
### 5. Auto-Proceed to Finalize
Persist any preflight notes to `{outputFile}`, update frontmatter (append `step-02-preflight` and `step-02a-preflight-config`, set `lastUpdated`).
---
## Then
→ Load, read entire file, and execute `{nextStep}`

View File

@@ -0,0 +1,79 @@
---
name: 'step-02b-preflight-finalize'
description: 'Finalize preflight and start execution'
nextStep: './step-03-execute.md'
outputFolder: '{output_folder}/story-automator'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
stateHelper: '../scripts/story-automator'
ensureMarkerGitignore: '../scripts/story-automator'
deriveProjectSlug: '../scripts/story-automator'
markerFormat: '../data/marker-file-format.md'
---
# Step 2b: Pre-flight Finalize
**Goal:** Finalize preflight artifacts, create marker, and start execution.
**Interaction mode:** Deterministic auto-proceed.
---
## Do
### 1. Create Complexity + Agents Files
Derive deterministic filenames:
```bash
state_base=$(basename "{outputFile}" .md)
complexity_path="{outputFolder}/complexity-${state_base}.json"
agents_dir="{outputFolder}/agents"
agents_path="$agents_dir/agents-${state_base}.md"
```
Write complexity file:
```bash
mkdir -p "$(dirname "$complexity_path")"
echo "$stories_json" | jq -c '{stories:.}' > "$complexity_path"
```
Build deterministic agents file:
```bash
mkdir -p "$agents_dir"
"{stateHelper}" orchestrator-helper agents-build \
--state-file "{outputFile}" \
--complexity-file "$complexity_path" \
--output "$agents_path" \
--config-json "$agent_config_json"
```
Update state frontmatter with file paths:
```bash
agents_path_json=$(printf '%s' "$agents_path" | jq -R '.')
complexity_path_json=$(printf '%s' "$complexity_path" | jq -R '.')
"{stateHelper}" orchestrator-helper state-update "{outputFile}" \
--set "agentsFile=$agents_path_json" \
--set "complexityFile=$complexity_path_json"
```
### 2. Create Marker and Begin Execution
**Create marker file** (see `{markerFormat}` for JSON structure):
```bash
# Resolve the active marker path for the selected runtime layout and gitignore it.
marker_info=$("{stateHelper}" orchestrator-helper marker path)
marker_entry=$(echo "$marker_info" | jq -r '.entry')
"{ensureMarkerGitignore}" ensure-marker-gitignore --gitignore ".gitignore" --entry "$marker_entry"
# Create marker
project_slug=$(echo "$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}")" | jq -r '.slug')
"{stateHelper}" orchestrator-helper marker create --epic "$epic_id" --story "$first_story_id" \
--remaining "$selected_count" --state-file "{outputFile}" \
--project-slug "$project_slug" --pid "$$" --heartbeat "{timestamp}"
```
Set status="IN_PROGRESS", log "Execution started".
Update frontmatter (append `step-02b-preflight-finalize`, set `lastUpdated`).
---
## Then
→ Load, read entire file, and execute `{nextStep}`

View File

@@ -0,0 +1,199 @@
---
name: 'step-03-execute'
description: 'Autonomous execution loop - create and dev stories'
nextStep: './step-03a-execute-review.md'
dataFileIndex: '../data/data-file-index.md'
scriptsDir: '../scripts/story-automator'
outputFolder: '{output_folder}/story-automator'
stateFilePattern: '{outputFolder}/orchestration-*.md'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
retryStrategy: '../data/retry-fallback-strategy.md'
executionPatterns: '../data/execution-patterns.md'
subagentPrompts: '../data/subagent-prompts.md'
---
## 🚨 CRITICAL: Load Data File Index FIRST
**BEFORE ANY EXECUTION**, load and read `{dataFileIndex}` completely.
**DO NOT proceed until you have read the index and loaded the required files.**
---
Set: `scripts="{scriptsDir}"`
## 🚨 CRITICAL: CLI Contract Check (Interface Drift Guard)
Before running any story loop logic, verify required helper commands/flags still exist.
```bash
# Core command availability
"$scripts" tmux-wrapper --help >/dev/null
"$scripts" monitor-session --help >/dev/null
"$scripts" orchestrator-helper --help >/dev/null
# Required spawn contract: --command must exist
"$scripts" tmux-wrapper spawn --help | grep -q -- "--command"
# Build command contract must be available
"$scripts" tmux-wrapper build-cmd --help >/dev/null
```
If any check fails: **STOP and escalate immediately** with "helper CLI contract changed".
---
# Step 3: Execute Build Cycle
**Goal:** Autonomously execute all stories. Escalate only when decisions needed.
**Interaction mode:** Deterministic autonomous execution.
---
## Setup
Load from state document (located via `{stateFilePattern}`; output folder `{outputFolder}`; resolved path stored as `{outputFile}` for this run):
- `storyRange`, `currentStory`, `currentStep`
- `overrides` (skipAutomate, maxParallel)
- `customInstructions`
Resolve agent configuration using deterministic agents file (see `{retryStrategy}` for full function):
```bash
state_file="{outputFile}"
# resolve_agent_for_task "{task}" "$state_file" "{story_id}" -> sets primary_agent,fallback_agent
```
**IF resuming** (currentStory set): Skip to that point in loop.
**IF fresh**: Display "**Starting build cycle for {count} stories...**"
## 🚨 CRITICAL: Execution Patterns
**BEFORE executing any steps, read `{executionPatterns}` for:**
- FORBIDDEN patterns (never chain multiple workflow steps)
- REQUIRED patterns (verify state after each step)
- Monitoring failure fallback sequence
**Key rule:** Each step (create/dev/auto/review) MUST be executed and monitored separately. NEVER chain steps in loops.
## Story Loop
> **⚠️ SPAWN PATTERN - READ THIS:**
> Every `story-automator tmux-wrapper spawn` call **MUST** include `--command` with the built command:
> ```bash
> session=$("$scripts" tmux-wrapper spawn {step} {epic} {story_id} \
> --agent "$agent" \
> --command "$("$scripts" tmux-wrapper build-cmd {step} {story_id} --agent "$agent")")
> ```
> **Missing `--command` = session sits idle → `never_active` failure!**
**FOR EACH story in range:**
```bash
"$scripts" orchestrator-helper state-update "$state_file" \
--set currentStory={story_id} --set currentStep=step-03-execute \
--set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$state_file"
# Initialize Story Progress row
tmp_state=$(mktemp)
awk -v row="| {story_id} | - | - | - | - | - | in-progress |" '
/^<!-- Progress rows -->$/ { print row }
{ print }
' "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file"
```
Display: "**Story {N}/{total}: {title}**"
Use compact operator output format for routine progress:
```text
[story {N}/{total}] {step} -> {state} (agent={agent}, retries={attempts})
```
After any session completes (create/dev/auto/review): `"$scripts" tmux-wrapper kill "$session"`
**MANDATORY log pre-filter (all sessions):** Before any deep parsing, pre-filter logs with a single grep/regex pass and pass only focused output forward.
```bash
log_file=$(echo "$result" | jq -r '.output_file')
log_focus=$(grep -nE "SUCCESS|FAIL|ERROR|CRITICAL|WARN|RETRY|ESCALATE" "$log_file" | head -n 120)
if [ -z "$log_focus" ]; then
log_focus=$(tail -n 120 "$log_file")
fi
```
If multiple logs exist, run one grep/regex pass across all log files and forward only matched lines + file names.
**Compact result contract (required):**
- Return only: `next_action`, `confidence`, `error_class`, `retryable`, `reasons`, `session_id`
- Do not pass full raw logs to parent flow unless escalation explicitly requires evidence payload
### A. Create Story
*Skip if story file exists*
**Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents, network-aware delays.
```bash
# Retry loop: see {retryStrategy}
session=$("$scripts" tmux-wrapper spawn create {epic} {story_id} \
--agent "$current_agent" \
--command "$("$scripts" tmux-wrapper build-cmd create {story_id} --agent "$current_agent" --state-file "$state_file")")
result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
"$scripts" tmux-wrapper kill "$session"
validation=$("$scripts" orchestrator-helper verify-step create {story_id} --state-file "$state_file")
```
- If `validation.verified == true`:
```bash
# Update Story Progress: mark create-story done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | - | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file"
```
→ proceed to B
- If `validation.verified == false` AND attempts < 5 → retry with next agent (see `{retryStrategy}`)
- If `validation.verified == false` AND attempts == 5 → escalate (all retries exhausted)
### B. Dev Story
**Apply retry/fallback pattern from `{retryStrategy}`:** Up to 5 attempts, alternating agents.
```bash
# Retry loop with agent alternation: see {retryStrategy}
session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \
--agent "$current_agent" \
--command "$("$scripts" tmux-wrapper build-cmd dev {story_id} --agent "$current_agent" --state-file "$state_file")")
result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
"$scripts" tmux-wrapper kill "$session"
```
**Session Parsing Contract (required):**
- Preferred: use Session Output Parser prompt from `{subagentPrompts}` on `result.output_file`
- Fallback: use local parser below
- Return normalized schema only: `next_action`, `confidence`, `error_class`, `reasons`
```bash
parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" dev)
next_action=$(echo "$parsed" | jq -r '.next_action')
confidence=$(echo "$parsed" | jq -r '.confidence // 0.0')
error_class=$(echo "$parsed" | jq -r '.error_class // "none"')
reasons=$(echo "$parsed" | jq -c '.reasons // []')
```
- If `next_action == "proceed"`:
```bash
# Update Story Progress: mark dev-story done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file"
```
→ proceed to C (next step)
- If `next_action == "retry"` OR `result.final_state == "crashed"`:
- Attempts < 5 → retry with next agent (see `{retryStrategy}`)
- Plateau detected (same task 3x) → DEFER story, continue to next
- Attempts == 5 → escalate (all retries exhausted)
## Auto-Proceed to Review Phase
Display: "**Dev story complete. Proceeding to automate and code review...**"
```bash
"$scripts" orchestrator-helper state-update "$state_file" \
--set currentStep=step-03a-execute-review \
--set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Dev complete, proceeding to review phase" >> "$state_file"
```
## Then
→ Immediately load and execute `{nextStep}`

View File

@@ -0,0 +1,119 @@
---
name: 'step-03a-execute-review'
description: 'Autonomous execution loop - automate and code review'
nextStep: './step-03b-execute-finish.md'
scriptsDir: '../scripts/story-automator'
outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp}.md'
retryStrategy: '../data/retry-fallback-strategy.md'
reviewLoop: '../data/code-review-loop.md'
---
# Step 3a: Execute Review Phase
**Goal:** Run automate (guardrails) and code review loop for the current story.
**Interaction mode:** Deterministic autonomous execution.
---
## Prerequisites
- Step 3 completed (create-story and dev-story done)
- State document updated with current story progress
Set: `scripts="{scriptsDir}"`
---
## Story Loop (Continue from Step 3)
### C. Automate (Guardrails)
*Skip if `overrides.skipAutomate`*
**Apply retry/fallback pattern from `{retryStrategy}`:** Non-blocking, but still retry on failure.
```bash
# --command required (see Spawn Pattern in step-03)
session=$("$scripts" tmux-wrapper spawn auto {epic} {story_id} \
--agent "$current_agent" \
--command "$("$scripts" tmux-wrapper build-cmd auto {story_id} --agent "$current_agent" --state-file "$state_file")")
result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
"$scripts" tmux-wrapper kill "$session"
```
- SUCCESS:
```bash
# Update Story Progress: mark automate done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}"
```
Display: `[story {N}/{total}] automate -> done`
→ proceed to D
- FAILURE → retry up to 3 attempts (non-blocking, so fewer retries), then log warning:
```bash
# Update Story Progress: mark automate skipped
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | skip | - | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}"
```
Display: `[story {N}/{total}] automate -> skip (non-blocking)`
→ proceed to D
### D. Code Review Loop
**See `{reviewLoop}` for complete script-based review cycle with v2.3 per-task agent configuration.**
**MANDATORY log-summary contract (every review cycle):**
- Run a single grep/regex pass over review output first.
- Return only compact fields to parent flow: `next_action`, `confidence`, `error_class`, `issues_count`, `top_issues`.
- Do not carry full log payloads forward unless escalation requires raw evidence.
```bash
review_log=$(echo "$result" | jq -r '.output_file')
review_focus=$(grep -nE "SUCCESS|FAIL|ERROR|CRITICAL|WARN|RETRY|ESCALATE|ISSUE" "$review_log" | head -n 120)
if [ -z "$review_focus" ]; then
review_focus=$(tail -n 120 "$review_log")
fi
# Compact subprocess-style summary contract for parent flow
review_summary=$("$scripts" orchestrator-helper parse-output "$review_log" review --state-file "$state_file" | jq -c '
{
next_action: (.next_action // "retry"),
confidence: (.confidence // 0),
error_class: (.error_class // "unknown"),
issues_count: ((.issues // []) | length),
top_issues: ((.issues // [])[:3])
}
')
```
Key points:
- Up to 5 cycles using `story-automator tmux-wrapper spawn review` + `story-automator monitor-session`
- **Agent:** Uses per-task config from state document (`resolve_agent_for_task "review"`)
- **Verification:** Uses `--workflow review --story-key` for sprint-status verification
- **States:** `completed` (verified):
```bash
# Update Story Progress: mark code-review done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | - | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}"
```
Display: `[story {N}/{total}] review -> done`
→ E | `incomplete` → count as failed attempt, retry until maxCycles, then CRITICAL escalate (Trigger #8)
- Exit loop when sprint-status shows "done"
- If `review_summary.next_action` is ambiguous, ask one clarifying question before escalating.
---
## Auto-Proceed to Finalization
Display: "**Code review complete. Proceeding to finalize commits and status checks...**"
```bash
"$scripts" orchestrator-helper state-update "{outputFile}" \
--set currentStep=step-03b-execute-finish \
--set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Code review complete, proceeding to finalization" >> "{outputFile}"
```
---
## Then
→ Immediately load and execute `{nextStep}`

View File

@@ -0,0 +1,173 @@
---
name: 'step-03b-execute-finish'
description: 'Finalize each story (commit/status), trigger retrospective when epic complete, and finish execution loop'
nextStep: './step-03c-execute-complete.md'
scriptsDir: '../scripts/story-automator'
outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp}.md'
---
# Step 3b: Finalize Story + Wrap Execution
**Goal:** After code review completes for a story, commit changes, verify sprint status, update progress, and finish the loop.
**Interaction mode:** Deterministic autonomous execution.
---
## Story Loop (Continue from Step 3)
### E. Git Commit
**Required:** Commit after every story (do not skip).
```bash
commit=$("{scriptsDir}" commit-story --repo "{project-root}" --story {story_id} --title "{title}")
ok=$(echo "$commit" | jq -r '.ok')
```
- If `ok == true`:
```bash
# Update Story Progress: mark git-commit done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | in-progress |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}"
```
→ proceed to F
- If `ok == false` → log warning and escalate
### F. Verify Sprint Status
```bash
# Check sprint-status with story file fallback (v1.4.0)
normalized=$("{scriptsDir}" orchestrator-helper normalize-key {story_id})
story_key=$(echo "$normalized" | jq -r '.key')
status=$("{scriptsDir}" orchestrator-helper sprint-status get "$story_key")
is_done=$(echo "$status" | jq -r '.done')
# Fallback: trust story file if sprint-status disagrees
if [ "$is_done" != "true" ]; then
file_done=$("{scriptsDir}" orchestrator-helper story-file-status {story_id} | jq -r '.status')
[ "$file_done" = "done" ] && is_done="true"
fi
```
- If `is_done == false` → return to Code Review Loop (Step 3, section D)
- If `is_done == true` → proceed to G
### G. Story Complete
Display: "**✅ Story {N} complete.**"
```bash
"{scriptsDir}" orchestrator-helper state-update "{outputFile}" \
--set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Story {story_id}: ✅ complete (commit + sprint-status verified)" >> "{outputFile}"
# Update Story Progress: mark story done
tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | done |/" "{outputFile}" > "$tmp_state" && mv "$tmp_state" "{outputFile}"
```
Display: `[story {N}/{total}] finalize -> done`
### H. Check Epic Completion & Trigger Retrospective (Multi-Epic Support)
After each story completes, check if ALL stories in this epic are now done. Retrospective only triggers when every story in the epic has passed code review and sprint status confirms all are "done".
#### H.1 Check All Stories Done
```bash
# Run epic-level check in parallel with per-story checks
tmp_epic_status=$(mktemp)
("{scriptsDir}" orchestrator-helper sprint-status check-epic {epic_number} > "$tmp_epic_status") &
epic_status_pid=$!
# Get all stories for this epic and verify each is done
epic_stories=$("{scriptsDir}" orchestrator-helper get-epic-stories {epic_number} --state-file "{outputFile}")
stories_ok=$(echo "$epic_stories" | jq -r '.ok')
story_count=$(echo "$epic_stories" | jq -r '.count')
all_done=true
if [ "$stories_ok" != "true" ] || [ "$story_count" -eq 0 ]; then
all_done=false
else
tmp_story_checks=$(mktemp)
echo "$epic_stories" | jq -r '.stories[]' \
| xargs -I{} -P 4 sh -c '
status=$("'"{scriptsDir}"'" orchestrator-helper sprint-status get "{}")
done=$(echo "$status" | jq -r ".done")
[ "$done" = "true" ] && echo "{}|done" || echo "{}|not_done"
' > "$tmp_story_checks"
if rg -q '\|not_done$' "$tmp_story_checks"; then
all_done=false
fi
rm -f "$tmp_story_checks"
fi
```
#### H.2 Secondary Verification via Sprint Status
```bash
# Double-check: use result from parallel epic-level check
wait "$epic_status_pid"
epic_status=$(cat "$tmp_epic_status")
rm -f "$tmp_epic_status"
epic_complete=$(echo "$epic_status" | jq -r '.allStoriesDone')
epic_ok=$(echo "$epic_status" | jq -r '.ok')
# Both checks must pass
if [ "$all_done" = "true" ] && [ "$epic_ok" = "true" ] && [ "$epic_complete" = "true" ]; then
trigger_retro=true
else
trigger_retro=false
fi
```
#### H.3 Trigger Retrospective (Only When Epic Fully Complete)
**IF trigger_retro == true:**
1. Display: "**✅ Epic {epic_number} complete! All stories passed code review. Triggering retrospective (YOLO mode)...**"
2. Log: `- **[{timestamp}]** Epic {epic_number}: ALL STORIES DONE - triggering retrospective`
```bash
# CRITICAL: Use build-cmd to get full YOLO prompt with doc verification
retro_agent=$("{scriptsDir}" orchestrator-helper retro-agent --state-file "{outputFile}" | jq -r '.primary')
cmd=$("{scriptsDir}" tmux-wrapper build-cmd retro {epic_number} --agent "$retro_agent")
session=$("{scriptsDir}" tmux-wrapper spawn retro "" {epic_number} --agent "$retro_agent" --command "$cmd")
# Monitor with safe failure (never escalate on retro failure)
retro_timeout=60
[ "$story_count" -gt 10 ] && retro_timeout=90
result=$("{scriptsDir}" monitor-session "$session" --json --agent "$retro_agent" --timeout "$retro_timeout")
"{scriptsDir}" tmux-wrapper kill "$session"
retro_status=$(echo "$result" | jq -r '.final_state')
if [ "$retro_status" = "completed" ] || [ "$retro_status" = "success" ]; then
echo "- **[{timestamp}]** Epic {epic_number} retrospective: completed successfully" >> "{outputFile}"
else
echo "- **[{timestamp}]** Epic {epic_number} retrospective: skipped (reason: $retro_status)" >> "{outputFile}"
fi
```
3. Update state document with retrospective status:
```yaml
retrospectives:
epic-{epic_number}:
status: "completed" | "skipped"
reason: "{reason_if_skipped}"
timestamp: "{timestamp}"
```
4. **Continue to next story regardless of retrospective result** (retrospectives never block)
**IF trigger_retro == false:**
- Continue to next story (epic not yet complete)
**IMPORTANT RULES:**
- **ALL stories must be done**: Retrospective only triggers when every story in the epic shows "done" in sprint status
- **Use configured retro agent**: Resolve retrospective agent from `agentConfig` before spawn
- **Never escalate; non-blocking**: If retrospective fails for any reason, log warning and continue
**END FOR EACH**
## Then
→ After all stories complete, load and execute `{nextStep}`

View File

@@ -0,0 +1,68 @@
---
name: 'step-03c-execute-complete'
description: 'Post-loop completion summary, parallelism notes, and transition to wrapup'
nextStep: './step-04-wrapup.md'
scriptsDir: '../scripts/story-automator'
outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp}.md'
executionPatterns: '../data/execution-patterns.md'
retryStrategy: '../data/retry-fallback-strategy.md'
triggers: '../data/escalation-triggers.md'
---
# Step 3c: Execution Complete
**Goal:** Summarize results after all stories finish, persist final status, and transition to wrapup.
**Interaction mode:** Deterministic auto-proceed.
---
## All Complete
Display:
```
**All {count} stories completed!**
If `{count} <= 10`:
| Story | Status |
|-------|--------|
{summary_table}
If `{count} > 10`:
- Completed: {completed_count}
- Warnings: {warning_count}
- Escalations: {escalation_count}
- See state log for full per-story table.
Proceeding to wrap-up...
```
```bash
"{scriptsDir}" orchestrator-helper state-update "{outputFile}" \
--set status=EXECUTION_COMPLETE --set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** All stories complete — execution finished" >> "{outputFile}"
```
## Parallelism & Escalation
**Parallelism:** When `overrides.maxParallel > 1`, batch independent stories into concurrent groups:
1. Check story dependency graph — only stories with no shared file dependencies can run in parallel
2. Spawn up to `maxParallel` tmux sessions simultaneously (each runs steps A→F independently)
3. Wait for all sessions in the batch to complete before starting the next batch
4. Epic completion check (H) runs only after all batches finish
See `{executionPatterns}` for forbidden patterns and session isolation rules.
**Escalation:** See `{triggers}` for trigger definitions and `{retryStrategy}` for retry/fallback patterns. Escalation only after exhausting all retry attempts.
## Auto-Proceed to Wrap-up
Display: "**Execution loop complete. Proceeding to wrap-up...**"
```bash
"{scriptsDir}" orchestrator-helper state-update "{outputFile}" \
--set currentStep=step-04-wrapup \
--set lastUpdated="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
```
## Then
→ Immediately load and execute `{nextStep}`

View File

@@ -0,0 +1,132 @@
---
name: 'step-04-wrapup'
description: 'Finalize: summary, learnings, recommendations (terminal step)'
learningsFile: '{output_folder}/story-automator/learnings.md'
templates: '../data/wrapup-templates.md'
stateFilePattern: '{output_folder}/story-automator/orchestration-*.md'
outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp}.md'
stateHelper: '../scripts/story-automator'
stateMetrics: '../scripts/story-automator'
reportRetentionPolicy: '../data/report-retention-policy.md'
---
# Step 4: Wrap-up
**Goal:** Generate summary, capture learnings, finalize state.
**Interaction mode:** Structured wrap-up with recommendation output.
---
## Do
### 1. Load Final State
From state document (located via `{stateFilePattern}`; resolved path stored as `{outputFile}` for this run), extract:
- Story progress table
- Action log
- Session references
Calculate:
- Stories completed vs total
- Code review cycles
- Escalations encountered
Use the existing state document path from execution, and derive `story_range_csv` from frontmatter `storyRange`.
Deterministic metrics:
```bash
metrics=$("{stateMetrics}" state-metrics --state "{state_document_path}")
```
Parallel optimization (metrics + retention policy extraction):
```bash
tmp_metrics=$(mktemp)
tmp_retention=$(mktemp)
("{stateMetrics}" state-metrics --state "{state_document_path}" > "$tmp_metrics") &
metrics_pid=$!
(awk '/^```bash/{flag=1;next}/^```/{flag=0}flag{print}' "{reportRetentionPolicy}" > "$tmp_retention") &
retention_pid=$!
wait "$metrics_pid"
wait "$retention_pid"
metrics=$(cat "$tmp_metrics")
retention_cmds=$(cat "$tmp_retention")
rm -f "$tmp_metrics" "$tmp_retention"
```
**Optimization (data ops):** If action log exceeds 200 lines, use compact summary by default.
```bash
log_block=$(awk '/^## Action Log/{flag=1;next}/^## /{if(flag){exit}}flag{print}' "{state_document_path}")
log_lines=$(printf "%s\n" "$log_block" | wc -l | tr -d ' ')
if [ "$log_lines" -gt 200 ]; then
log_focus=$(printf "%s\n" "$log_block" | tail -n 50)
else
log_focus="$log_block"
fi
```
### 2. Generate Summary
From `{templates}`, use **Summary Report Template**.
Fill in all stats and display to user.
### 3. Capture Learnings
Analyze run for patterns:
- Common code review issues
- Steps needing escalation
- Timing patterns
- What worked well
**IF `{learningsFile}` exists:** Load and merge
**ELSE:** Create new
Append entry using **Learnings Entry Template** from `{templates}`.
### 4. Recommendations
From `{templates}`, use **Recommendations Template**.
Present actionable suggestions based on patterns observed.
### 4b. Validation Report Housekeeping
Load `{reportRetentionPolicy}` and apply its retention guidance when needed.
If validation report history is large, run the suggested maintenance command from that policy file.
### 5. Finalize State
Update state document:
- `status = 'COMPLETE'`
- `completedAt = {timestamp}`
- Append final summary to action log
Display: "**State document finalized.**"
### 6. Remove Marker File
Remove the active runtime marker:
```bash
"{stateHelper}" orchestrator-helper marker remove
```
This allows the Stop hook to stop normally after workflow completion by clearing the marker from the active runtime layout.
### 7. Workflow Complete
Display:
```
**🎉 Story Automator workflow complete!**
All stories have been processed through the build cycle.
Retrospectives were triggered automatically when each epic completed (during execution loop).
State document: {outputFile}
Learnings: {learningsFile}
```
Persist final state to `{outputFile}`.
---
## End
**Workflow terminates here.** Retrospectives are now handled within the execution loop (step-03b) when each epic completes, not as a separate terminal step.

View File

@@ -0,0 +1,173 @@
---
name: 'step-e-01-load'
description: 'Load and modify orchestration configuration settings'
outputFolder: '{output_folder}/story-automator'
rules: '../data/orchestrator-rules.md'
stateFilePattern: '{outputFolder}/orchestration-*.md'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
stateHelper: '../scripts/story-automator'
validateStep: '../steps-v/step-v-01-check.md'
---
# Edit Step 1: Modify Orchestration
**Goal:** Load an existing orchestration state and allow configuration changes.
---
## Do
### 1. Load Rules
Load `{rules}` once for context.
### 2. Request State Document
```
**Which orchestration would you like to edit?**
Found state documents in `{outputFolder}`:
[List all orchestration-*.md files with: name, status, last updated]
Enter filename or number to select:
```
**Wait.**
Deterministic listing (matches `{stateFilePattern}`):
```bash
state_list=$("{stateHelper}" orchestrator-helper state-list "{outputFolder}")
```
### 3. Load Current State
Load the selected state document (resolved as `{outputFile}` for this run). Display current configuration:
Deterministic summary:
```bash
summary=$("{stateHelper}" orchestrator-helper state-summary "{state_path}")
```
```
**Current Configuration: {epicName}**
**Status:** {status}
**Epic:** {epic}
**Story Range:** {storyRange}
**Current Position:** Story {currentStory}, Step {currentStep}
**Project Context:**
- Product Brief: {projectContext.productBrief}
- PRD: {projectContext.prd}
- Architecture: {projectContext.architecture}
**Execution Settings:**
- AI Command: {aiCommand}
- Max Parallel: {overrides.maxParallel}
- Skip Automate: {overrides.skipAutomate}
**Custom Context:**
{customContext or "None"}
```
### 4. Edit Menu
```
**What would you like to modify?**
[S]tatus - Change orchestration status
[R]ange - Modify story range
[O]verrides - Adjust execution settings
[T]ext Context - Update custom context
[I] Command - Change AI tool command
[D]ocs - Update project context paths
[X]Exit - Save and exit
```
**Wait.**
#### Menu Handling Logic:
- IF S: Update status, log change → redisplay menu
- IF R: Update story range, log change → redisplay menu
- IF O: Update overrides, log change → redisplay menu
- IF T: Update custom context, log change → redisplay menu
- IF I: Update AI command, log change → redisplay menu
- IF D: Update project doc paths, log change → redisplay menu
- IF X: Proceed to step 6
- IF Any other: help user respond, then redisplay menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- After non-exit options, return to this menu
- Keep prompts concise and progressive (one decision at a time)
### 5. Handle Edits
| Choice | Action |
|--------|--------|
| **S** | Present status options: READY, IN_PROGRESS, PAUSED → update, log change → redisplay menu |
| **R** | Show stories, ask for new range (e.g., "3-5", "all") → update, log change → redisplay menu |
| **O** | Show override settings, allow changes → update, log change → redisplay menu |
| **T** | Show current context, accept new text → update, log change → redisplay menu |
| **I** | Show current command, accept new (e.g., "cursor", "/path/to/ai") → update, log change → redisplay menu |
| **D** | Show current paths, allow updates → update, log change → redisplay menu |
| **X** | Proceed to step 6 |
### 6. Confirm and Save
```
**Changes to save:**
[List all modifications made]
[S]ave - Write changes to state document
[D]iscard - Exit without saving
[E]dit more - Return to edit menu
```
**Wait.**
| Choice | Action |
|--------|--------|
| **S** | Update `lastUpdated`, log "Configuration edited", write file → step 7 |
| **D** | Display "Changes discarded." → end |
| **E** | Return to step 4 |
#### Menu Handling Logic:
- IF S: Save changes then proceed to step 7
- IF D: Discard changes and end
- IF E: Return to step 4
- IF Any other: help user respond, then redisplay this menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- Keep prompts concise and progressive (one decision at a time)
### 7. Post-Edit Options
```
**Changes saved.**
[R]esume - Continue orchestration from current position
[V]alidate - Run validation check on state
[X]Exit - Return to main menu
```
**Wait.**
| Choice | Action |
|--------|--------|
| **R** | Route to appropriate step based on `currentStep` (preflight/execute/wrapup) |
| **V** | Load `{validateStep}` |
| **X** | Display "Edit complete." and end |
#### Menu Handling Logic:
- IF R: Route based on `currentStep`
- IF V: Load `{validateStep}`
- IF X: End workflow
- IF Any other: help user respond, then redisplay this menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- Keep prompts concise and progressive (one decision at a time)
---
## Then
→ End workflow or route based on choice

View File

@@ -0,0 +1,178 @@
---
name: 'step-v-01-check'
description: 'Validate orchestration state document integrity and session health'
nextStep: './step-v-02-report.md'
outputFolder: '{output_folder}/story-automator'
rules: '../data/orchestrator-rules.md'
stateFilePattern: '{outputFolder}/orchestration-*.md'
outputFile: '{outputFolder}/orchestration-{epic_id}-{timestamp}.md'
validateState: '../scripts/story-automator'
listSessions: '../scripts/story-automator'
deriveProjectSlug: '../scripts/story-automator'
tmuxCommands: '../data/tmux-commands.md'
---
# Validation Step 1: Check State Integrity
**Goal:** Validate an orchestration state document for structural integrity and session health.
## MANDATORY EXECUTION RULES
- 🛑 **DO NOT BE LAZY** - CHECK EVERY FIELD AND SESSION
- 📖 Validate ALL required fields, not just a sample
- 🚫 DO NOT skip any validation checks
- ✅ Report ALL issues found, not just the first one
---
## Do
### 1. Load Rules
Load `{rules}` once for context on expected state structure.
### 2. Request State Document
```
**Which orchestration would you like to validate?**
Found state documents in `{outputFolder}`:
[List all orchestration-*.md files with: name, status, last updated]
Pattern: `{stateFilePattern}`
Enter filename or number to select:
```
**Wait.**
### 3. Load and Parse State
Load the selected state document (resolved as `{state_path}` for this run). Extract frontmatter:
- `epic`, `epicName`, `storyRange`
- `status`, `currentStory`, `currentStep`
- `stepsCompleted`, `lastUpdated`
- `projectContext`, `aiCommand`, `agentConfig`, `overrides`
- `activeSessions`, `completedSessions`
### 3a. Helper CLI Contract Check (Required)
Before running validation commands, verify helper interfaces in parallel:
```bash
tmp_help_validate=$(mktemp)
tmp_help_sessions=$(mktemp)
tmp_help_slug=$(mktemp)
("{validateState}" validate-state --help >"$tmp_help_validate" 2>&1) &
pid_validate=$!
("{listSessions}" list-sessions --help >"$tmp_help_sessions" 2>&1) &
pid_sessions=$!
("{deriveProjectSlug}" derive-project-slug --help >"$tmp_help_slug" 2>&1) &
pid_slug=$!
wait "$pid_validate"; status_validate=$?
wait "$pid_sessions"; status_sessions=$?
wait "$pid_slug"; status_slug=$?
if [ "$status_validate" -ne 0 ] || [ "$status_sessions" -ne 0 ] || [ "$status_slug" -ne 0 ]; then
rm -f "$tmp_help_validate" "$tmp_help_sessions" "$tmp_help_slug"
echo "validation helper CLI contract changed"
exit 1
fi
rm -f "$tmp_help_validate" "$tmp_help_sessions" "$tmp_help_slug"
```
If any check fails: **STOP and report "validation helper CLI contract changed"**.
### 4. Run Structure + Session Baseline in Parallel
Run structure validation and session inventory concurrently, then aggregate results.
```bash
tmp_validation=$(mktemp)
tmp_sessions=$(mktemp)
("{validateState}" validate-state --state "{state_path}" > "$tmp_validation") &
validation_pid=$!
project_slug_json=$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}") || {
rm -f "$tmp_validation" "$tmp_sessions"
echo "derive-project-slug failed"
exit 1
}
project_slug=$(printf '%s' "$project_slug_json" | jq -r '.slug')
("{listSessions}" list-sessions --slug "$project_slug" > "$tmp_sessions") &
sessions_pid=$!
wait "$validation_pid"; validation_status=$?
wait "$sessions_pid"; sessions_status=$?
if [ "$validation_status" -ne 0 ] || [ "$sessions_status" -ne 0 ]; then
rm -f "$tmp_validation" "$tmp_sessions"
echo "state validation or session inventory failed"
exit 1
fi
validation=$(cat "$tmp_validation")
sessions=$(cat "$tmp_sessions")
rm -f "$tmp_validation" "$tmp_sessions"
```
### 5. Validate Structure + Session Consistency (Single Diff Pass)
**Required Fields Check:**
| Field | Present | Valid |
|-------|---------|-------|
| epic | ✅/❌ | non-empty string |
| epicName | ✅/❌ | non-empty string |
| storyRange | ✅/❌ | array |
| status | ✅/❌ | valid enum |
| lastUpdated | ✅/❌ | ISO date |
| aiCommand or agentConfig | ✅/❌ | at least one runtime command source is present |
**Valid status values:** INITIALIZING, READY, IN_PROGRESS, PAUSED, COMPLETE, ABORTED
**Record issues:**
- Missing required fields
- Invalid field values
- Malformed YAML
Single-pass structure issue extraction (compact output):
```bash
field_issues=$(echo "$validation" | jq -r '.issues[]? | select(.type=="missing_field" or .type=="invalid_value" or .type=="yaml_error") | "\(.type): \(.field // .message)"')
```
Using `{tmuxCommands}` semantics and `sessions` output, compare state vs live sessions in one pass:
```bash
state_sessions=$(echo "$validation" | jq -r '.activeSessions[]?.sessionId // empty' | sort -u)
live_sessions=$(echo "$sessions" | jq -r '.sessions[]?.name // empty' | sort -u)
orphaned_refs=$(comm -23 <(echo "$state_sessions") <(echo "$live_sessions"))
untracked_live=$(comm -13 <(echo "$state_sessions") <(echo "$live_sessions"))
```
**Session consistency checks:**
| Check | Result |
|-------|--------|
| Active sessions in state but not in T-Mux | Orphaned references |
| T-Mux sessions not in state | Untracked sessions |
| Status=IN_PROGRESS but no active sessions | Stale state |
### 6. Carry Forward Validation Context
Carry forward to `{nextStep}`:
- `state_path`
- `validation`
- `sessions`
- `orphaned_refs`
- `untracked_live`
- Any structure/session issues identified
### 7. Auto-Proceed
Display: "**Structure and session baseline complete. Proceeding to progress validation and final report...**"
---
## Then
→ Load and execute `{nextStep}`

View File

@@ -0,0 +1,115 @@
---
name: 'step-v-02-report'
description: 'Validate story progress consistency and present final validation report'
outputFile: '{output_folder}/story-automator/orchestration-{epic_id}-{timestamp}.md'
---
# Validation Step 2: Progress Consistency + Final Report
**Goal:** Validate story-progress consistency using the selected state document and present a consolidated validation report.
## MANDATORY EXECUTION RULES
- 🛑 **DO NOT BE LAZY** - CHECK EVERY STORY IN RANGE
- 📖 Validate ALL progress checks, not just samples
- 🚫 DO NOT skip stalled/skipped-step checks
- ✅ Include structure/session findings from step-v-01 in final report
---
## Do
### 1. Load Validation Context from Step 1
Use carried-forward context:
- `state_path`
- `validation`
- `sessions`
- `orphaned_refs`
- `untracked_live`
- Prior structure/session issues
Load the selected state document again (resolved as `{state_path}` for this run) to verify progress details.
### 2. Validate Story Progress Thoroughly
Run a single prefilter pass first and keep parent context compact:
```bash
# Focused extraction before deep checks
progress_focus=$(rg -n "done|in_progress|blocked|review|create|dev|automate|commit|ERROR|WARN|FAIL" "$state_path" | head -n 200)
if [ -z "$progress_focus" ]; then
progress_focus=$(tail -n 200 "$state_path")
fi
```
Return only compact progress fields to the final report synthesis:
- `story_count`
- `progress_rows`
- `inconsistency_count`
- `stalled_count`
- `critical_issues[]`
For each story in `storyRange`:
- Check progress table has an entry for the story
- Verify task sequence is coherent (`create -> dev -> automate -> review -> commit`)
- Flag impossible regressions (for example, `review=done` while `dev` missing)
- Detect potentially stuck stories (same `currentStep` for too long without action-log movement)
Deterministic checks (example pattern):
```bash
# Example only: derive summary values from state/action log without loading full logs
story_count=$(echo "$validation" | jq -r '.storyRangeCount // 0')
progress_rows=$(rg -n "^[[:space:]]*\\|[[:space:]]*[0-9]+\\.[0-9]+" "$state_path" | wc -l | tr -d ' ')
```
If `story_count >= 4`, run per-story consistency checks in parallel and return compact rows only:
```bash
story_ids=$(echo "$validation" | jq -r '.storyRange[]?')
tmp_progress=$(mktemp)
printf "%s\n" "$story_ids" | xargs -I{} -P 4 sh -c \
'id="$1"; file="$2"; rg -n -F "| ${id} |" "$file" | head -n 1 | sed "s/^/${id}|/"' _ "{}" "$state_path" \
> "$tmp_progress"
progress_rows=$(wc -l < "$tmp_progress" | tr -d ' ')
rm -f "$tmp_progress"
```
### 3. Consolidate Findings
Create final status buckets:
- **Structure:** from `validation`
- **Sessions:** from `sessions`, `orphaned_refs`, `untracked_live`
- **Progress:** from step-2 checks above
Mark severity:
- **CRITICAL:** malformed state / irrecoverable sequence corruption
- **WARNING:** stale or inconsistent but recoverable
- **INFO:** healthy or minor notes
### 4. Present Final Validation Report
```
**Validation Report: {epicName}**
**Structure:** ✅ Valid / ⚠️ Issues found
**Sessions:** ✅ Healthy / ⚠️ Anomalies detected
**Progress:** ✅ Consistent / ⚠️ Inconsistencies found
[If issues:]
**Issues Found:**
1. {issue description}
2. {issue description}
**Recommendations:**
- {recommendation}
```
### 5. Complete
Display: "**Validation complete.** Review the issues above and use the edit workflow to apply fixes if needed."
**End validation.**
---
## Then
→ End workflow (validation completed in 2 steps)

View File

@@ -0,0 +1,115 @@
---
# Orchestration State Document
epic: ""
epicName: ""
storyRange: []
status: "INITIALIZING"
currentStory: null
currentStep: null
stepsCompleted: []
lastUpdated: ""
createdAt: ""
# Configuration
aiCommand: "" # Deprecated: use agentConfig
overrides:
skipAutomate: false
maxParallel: 1
customInstructions: "" # User-provided instructions for orchestration
agentsFile: "" # Deterministic per-story agent selections
complexityFile: "" # Persisted story complexity data
policyVersion: 0
policySnapshotFile: ""
policySnapshotHash: ""
legacyPolicy: false
# Agent Configuration (v3.0.0)
agentConfig:
defaultPrimary: "auto" # auto resolves to the active runtime provider: claude | codex
defaultFallback: false # Default fallback: claude | codex | false (disabled)
# Per-task overrides (optional)
# perTask:
# create:
# primary: "codex"
# fallback: "claude"
# dev:
# primary: "claude"
# fallback: false
# auto:
# primary: "codex"
# fallback: false
# review:
# primary: "claude"
# fallback: false
# Complexity-based overrides (optional, WIN per task)
# complexityOverrides:
# low:
# create:
# primary: "claude"
# fallback: false
# medium:
# dev:
# primary: "claude"
# fallback: false
# high:
# review:
# primary: "claude"
# fallback: false
# Codex-specific (applied automatically when agent is codex):
# - 1.5x timeout multiplier (60min → 90min)
# - 1.5x wait time cap (2min → 3min between polls)
# - Natural language prompts instead of command syntax
# Session Tracking
activeSessions: []
completedSessions: []
---
# Orchestration Log: {{epicName}}
## Configuration
**Epic:** {{epic}}
**Story Range:** {{storyRange}}
**Created:** {{createdAt}}
**Overrides:**
- Skip Automate: {{overrides.skipAutomate}}
- Max Parallel: {{overrides.maxParallel}}
**Custom Instructions:**
{{customInstructions}}
---
## Story Progress
| Story | create-story | dev-story | automate | code-review | git-commit | Status |
|-------|--------------|-----------|----------|-------------|------------|--------|
<!-- Progress rows will be appended here -->
---
## Action Log
<!-- Timestamped action entries will be appended here -->
---
## Session References
| Session ID | Story | Step | Status | Started | Completed |
|------------|-------|------|--------|---------|-----------|
<!-- Session entries will be appended here -->
---
## Pending Decisions
<!-- Escalations awaiting user input will be listed here -->
---
## Learnings & Recommendations
<!-- Populated during wrapup phase -->

View File

@@ -0,0 +1,172 @@
---
name: story-automator
version: "1.12.0"
description: "Automate the build cycle for stories in an epic using T-Mux sessions with full resumability, smart parallelism, decision escalation, and automated retrospectives (tri-modal: create, validate, edit)"
web_bundle: true
configPath: '{project-root}/_bmad/bmm/config.yaml'
stateHelper: './scripts/story-automator'
outputFolder: '{output_folder}/story-automator'
---
# story-automator
**Goal:** Automate the entire development build cycle (create-story → dev-story → automate → code-review → retrospective) for multiple stories in one or more epics, using T-Mux to spawn isolated AI agent sessions while providing visibility, resumability, and graceful decision escalation.
**Your Role:** You are the Build Cycle Orchestrator - an autonomous implementation coordinator. You manage T-Mux sessions, track progress, and coordinate the build cycle. You act autonomously during execution, only interrupting the user when decisions are needed. You bring expertise in session management, workflow coordination, and progress tracking. The user brings their epic(s), stories, and domain context. Work efficiently with minimal interruption.
**Interaction Balance:** Use mixed style intentionally.
- Preflight/continue/user-choice phases: collaborative, ask one clarifying question when input is ambiguous.
- Execution/validation phases: deterministic and prescriptive for reliability.
**Meta-Context:** This orchestrator spawns and monitors other workflows (create-story, dev-story, automate, code-review, retrospective) in isolated T-Mux sessions. It tracks state for full resumability and escalates to the user only when autonomous decisions cannot be made.
**Runtime Policy:** Machine settings live in `data/orchestration-policy.json`. Prompt contracts, parse contracts, retry budgets, and verifier selection should follow the pinned policy snapshot written at orchestration start.
---
## MULTI-EPIC SUPPORT
Story automator supports processing multiple epics in a single run:
### Multi-Epic Behavior
- **Aggregation**: When multiple epics are provided, stories from all epics are processed in order
- **Epic Completion Detection**: After each story completes, check if ALL stories in that epic are done
- **Retrospective Trigger**: Runs within execution loop when ALL stories in epic pass code review AND sprint status confirms all "done"
- **Independent Processing**: Each epic's retrospective is independent - failures don't block others or subsequent stories
### Retrospective Trigger Conditions (v1.8.0)
Retrospective for an epic triggers **only when**:
1. **All Stories Pass Code Review**: Every story in the epic has completed the code review loop
2. **Sprint Status Verification**: Sprint status confirms ALL stories in the epic show "done"
This ensures retrospective runs at the right time in multi-epic scenarios, not at workflow end.
### Retrospective Rules
- **Use configured retro agent**: Retrospectives inherit the configured primary agent unless `agentConfig` overrides `retro`
- **YOLO Mode**: Fully automated, no user input expected
- **Never Escalate**: If retrospective fails for ANY reason, safely skip (log warning, continue)
- **Non-Blocking**: Retrospective completion does not block next story or epic
- **Doc Verification**: After retrospective creates documents, subagents verify and sync docs
### Example Multi-Epic Flow
```
Epic 1: story 1-1 → done
Epic 1: story 1-2 → done
Epic 1: story 1-3 → done → ALL Epic 1 stories done → retrospective (YOLO)
Epic 2: story 2-1 → done
Epic 2: story 2-2 → done → ALL Epic 2 stories done → retrospective (YOLO)
→ Wrapup (terminal step)
```
If Epic 1 retrospective fails: log warning, skip, continue to Epic 2 stories.
---
## WORKFLOW ARCHITECTURE
This uses **step-file architecture** for disciplined execution:
### Core Principles
- **Micro-file Design**: Each step is a self-contained instruction file
- **Just-In-Time Loading**: Only the current step file is in memory
- **Sequential Enforcement**: Sequence within step files must be completed in order
- **State Tracking**: Document progress in state document frontmatter using structured tracking
- **Tri-Modal Structure**: Separate step folders for Create (steps-c/), Validate (steps-v/), and Edit (steps-e/) modes
### Step Processing Rules
1. **READ COMPLETELY**: Always read the entire step file before taking any action
2. **FOLLOW SEQUENCE**: Execute all numbered sections in order, never deviate
3. **WAIT FOR INPUT**: If a menu is presented, halt and wait for user selection
4. **CHECK CONTINUATION**: Only proceed to next step when directed
5. **SAVE STATE**: Update state document before loading next step
6. **LOAD NEXT**: When directed, load, read entire file, then execute the next step file
### Critical Rules (NO EXCEPTIONS)
- 🛑 **NEVER** load multiple step files simultaneously
- 📖 **ALWAYS** read entire step file before execution
- 🚫 **NEVER** skip steps or optimize the sequence
- 💾 **ALWAYS** update state document when completing actions
- 🎯 **ALWAYS** follow the exact instructions in the step file
- ⏸️ **ALWAYS** halt at menus and wait for user input
- 📋 **NEVER** create mental todo lists from future steps
-**ALWAYS** communicate in the configured `{communication_language}`
### Preflight Requirements (v1.10.0)
During preflight (step-02), the following sequence is **MANDATORY**:
1. **Parse epics** using `scripts/story-automator parse-epic`
2. **Compute complexity** using `scripts/story-automator parse-story --rules` for EACH story
3. **Display Complexity Matrix** showing all stories with levels/scores
4. **THEN** proceed to agent configuration (which references complexity data)
🛑 **FORBIDDEN:**
- Skipping complexity scoring
- Manual complexity assessment (reading epic/story content and guessing)
- Showing agent config before Complexity Matrix is displayed
- Creating state document without `stories_json` containing programmatic complexity
---
## INITIALIZATION SEQUENCE
### 1. Configuration Loading
Load and read full config from {configPath} and resolve:
- `project_name`, `output_folder`, `user_name`, `communication_language`, `document_output_language`
- ✅ Communicate in `{communication_language}`
### 2. Mode Determination
**Check if mode was specified in the command invocation:**
- If user invoked with "automate stories" or "run build cycle" or "story-automator" → Set mode to **create**
- If user invoked with "resume orchestration" or "continue orchestration" or "-r" → Set mode to **resume**
- If user invoked with "validate orchestration" or "check state" or "-v" → Set mode to **validate**
- If user invoked with "edit orchestration" or "modify settings" or "-e" → Set mode to **edit**
**If mode is still unclear, ask user:**
"Welcome to the Story Automator! What would you like to do?
**[C]reate** - Start a new build cycle for stories in an epic
**[R]esume** - Continue an existing orchestration (skips init checks)
**[V]alidate** - Check integrity of an existing orchestration state
**[E]dit** - Modify configuration of an existing orchestration
Please select: [C]reate / [R]esume / [V]alidate / [E]dit"
### 3. Route to First Step
**IF mode == create:**
Load, read completely, then execute `steps-c/step-01-init.md`
**IF mode == resume:**
Prompt for state document path (optional): "Which orchestration would you like to resume? Provide the path or press Enter to use the latest incomplete state."
**If path provided:** Store as `{resumeStatePath}`, then load, read completely, and execute `steps-c/step-01b-continue.md`
**If no path (Enter pressed):**
Use script to find latest incomplete:
```bash
result=$("{stateHelper}" orchestrator-helper state-latest-incomplete "{outputFolder}")
resumeStatePath=$(echo "$result" | jq -r '.path // empty')
```
- **If found (resumeStatePath not empty):** Display "Found: {resumeStatePath}", then load, read completely, and execute `steps-c/step-01b-continue.md`
- **If not found:** Display "No incomplete orchestration found. Starting fresh.", then load, read completely, and execute `steps-c/step-01-init.md`
**IF mode == validate:**
Prompt for state document path: "Which orchestration state would you like to validate? Please provide the path to the state document."
Then load, read completely, and execute `steps-v/step-v-01-check.md`
**IF mode == edit:**
Prompt for state document path: "Which orchestration would you like to edit? Please provide the path to the state document."
Then load, read completely, and execute `steps-e/step-e-01-load.md`