fix: resolve story automator review findings

This commit is contained in:
bmad 2026-03-28 16:09:16 -03:00
parent 21d8da520b
commit 9ff25ae547
39 changed files with 430 additions and 195 deletions

View File

@ -41,8 +41,8 @@ attempt_5_progress = {agent: primary, tasks: 5/9} # confirmed plateau
| 3 | FAILURE, plateau at same task (any agent) | Continue to attempt 4 (confirm with other agent) | | 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, plateau confirmed across agents | **DEFER** story (complexity/context limit hit) |
| 4 | FAILURE, variable progress | One more retry with extended timeout | | 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, zero progress all attempts | **ESCALATE** (likely API/connection issue) |
| 5 | FAILURE, plateau confirmed (and progress > 0) | **DEFER** story |
| 5 | FAILURE, variable but incomplete | **ESCALATE** (all retries exhausted) | | 5 | FAILURE, variable but incomplete | **ESCALATE** (all retries exhausted) |
--- ---

View File

@ -64,12 +64,13 @@ session=$("$scripts" tmux-wrapper spawn dev {epic} {story_id} \
## Codex Monitoring Notes ## Codex Monitoring Notes
- **No todo checkboxes:** Codex doesn't use ☒/☐ - `todos_done` and `todos_total` will be 0 - **No native todo checkboxes:** Codex doesn't emit ☒/☐, so active sessions report `0/0`
- **Longer waits:** Status check script returns 90s wait estimate for Codex (vs 60s for Claude) - **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) - **Different activity detection:** Uses output freshness + heartbeat (no marker reliance)
- **Output staleness window:** `CODEX_OUTPUT_STALE_SECONDS` (default: 300) - **Output staleness window:** `CODEX_OUTPUT_STALE_SECONDS` (default: 300)
- **1.5x timeout multiplier:** `story-automator monitor-session` applies 1.5x multiplier when `--agent codex` - **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" - **Output-fresh idle normalization:** If heartbeat is idle but output is still fresh, Codex reports `0/1` while work is still considered in progress
- **Completion normalization (v2.2):** When Codex finishes or prompt returns after activity, it 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 - **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) ## ⚠️ Codex Code-Review Limitations (v1.5.0)

View File

@ -101,13 +101,13 @@ is_done=$(echo "$status" | jq -r '.done')
**IF final_state == "incomplete":** (v2.2 - Codex-specific) **IF final_state == "incomplete":** (v2.2 - Codex-specific)
- Session idle but sprint-status NOT updated - Session idle but sprint-status NOT updated
- Cleanup: `"$scripts" tmux-wrapper kill "$session_name"` - Cleanup: `"$scripts" tmux-wrapper kill "$session_name"`
- Count this as a failed attempt and **retry** until `reviewCycle == maxCycles` - Increment `reviewCycle`
- **After maxCycles exhausted:** Escalate with CRITICAL priority (Trigger #8) - If `reviewCycle <= maxCycles`: count this as a failed attempt and **CONTINUE** with a retry
- Present options: - If `reviewCycle > maxCycles`: Escalate with CRITICAL priority (Trigger #8), then present options:
1. **[1] Manual Fix** - Update sprint-status.yaml yourself 1. **[1] Manual Fix** - Update sprint-status.yaml yourself
2. **[2] Run with Claude** - Re-run code-review with Claude agent 2. **[2] Run with Claude** - Re-run code-review with Claude agent
3. **[3] Skip Story** - Mark story as skipped and continue 3. **[3] Skip Story** - Mark story as skipped and continue
- **HALT** — wait for user choice - **HALT** — wait for user choice only after maxCycles is exhausted
**IF final_state == "crashed" or "stuck":** **IF final_state == "crashed" or "stuck":**
- Log "Review session failed: $final_state" - Log "Review session failed: $final_state"

View File

@ -27,6 +27,9 @@ tests_pass=$([[ "$test_result" != *"FAIL"* ]] && echo "true" || echo "false")
Story: {story_name} Story: {story_name}
Story ID: {story_id} Story ID: {story_id}
```
---
## 2. Cannot Parse Session Output ## 2. Cannot Parse Session Output
@ -64,7 +67,7 @@ Story: {story_name}
Step: {step_name} Step: {step_name}
Error: {error_message} Error: {error_message}
Unable to spawn T-Mux session after retry. Unable to spawn tmux session after retry.
Options: Options:
[1] Retry again [1] Retry again

View File

@ -3,3 +3,4 @@
See: See:
- `escalation-messages-core.md` (Triggers 1-4) - `escalation-messages-core.md` (Triggers 1-4)
- `escalation-messages-extended.md` (Triggers 5-7) - `escalation-messages-extended.md` (Triggers 5-7)
- Trigger 8 (`final_state: "incomplete"` after `maxCycles`) has no shared template; use the workflow-specific escalation in `code-review-loop.md`

View File

@ -22,15 +22,15 @@ When `story-automator monitor-session` fails or background monitoring task dies:
```bash ```bash
# STEP 1: Check if tmux session still exists # STEP 1: Check if tmux session still exists
sessions=$(tmux list-sessions 2>/dev/null | grep "sa-.*{story_pattern}" || true) sessions=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "sa-.*{story_pattern}" || true)
# STEP 2: If session exists, check its status directly # STEP 2: If session exists, check its status directly
if [ -n "$sessions" ]; then if [ -n "$sessions" ]; then
for session in $sessions; do while IFS= read -r session; do
status=$("$scripts" tmux-status-check "$session") status=$("$scripts" tmux-status-check "$session")
session_state=$(echo "$status" | cut -d',' -f6) session_state=$(echo "$status" | cut -d',' -f6)
# Act based on direct status # Act based on direct status
done done <<< "$sessions"
fi fi
# STEP 3: ALWAYS verify source of truth regardless of session status # STEP 3: ALWAYS verify source of truth regardless of session status

View File

@ -84,7 +84,7 @@ story-automator monitor-session <session_name> [options]
# --json Output as JSON instead of CSV # --json Output as JSON instead of CSV
# Output (JSON): # Output (JSON):
# {"final_state":"completed|crashed|stuck|timeout","output_file":"/tmp/...","exit_reason":"..."} # {"final_state":"completed|crashed|stuck|timeout|incomplete|not_found","output_file":"/tmp/...","exit_reason":"..."}
``` ```
### story-automator orchestrator-helper ### story-automator orchestrator-helper

View File

@ -76,7 +76,7 @@ Before escalating, check if story is blocking:
- Decision tree for poll results - Decision tree for poll results
- Polling loop with state tracking - Polling loop with state tracking
- **Output Parsing:** See `data/monitoring-pattern.md` (Sub-Agent Invocation section) - **Output Parsing:** See `data/monitoring-pattern-parsing.md` (Sub-Agent Invocation section)
- NEVER parse output yourself - NEVER parse output yourself
- ALWAYS use sub-agents (Task tool, haiku) - ALWAYS use sub-agents (Task tool, haiku)
- Verification checkpoint before proceeding - Verification checkpoint before proceeding

View File

@ -36,8 +36,10 @@ resolve_agent_for_task() {
primary_agent=$(echo "$result" | jq -r '.primary') primary_agent=$(echo "$result" | jq -r '.primary')
fallback_agent=$(echo "$result" | jq -r '.fallback') fallback_agent=$(echo "$result" | jq -r '.fallback')
# Handle "false"/null meaning disabled # Normalize disabled fallback states
[ "$fallback_agent" = "false" ] && fallback_agent="" if [ -z "$fallback_agent" ] || [ "$fallback_agent" = "false" ] || [ "$fallback_agent" = "null" ] || [ "$fallback_agent" = "$primary_agent" ]; then
fallback_agent=""
fi
} }
# Usage: # Usage:

View File

@ -36,7 +36,7 @@ session=$("$scripts" tmux-wrapper spawn {type} {epic} {story_id} \
result=$("$scripts" monitor-session "$session" --json --agent "$agent") result=$("$scripts" monitor-session "$session" --json --agent "$agent")
# Parse output # Parse output
parsed=$("$scripts" orchestrator-helper parse-output "$(echo $result | jq -r '.output_file')" {type}) parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" {type})
# Cleanup # Cleanup
"$scripts" tmux-wrapper kill "$session" "$scripts" tmux-wrapper kill "$session"

View File

@ -113,7 +113,7 @@ The status check script automatically detects Claude vs Codex sessions:
**For full output (when completed/stuck):** **For full output (when completed/stuck):**
```bash ```bash
./bin/story-automator tmux-status-check "SESSION_NAME" --full {project_root}/_bmad/bmm/4-implementation/bmad-story-automator-go/bin/story-automator tmux-status-check "SESSION_NAME" --full
``` ```
Returns: `idle,0,0,/tmp/sa-output-SESSION_NAME.txt,0,completed` Returns: `idle,0,0,/tmp/sa-output-SESSION_NAME.txt,0,completed`

View File

@ -116,9 +116,9 @@ tmux send-keys -t "SESSION" 'claude --dangerously-skip-permissions "bmad-create-
codex exec "Execute the BMAD create-story workflow for story STORY_ID. codex exec "Execute the BMAD create-story workflow for story STORY_ID.
Workflow location: _bmad/bmm/4-implementation/bmad-create-story/ Workflow location: _bmad/bmm/4-implementation/bmad-create-story/
- Read workflow.yaml for the process - Follow workflow.md for the process
- Use template.md as the output template - Use template.md as the output template
- Follow instructions.xml for detailed steps - Reference checklist.md for validation steps
Create story file at: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md Create story file at: _bmad-output/implementation-artifacts/STORY_PREFIX-*.md

View File

@ -79,9 +79,15 @@ func cmdEnsureMarkerGitignore(args []string) int {
return 1 return 1
} }
if strings.Contains(content, entry) { for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
writeJSON(map[string]any{"ok": true, "changed": false, "path": gitignorePath}) trimmed := strings.TrimSpace(line)
return 0 if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if trimmed == entry {
writeJSON(map[string]any{"ok": true, "changed": false, "path": gitignorePath})
return 0
}
} }
f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_WRONLY, 0o644) f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_WRONLY, 0o644)
@ -90,7 +96,11 @@ func cmdEnsureMarkerGitignore(args []string) int {
return 1 return 1
} }
defer f.Close() defer f.Close()
if _, err := f.WriteString(entry + "\n"); err != nil { prefix := ""
if content != "" && !strings.HasSuffix(content, "\n") {
prefix = "\n"
}
if _, err := f.WriteString(prefix + entry + "\n"); err != nil {
writeJSON(map[string]any{"ok": false, "error": "append_failed"}) writeJSON(map[string]any{"ok": false, "error": "append_failed"})
return 1 return 1
} }
@ -202,7 +212,7 @@ func cmdEnsureStopHook(args []string) int {
} }
exists := false exists := false
needsPathUpdate := false needsUpdate := false
for _, entry := range stopHooks { for _, entry := range stopHooks {
entryMap, ok := entry.(map[string]any) entryMap, ok := entry.(map[string]any)
if !ok { if !ok {
@ -217,16 +227,26 @@ func cmdEnsureStopHook(args []string) int {
if cmd, ok := m["command"].(string); ok { if cmd, ok := m["command"].(string); ok {
if cmd == commandPath { if cmd == commandPath {
exists = true exists = true
break } else if strings.Contains(cmd, "story-automator") && strings.Contains(cmd, "stop-hook") {
}
// Flexible match: any command referencing story-automator stop-hook
// regardless of path format (relative, absolute, project-relative).
if strings.Contains(cmd, "story-automator") && strings.Contains(cmd, "stop-hook") {
exists = true exists = true
if cmd != commandPath { m["command"] = commandPath
// Migrate stale path to resolved absolute path in-place. needsUpdate = true
m["command"] = commandPath }
needsPathUpdate = true if exists {
switch current := m["timeout"].(type) {
case float64:
if int(current) != timeout {
m["timeout"] = timeout
needsUpdate = true
}
case int:
if current != timeout {
m["timeout"] = timeout
needsUpdate = true
}
default:
m["timeout"] = timeout
needsUpdate = true
} }
break break
} }
@ -237,21 +257,18 @@ func cmdEnsureStopHook(args []string) int {
} }
} }
if exists && !needsPathUpdate { if exists && !needsUpdate {
writeJSON(map[string]any{"ok": true, "changed": false, "reason": "already_configured", "path": settingsPath}) writeJSON(map[string]any{"ok": true, "changed": false, "reason": "already_configured", "path": settingsPath})
return 0 return 0
} }
if exists && needsPathUpdate { if exists && needsUpdate {
// Path normalized to absolute — write updated settings.
// Return changed:false because the hook functionally existed;
// no session restart is needed.
b, _ := json.MarshalIndent(root, "", " ") b, _ := json.MarshalIndent(root, "", " ")
if err := writeFileAtomic(settingsPath, b); err != nil { if err := writeFileAtomic(settingsPath, b); err != nil {
writeJSON(map[string]any{"ok": false, "error": "write_failed", "path": settingsPath}) writeJSON(map[string]any{"ok": false, "error": "write_failed", "path": settingsPath})
return 1 return 1
} }
writeJSON(map[string]any{"ok": true, "changed": false, "reason": "path_normalized", "path": settingsPath}) writeJSON(map[string]any{"ok": true, "changed": false, "reason": "hook_normalized", "path": settingsPath})
return 0 return 0
} }

View File

@ -2,14 +2,19 @@ package main
import ( import (
"fmt" "fmt"
"io"
"os" "os"
) )
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
usage() printUsage(os.Stderr)
os.Exit(1) os.Exit(1)
} }
if isHelpFlag(os.Args[1]) {
printUsage(os.Stdout)
return
}
cmd := os.Args[1] cmd := os.Args[1]
args := os.Args[2:] args := os.Args[2:]
@ -62,37 +67,37 @@ func main() {
code = cmdAgentConfig(args) code = cmdAgentConfig(args)
default: default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd) fmt.Fprintf(os.Stderr, "Unknown command: %s\n", cmd)
usage() printUsage(os.Stderr)
code = 1 code = 1
} }
os.Exit(code) os.Exit(code)
} }
func usage() { func printUsage(w io.Writer) {
fmt.Fprintln(os.Stderr, "story-automator <command> [args]") fmt.Fprintln(w, "story-automator <command> [args]")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(w, "")
fmt.Fprintln(os.Stderr, "Commands:") fmt.Fprintln(w, "Commands:")
fmt.Fprintln(os.Stderr, " derive-project-slug") fmt.Fprintln(w, " derive-project-slug")
fmt.Fprintln(os.Stderr, " ensure-marker-gitignore") fmt.Fprintln(w, " ensure-marker-gitignore")
fmt.Fprintln(os.Stderr, " ensure-stop-hook") fmt.Fprintln(w, " ensure-stop-hook")
fmt.Fprintln(os.Stderr, " stop-hook") fmt.Fprintln(w, " stop-hook")
fmt.Fprintln(os.Stderr, " build-state-doc") fmt.Fprintln(w, " build-state-doc")
fmt.Fprintln(os.Stderr, " commit-story") fmt.Fprintln(w, " commit-story")
fmt.Fprintln(os.Stderr, " parse-epic") fmt.Fprintln(w, " parse-epic")
fmt.Fprintln(os.Stderr, " parse-story") fmt.Fprintln(w, " parse-story")
fmt.Fprintln(os.Stderr, " parse-story-range") fmt.Fprintln(w, " parse-story-range")
fmt.Fprintln(os.Stderr, " epic-complete") fmt.Fprintln(w, " epic-complete")
fmt.Fprintln(os.Stderr, " sprint-compare") fmt.Fprintln(w, " sprint-compare")
fmt.Fprintln(os.Stderr, " state-metrics") fmt.Fprintln(w, " state-metrics")
fmt.Fprintln(os.Stderr, " validate-state") fmt.Fprintln(w, " validate-state")
fmt.Fprintln(os.Stderr, " validate-story-creation") fmt.Fprintln(w, " validate-story-creation")
fmt.Fprintln(os.Stderr, " list-sessions") fmt.Fprintln(w, " list-sessions")
fmt.Fprintln(os.Stderr, " tmux-wrapper") fmt.Fprintln(w, " tmux-wrapper")
fmt.Fprintln(os.Stderr, " heartbeat-check") fmt.Fprintln(w, " heartbeat-check")
fmt.Fprintln(os.Stderr, " codex-status-check") fmt.Fprintln(w, " codex-status-check")
fmt.Fprintln(os.Stderr, " tmux-status-check") fmt.Fprintln(w, " tmux-status-check")
fmt.Fprintln(os.Stderr, " monitor-session") fmt.Fprintln(w, " monitor-session")
fmt.Fprintln(os.Stderr, " orchestrator-helper") fmt.Fprintln(w, " orchestrator-helper")
fmt.Fprintln(os.Stderr, " agent-config") fmt.Fprintln(w, " agent-config")
} }

View File

@ -200,12 +200,12 @@ func orchestratorAgentsResolve(args []string) int {
} }
} }
if stateFile == "" || storyID == "" || task == "" { if storyID == "" || task == "" || (stateFile == "" && agentsPath == "") {
writeJSON(map[string]any{"ok": false, "error": "missing_args"}) writeJSON(map[string]any{"ok": false, "error": "missing_args"})
return 1 return 1
} }
if agentsPath == "" { if agentsPath == "" && stateFile != "" {
agentsPath = findFrontmatterValue(stateFile, "agentsFile") agentsPath = findFrontmatterValue(stateFile, "agentsFile")
} }
if agentsPath == "" || !fileExists(agentsPath) { if agentsPath == "" || !fileExists(agentsPath) {

View File

@ -2,12 +2,16 @@ package main
import ( import (
"fmt" "fmt"
"io"
"os" "os"
) )
func cmdOrchestratorHelper(args []string) int { func cmdOrchestratorHelper(args []string) int {
if len(args) == 0 { if len(args) == 0 {
return orchestratorUsage() return orchestratorUsage(os.Stderr, 1)
}
if isHelpFlag(args[0]) {
return orchestratorUsage(os.Stdout, 0)
} }
action := args[0] action := args[0]
args = args[1:] args = args[1:]
@ -50,36 +54,36 @@ func cmdOrchestratorHelper(args []string) int {
case "agents-resolve": case "agents-resolve":
return orchestratorAgentsResolve(args) return orchestratorAgentsResolve(args)
default: default:
return orchestratorUsage() return orchestratorUsage(os.Stderr, 1)
} }
} }
func orchestratorUsage() int { func orchestratorUsage(w io.Writer, code int) int {
fmt.Fprintln(os.Stderr, "Usage: orchestrator-helper <action> [args]") fmt.Fprintln(w, "Usage: orchestrator-helper <action> [args]")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(w, "")
fmt.Fprintln(os.Stderr, "Actions:") fmt.Fprintln(w, "Actions:")
fmt.Fprintln(os.Stderr, " sprint-status get <story_key>") fmt.Fprintln(w, " sprint-status get <story_key>")
fmt.Fprintln(os.Stderr, " sprint-status exists") fmt.Fprintln(w, " sprint-status exists")
fmt.Fprintln(os.Stderr, " sprint-status check-epic <epic>") fmt.Fprintln(w, " sprint-status check-epic <epic>")
fmt.Fprintln(os.Stderr, " parse-output <file> <step>") fmt.Fprintln(w, " parse-output <file> <step>")
fmt.Fprintln(os.Stderr, " marker create --epic E --story S --remaining N --state-file F") fmt.Fprintln(w, " marker create --epic E --story S --remaining N --state-file F")
fmt.Fprintln(os.Stderr, " marker remove") fmt.Fprintln(w, " marker remove")
fmt.Fprintln(os.Stderr, " marker check") fmt.Fprintln(w, " marker check")
fmt.Fprintln(os.Stderr, " marker heartbeat") fmt.Fprintln(w, " marker heartbeat")
fmt.Fprintln(os.Stderr, " state-list <folder>") fmt.Fprintln(w, " state-list <folder>")
fmt.Fprintln(os.Stderr, " state-latest <folder> [status]") fmt.Fprintln(w, " state-latest <folder> [status]")
fmt.Fprintln(os.Stderr, " state-latest-incomplete <folder>") fmt.Fprintln(w, " state-latest-incomplete <folder>")
fmt.Fprintln(os.Stderr, " state-summary <file>") fmt.Fprintln(w, " state-summary <file>")
fmt.Fprintln(os.Stderr, " state-update <file> --set k=v") fmt.Fprintln(w, " state-update <file> --set k=v")
fmt.Fprintln(os.Stderr, " escalate <trigger> <context>") fmt.Fprintln(w, " escalate <trigger> <context>")
fmt.Fprintln(os.Stderr, " commit-ready <story_id>") fmt.Fprintln(w, " commit-ready <story_id>")
fmt.Fprintln(os.Stderr, " normalize-key <input> [--to id|key|prefix|json]") fmt.Fprintln(w, " normalize-key <input> [--to id|key|prefix|json]")
fmt.Fprintln(os.Stderr, " story-file-status <story>") fmt.Fprintln(w, " story-file-status <story>")
fmt.Fprintln(os.Stderr, " verify-code-review <story>") fmt.Fprintln(w, " verify-code-review <story>")
fmt.Fprintln(os.Stderr, " check-epic-complete <epic> <story> [--state-file path]") fmt.Fprintln(w, " check-epic-complete <epic> <story> [--state-file path]")
fmt.Fprintln(os.Stderr, " get-epic-stories <epic> [--state-file path]") fmt.Fprintln(w, " get-epic-stories <epic> [--state-file path]")
fmt.Fprintln(os.Stderr, " check-blocking <story_id>") fmt.Fprintln(w, " check-blocking <story_id>")
fmt.Fprintln(os.Stderr, " agents-build --state-file path --complexity-file path --output path --config-json '{}'") fmt.Fprintln(w, " agents-build --state-file path --complexity-file path --output path --config-json '{}'")
fmt.Fprintln(os.Stderr, " agents-resolve --state-file path --story ID --task create|dev|auto|review [--agents-file path]") fmt.Fprintln(w, " agents-resolve --state-file path --story ID --task create|dev|auto|review [--agents-file path]")
return 1 return code
} }

View File

@ -146,10 +146,9 @@ func orchestratorCheckEpicComplete(args []string) int {
} }
} }
epicsDir := filepath.Join(getProjectRoot(), "_bmad-output", "implementation-artifacts") epicFile := findEpicFile(getProjectRoot(), epicNumber)
matches, _ := filepath.Glob(filepath.Join(epicsDir, fmt.Sprintf("epic-%s-*.md", epicNumber))) if epicFile != "" {
if len(matches) > 0 { content, _ := readFile(epicFile)
content, _ := readFile(matches[0])
re := regexp.MustCompile(regexp.QuoteMeta(epicNumber) + `\.[0-9]+`) re := regexp.MustCompile(regexp.QuoteMeta(epicNumber) + `\.[0-9]+`)
ids := re.FindAllString(content, -1) ids := re.FindAllString(content, -1)
ids = uniqueSortedStories(ids) ids = uniqueSortedStories(ids)

View File

@ -1,10 +1,11 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "strconv"
"strings" "strings"
) )
@ -69,20 +70,53 @@ func orchestratorMarker(args []string) int {
} }
} }
_ = ensureDir(filepath.Dir(markerFile))
if heartbeat == "" { if heartbeat == "" {
heartbeat = nowUTC().Format("2006-01-02T15:04:05Z") heartbeat = nowUTC().Format("2006-01-02T15:04:05Z")
} }
payload := fmt.Sprintf("{\n \"epic\": %q,\n \"currentStory\": %q,\n \"storiesRemaining\": %s,\n \"stateFile\": %q,\n \"createdAt\": %q,\n \"heartbeat\": %q,\n \"pid\": %s,\n \"projectSlug\": %q\n}\n", if err := ensureDir(filepath.Dir(markerFile)); err != nil {
epic, story, remaining, stateFile, nowUTC().Format("2006-01-02T15:04:05Z"), heartbeat, pid, projectSlug) fmt.Fprintf(os.Stderr, "Failed to create marker directory %s: %v\n", filepath.Dir(markerFile), err)
_ = os.WriteFile(markerFile, []byte(payload), 0o644) return 1
}
remainingNum, err := strconv.Atoi(remaining)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid --remaining value %q\n", remaining)
return 1
}
pidNum, err := strconv.Atoi(pid)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid --pid value %q\n", pid)
return 1
}
payloadObj := map[string]any{
"epic": epic,
"currentStory": story,
"storiesRemaining": remainingNum,
"stateFile": stateFile,
"createdAt": nowUTC().Format("2006-01-02T15:04:05Z"),
"heartbeat": heartbeat,
"pid": pidNum,
"projectSlug": projectSlug,
}
payload, err := json.MarshalIndent(payloadObj, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to serialize marker %s: %v\n", markerFile, err)
return 1
}
if err := writeFileAtomic(markerFile, append(payload, '\n')); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write marker %s: %v\n", markerFile, err)
return 1
}
fmt.Printf("Marker created: %s\n", markerFile) fmt.Printf("Marker created: %s\n", markerFile)
return 0 return 0
case "remove": case "remove":
_ = os.Remove(markerFile) if err := os.Remove(markerFile); err != nil && !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Failed to remove marker %s: %v\n", markerFile, err)
return 1
}
fmt.Println("Marker removed") fmt.Println("Marker removed")
return 0 return 0
@ -110,8 +144,21 @@ func orchestratorMarker(args []string) int {
return 1 return 1
} }
newHeartbeat := nowUTC().Format("2006-01-02T15:04:05Z") newHeartbeat := nowUTC().Format("2006-01-02T15:04:05Z")
updated := regexp.MustCompile(`"heartbeat":.*$`).ReplaceAllString(content, fmt.Sprintf("\"heartbeat\": \"%s\"", newHeartbeat)) var marker map[string]any
_ = os.WriteFile(markerFile, []byte(updated), 0o644) if err := json.Unmarshal([]byte(content), &marker); err != nil {
fmt.Fprintf(os.Stderr, "Marker file is not valid JSON: %v\n", err)
return 1
}
marker["heartbeat"] = newHeartbeat
updated, err := json.MarshalIndent(marker, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to serialize marker %s: %v\n", markerFile, err)
return 1
}
if err := writeFileAtomic(markerFile, append(updated, '\n')); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write marker %s: %v\n", markerFile, err)
return 1
}
fmt.Printf("Heartbeat updated: %s\n", newHeartbeat) fmt.Printf("Heartbeat updated: %s\n", newHeartbeat)
return 0 return 0

View File

@ -1,13 +1,18 @@
package main package main
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp" "regexp"
"strings" "strings"
"time"
) )
const parseOutputTimeout = 2 * time.Minute
func orchestratorParseOutput(args []string) int { func orchestratorParseOutput(args []string) int {
if len(args) < 2 { if len(args) < 2 {
fmt.Println("{\"status\":\"error\",\"reason\":\"output file not found or empty\"}") fmt.Println("{\"status\":\"error\",\"reason\":\"output file not found or empty\"}")
@ -36,7 +41,10 @@ func orchestratorParseOutput(args []string) int {
prompt := buildParsePrompt(stepType, content) prompt := buildParsePrompt(stepType, content)
cmd := exec.Command("claude", "-p", "--model", "haiku", prompt) ctx, cancel := context.WithTimeout(context.Background(), parseOutputTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "claude", "-p", "--model", "haiku", prompt)
env := []string{} env := []string{}
for _, e := range os.Environ() { for _, e := range os.Environ() {
if !strings.HasPrefix(e, "CLAUDECODE=") { if !strings.HasPrefix(e, "CLAUDECODE=") {
@ -46,6 +54,10 @@ func orchestratorParseOutput(args []string) int {
cmd.Env = append(env, "STORY_AUTOMATOR_CHILD=true") cmd.Env = append(env, "STORY_AUTOMATOR_CHILD=true")
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("{\"status\":\"error\",\"reason\":\"sub-agent call timed out\"}")
return 1
}
fmt.Println("{\"status\":\"error\",\"reason\":\"sub-agent call failed\"}") fmt.Println("{\"status\":\"error\",\"reason\":\"sub-agent call failed\"}")
return 1 return 1
} }
@ -89,8 +101,11 @@ func extractJSONLine(result string) string {
lines := trimLines(result) lines := trimLines(result)
jsonRe := regexp.MustCompile(`\{.*\}`) jsonRe := regexp.MustCompile(`\{.*\}`)
for _, line := range lines { for _, line := range lines {
if jsonRe.MatchString(line) { for _, match := range jsonRe.FindAllString(line, -1) {
return jsonRe.FindString(line) match = strings.TrimSpace(match)
if json.Valid([]byte(match)) {
return match
}
} }
} }
return "" return ""

View File

@ -152,9 +152,9 @@ func sprintStatusEpic(statusFile, epic string) ([]string, int) {
if !seen[key] { if !seen[key] {
stories = append(stories, key) stories = append(stories, key)
seen[key] = true seen[key] = true
} if len(status) > 0 && status[0] == "done" {
if len(status) > 0 && status[0] == "done" { doneCount++
doneCount++ }
} }
} }
} }

View File

@ -169,16 +169,27 @@ func orchestratorStateUpdate(args []string) int {
} }
key := parts[0] key := parts[0]
val := parts[1] val := parts[1]
replaced := false
for idx, line := range lines { for idx, line := range lines {
if strings.HasPrefix(line, key+":") { if strings.HasPrefix(line, key+":") {
lines[idx] = fmt.Sprintf("%s: %s", key, val) lines[idx] = fmt.Sprintf("%s: %s", key, val)
replaced = true
} }
} }
updatedKeys = append(updatedKeys, key) if replaced {
updatedKeys = append(updatedKeys, key)
}
} }
} }
_ = os.WriteFile(file, []byte(strings.Join(lines, "\n")), 0o644) if len(updatedKeys) == 0 {
writeJSON(map[string]any{"ok": false, "error": "keys_not_found", "updated": updatedKeys})
return 1
}
if err := os.WriteFile(file, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
writeJSON(map[string]any{"ok": false, "error": "write_failed", "updated": []string{}})
return 1
}
writeJSON(map[string]any{"ok": true, "updated": updatedKeys}) writeJSON(map[string]any{"ok": true, "updated": updatedKeys})
return 0 return 0
} }
@ -199,14 +210,19 @@ func readStoryRangeFromState(stateFile string) []string {
for _, line := range lines { for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "storyRange:") { if strings.HasPrefix(strings.TrimSpace(line), "storyRange:") {
inRange = true inRange = true
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "storyRange:"))
if parsed := parseStringListLiteral(raw); parsed != nil {
storyRange = parsed
inRange = false
continue
}
if strings.HasSuffix(strings.TrimSpace(line), "[]") { if strings.HasSuffix(strings.TrimSpace(line), "[]") {
storyRange = []string{} storyRange = []string{}
} }
continue continue
} }
if inRange && strings.HasPrefix(strings.TrimSpace(line), "-") { if inRange && strings.HasPrefix(strings.TrimSpace(line), "-") {
val := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-")) val := unquoteScalar(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-")))
val = strings.Trim(val, "\"")
if val != "" { if val != "" {
storyRange = append(storyRange, val) storyRange = append(storyRange, val)
} }

View File

@ -145,10 +145,9 @@ func normalizeStoryKey(projectRoot, input string) (struct{ ID, Prefix, Key strin
statusFile := sprintStatusFile(projectRoot) statusFile := sprintStatusFile(projectRoot)
if fileExists(statusFile) { if fileExists(statusFile) {
content, _ := readFile(statusFile) content, _ := readFile(statusFile)
re := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(result.Prefix) + `-`) // full key re := regexp.MustCompile(`(?m)^\s*(` + regexp.QuoteMeta(result.Prefix) + `-[^:\s]+)\s*:`)
lines := re.FindAllString(content, -1) if match := re.FindStringSubmatch(content); len(match) == 2 {
if len(lines) > 0 { result.Key = strings.TrimSpace(match[1])
result.Key = strings.TrimSpace(strings.SplitN(lines[0], ":", 2)[0])
} }
} }
} }

View File

@ -28,8 +28,12 @@ func findFrontmatterValueCase(path, key string) string {
front := extractFrontmatter(content) front := extractFrontmatter(content)
lines := trimLines(front) lines := trimLines(front)
for _, line := range lines { for _, line := range lines {
if strings.HasPrefix(line, key+":") { idx := strings.Index(line, ":")
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, key+":")), "\"") if idx < 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(line[:idx]), key) {
return unquoteScalar(strings.TrimSpace(line[idx+1:]))
} }
} }
return "" return ""

View File

@ -388,13 +388,19 @@ func cmdSprintCompare(args []string) int {
} }
if strings.HasPrefix(line, "storyRange:") { if strings.HasPrefix(line, "storyRange:") {
key = "storyRange" key = "storyRange"
raw := strings.TrimSpace(strings.TrimPrefix(line, "storyRange:"))
if parsed := parseStringListLiteral(raw); parsed != nil {
storyRange = parsed
key = ""
continue
}
if strings.HasSuffix(strings.TrimSpace(line), "[]") { if strings.HasSuffix(strings.TrimSpace(line), "[]") {
storyRange = []string{} storyRange = []string{}
} }
continue continue
} }
if key == "storyRange" && strings.HasPrefix(strings.TrimSpace(line), "-") { if key == "storyRange" && strings.HasPrefix(strings.TrimSpace(line), "-") {
storyRange = append(storyRange, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))) storyRange = append(storyRange, unquoteScalar(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))))
continue continue
} }
if regexp.MustCompile(`^\S+:`).MatchString(line) && !strings.HasPrefix(line, "storyRange:") { if regexp.MustCompile(`^\S+:`).MatchString(line) && !strings.HasPrefix(line, "storyRange:") {
@ -539,11 +545,12 @@ func cmdValidateState(args []string) int {
if keyRe.MatchString(line) { if keyRe.MatchString(line) {
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0]) key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1]) rawVal := strings.TrimSpace(parts[1])
if val == "" { if rawVal == "" {
fields[key] = []string{} fields[key] = []string{}
currentKey = key currentKey = key
} else { } else {
val := unquoteScalar(rawVal)
fields[key] = val fields[key] = val
currentKey = "" currentKey = ""
} }
@ -551,7 +558,7 @@ func cmdValidateState(args []string) int {
} }
if currentKey != "" && strings.HasPrefix(strings.TrimSpace(line), "-") { if currentKey != "" && strings.HasPrefix(strings.TrimSpace(line), "-") {
items, _ := fields[currentKey].([]string) items, _ := fields[currentKey].([]string)
items = append(items, strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))) items = append(items, unquoteScalar(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))))
fields[currentKey] = items fields[currentKey] = items
} }
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -40,17 +41,23 @@ type tmuxStatus struct {
func cmdTmuxWrapper(args []string) int { func cmdTmuxWrapper(args []string) int {
if len(args) == 0 { if len(args) == 0 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
}
if isHelpFlag(args[0]) {
return tmuxWrapperUsage(os.Stdout, 0)
} }
action := args[0] action := args[0]
args = args[1:] args = args[1:]
switch action { switch action {
case "spawn": case "spawn":
if len(args) > 0 && isHelpFlag(args[0]) {
return tmuxWrapperUsage(os.Stdout, 0)
}
return tmuxWrapperSpawn(args) return tmuxWrapperSpawn(args)
case "name": case "name":
if len(args) < 3 { if len(args) < 3 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
step := args[0] step := args[0]
epic := args[1] epic := args[1]
@ -71,7 +78,7 @@ func cmdTmuxWrapper(args []string) int {
return 0 return 0
case "kill": case "kill":
if len(args) < 1 { if len(args) < 1 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
tmuxKillSession(args[0]) tmuxKillSession(args[0])
return 0 return 0
@ -88,7 +95,7 @@ func cmdTmuxWrapper(args []string) int {
return 0 return 0
case "exists": case "exists":
if len(args) < 1 { if len(args) < 1 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
if tmuxHasSession(args[0]) { if tmuxHasSession(args[0]) {
fmt.Println("true") fmt.Println("true")
@ -97,6 +104,9 @@ func cmdTmuxWrapper(args []string) int {
fmt.Println("false") fmt.Println("false")
return 1 return 1
case "build-cmd": case "build-cmd":
if len(args) > 0 && isHelpFlag(args[0]) {
return tmuxWrapperUsage(os.Stdout, 0)
}
return tmuxWrapperBuildCmd(args) return tmuxWrapperBuildCmd(args)
case "project-slug": case "project-slug":
fmt.Println(getProjectSlug()) fmt.Println(getProjectSlug())
@ -106,7 +116,7 @@ func cmdTmuxWrapper(args []string) int {
return 0 return 0
case "story-suffix": case "story-suffix":
if len(args) < 1 { if len(args) < 1 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
fmt.Println(strings.ReplaceAll(args[0], ".", "-")) fmt.Println(strings.ReplaceAll(args[0], ".", "-"))
return 0 return 0
@ -120,33 +130,33 @@ func cmdTmuxWrapper(args []string) int {
fmt.Println(getSkillPrefix(getAgentType())) fmt.Println(getSkillPrefix(getAgentType()))
return 0 return 0
default: default:
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
} }
func tmuxWrapperUsage() int { func tmuxWrapperUsage(w io.Writer, code int) int {
fmt.Fprintln(os.Stderr, "Usage: tmux-wrapper <action> [args...]") fmt.Fprintln(w, "Usage: tmux-wrapper <action> [args...]")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(w, "")
fmt.Fprintln(os.Stderr, "Actions:") fmt.Fprintln(w, "Actions:")
fmt.Fprintln(os.Stderr, " spawn <step> <epic> <story_id> [--command \"...\"] [--cycle N] [--agent TYPE]") fmt.Fprintln(w, " spawn <step> <epic> <story_id> [--command \"...\"] [--cycle N] [--agent TYPE]")
fmt.Fprintln(os.Stderr, " name <step> <epic> <story_id> [--cycle N]") fmt.Fprintln(w, " name <step> <epic> <story_id> [--cycle N]")
fmt.Fprintln(os.Stderr, " list [--project-only]") fmt.Fprintln(w, " list [--project-only]")
fmt.Fprintln(os.Stderr, " kill <session_name>") fmt.Fprintln(w, " kill <session_name>")
fmt.Fprintln(os.Stderr, " kill-all [--project-only]") fmt.Fprintln(w, " kill-all [--project-only]")
fmt.Fprintln(os.Stderr, " exists <session_name>") fmt.Fprintln(w, " exists <session_name>")
fmt.Fprintln(os.Stderr, " build-cmd <step> <story_id> [--agent TYPE] [extra_instruction]") fmt.Fprintln(w, " build-cmd <step> <story_id> [--agent TYPE] [extra_instruction]")
fmt.Fprintln(os.Stderr, " project-slug") fmt.Fprintln(w, " project-slug")
fmt.Fprintln(os.Stderr, " project-hash") fmt.Fprintln(w, " project-hash")
fmt.Fprintln(os.Stderr, " story-suffix <story_id>") fmt.Fprintln(w, " story-suffix <story_id>")
fmt.Fprintln(os.Stderr, " agent-type") fmt.Fprintln(w, " agent-type")
fmt.Fprintln(os.Stderr, " agent-cli") fmt.Fprintln(w, " agent-cli")
fmt.Fprintln(os.Stderr, " skill-prefix") fmt.Fprintln(w, " skill-prefix")
return 1 return code
} }
func tmuxWrapperSpawn(args []string) int { func tmuxWrapperSpawn(args []string) int {
if len(args) < 3 { if len(args) < 3 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
step := args[0] step := args[0]
epic := args[1] epic := args[1]
@ -216,7 +226,7 @@ func tmuxWrapperSpawn(args []string) int {
func tmuxWrapperBuildCmd(args []string) int { func tmuxWrapperBuildCmd(args []string) int {
if len(args) < 2 { if len(args) < 2 {
return tmuxWrapperUsage() return tmuxWrapperUsage(os.Stderr, 1)
} }
step := args[0] step := args[0]
storyID := args[1] storyID := args[1]
@ -928,6 +938,11 @@ func cmdMonitorSession(args []string) int {
fmt.Fprintln(os.Stderr, "Usage: monitor-session <session_name> [options]") fmt.Fprintln(os.Stderr, "Usage: monitor-session <session_name> [options]")
return 1 return 1
} }
if isHelpFlag(args[0]) {
fmt.Println("Usage: monitor-session <session_name> [options]")
fmt.Println("Options: --max-polls N --initial-wait N --project-root PATH --timeout MIN --verbose --json --agent TYPE --workflow TYPE --story-key KEY")
return 0
}
sessionName := "" sessionName := ""
maxPolls := 30 maxPolls := 30

View File

@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"context"
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@ -12,10 +13,16 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
) )
const (
defaultCommandTimeout = 10 * time.Minute
commandTimeoutExit = 124
)
func mustJSON(v any) string { func mustJSON(v any) string {
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {
@ -68,16 +75,25 @@ func md5Hex8(input string) string {
} }
func runCmd(name string, args ...string) (string, error) { func runCmd(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...) ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
err := cmd.Run() err := cmd.Run()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return out.String(), ctx.Err()
}
return out.String(), err return out.String(), err
} }
func runCmdExit(name string, args ...string) (string, int, error) { func runCmdExit(name string, args ...string) (string, int, error) {
cmd := exec.Command(name, args...) ctx, cancel := context.WithTimeout(context.Background(), defaultCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
@ -85,6 +101,9 @@ func runCmdExit(name string, args ...string) (string, int, error) {
if err == nil { if err == nil {
return out.String(), 0, nil return out.String(), 0, nil
} }
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return out.String(), commandTimeoutExit, ctx.Err()
}
var exitErr *exec.ExitError var exitErr *exec.ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
return out.String(), exitErr.ExitCode(), err return out.String(), exitErr.ExitCode(), err
@ -98,8 +117,25 @@ func execLookPath(bin string) (string, error) {
func writeFileAtomic(path string, data []byte) error { func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
tmp := filepath.Join(dir, fmt.Sprintf(".%s.tmp", filepath.Base(path))) tmpFile, err := os.CreateTemp(dir, fmt.Sprintf(".%s.*.tmp", filepath.Base(path)))
if err := os.WriteFile(tmp, data, 0o644); err != nil { if err != nil {
return err
}
tmp := tmpFile.Name()
defer os.Remove(tmp)
if err := tmpFile.Chmod(0o644); err != nil {
_ = tmpFile.Close()
return err
}
if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close()
return err
}
if err := tmpFile.Sync(); err != nil {
_ = tmpFile.Close()
return err
}
if err := tmpFile.Close(); err != nil {
return err return err
} }
return os.Rename(tmp, path) return os.Rename(tmp, path)
@ -160,3 +196,33 @@ func clampInt(val, min, max int) int {
} }
return val return val
} }
func isHelpFlag(arg string) bool {
return arg == "--help" || arg == "-h"
}
func unquoteScalar(val string) string {
val = strings.TrimSpace(val)
if len(val) < 2 {
return val
}
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
if unquoted, err := strconv.Unquote(val); err == nil {
return unquoted
}
return val[1 : len(val)-1]
}
return val
}
func parseStringListLiteral(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
out := []string{}
if err := json.Unmarshal([]byte(raw), &out); err == nil {
return out
}
return nil
}

View File

@ -106,12 +106,22 @@ func cmdValidateStoryCreation(args []string) int {
switch args[i] { switch args[i] {
case "--before": case "--before":
if i+1 < len(args) { if i+1 < len(args) {
before, _ = strconv.Atoi(args[i+1]) n, err := strconv.Atoi(args[i+1])
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid --before value: %s\n", args[i+1])
return 1
}
before = n
i++ i++
} }
case "--after": case "--after":
if i+1 < len(args) { if i+1 < len(args) {
after, _ = strconv.Atoi(args[i+1]) n, err := strconv.Atoi(args[i+1])
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid --after value: %s\n", args[i+1])
return 1
}
after = n
i++ i++
} }
case "--artifacts-dir": case "--artifacts-dir":
@ -133,7 +143,14 @@ func cmdValidateStoryCreation(args []string) int {
fmt.Fprintln(os.Stderr, "Usage: validate-story-creation list <story_id>") fmt.Fprintln(os.Stderr, "Usage: validate-story-creation list <story_id>")
return 1 return 1
} }
listStoryFiles(args[0]) storyID := args[0]
for i := 1; i < len(args); i++ {
if args[i] == "--artifacts-dir" && i+1 < len(args) {
artifactsDir = args[i+1]
i++
}
}
listStoryFiles(storyID)
return 0 return 0
case "prefix": case "prefix":

View File

@ -88,7 +88,7 @@ compare=$(cat "$tmp_compare")
sessions=$(cat "$tmp_sessions") sessions=$(cat "$tmp_sessions")
rm -f "$tmp_compare" "$tmp_sessions" rm -f "$tmp_compare" "$tmp_sessions"
incomplete=$(echo "$compare" | jq -r '.incomplete | join(\", \")') incomplete=$(echo "$compare" | jq -r '.incomplete | join(", ")')
session_count=$(echo "$sessions" | jq -r '.count') session_count=$(echo "$sessions" | jq -r '.count')
``` ```

View File

@ -124,11 +124,13 @@ Refer to `{complexityScoring}` for scoring criteria and thresholds.
# Deterministic threshold # Deterministic threshold
if [ "$selected_count" -ge 4 ]; then if [ "$selected_count" -ge 4 ]; then
# Parallel mode (max 4 workers) # Parallel mode (max 4 workers)
tmp_story_complexity=$(mktemp)
printf "%s\n" $selected_ids | xargs -I{} -P 4 sh -c ' printf "%s\n" $selected_ids | xargs -I{} -P 4 sh -c '
"{parseStory}" parse-story --epic "{epic_path}" --story "{}" --rules "{complexityRules}" \ "{parseStory}" parse-story --epic "{epic_path}" --story "{}" --rules "{complexityRules}" \
| jq -c "{storyId:.storyId,title:.title,complexity:.complexity}" | jq -c "{storyId:.storyId,title:.title,complexity:.complexity}"
' > /tmp/story-complexity.ndjson ' > "$tmp_story_complexity"
stories_json=$(jq -s '.' /tmp/story-complexity.ndjson) stories_json=$(jq -s '.' "$tmp_story_complexity")
rm -f "$tmp_story_complexity"
else else
# Sequential mode # Sequential mode
stories_json='[]' stories_json='[]'

View File

@ -91,8 +91,11 @@ state_file="{outputFile}"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$state_file" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Starting story {story_id}" >> "$state_file"
# Initialize Story Progress row # Initialize Story Progress row
sed -i '' "/<!-- Progress rows/i\\ tmp_state=$(mktemp)
| {story_id} | - | - | - | - | - | in-progress |" "$state_file" 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}**" Display: "**Story {N}/{total}: {title}**"
@ -136,7 +139,8 @@ validation=$("$scripts" validate-story-creation check {story_id} --before $befor
- If `validation.valid == true`: - If `validation.valid == true`:
```bash ```bash
# Update Story Progress: mark create-story done # Update Story Progress: mark create-story done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | - | - | - | - | in-progress |/" "$state_file" tmp_state=$(mktemp)
sed "s/^| ${story_id} |.*$/| ${story_id} | done | - | - | - | - | in-progress |/" "$state_file" > "$tmp_state" && mv "$tmp_state" "$state_file"
``` ```
→ proceed to B → proceed to B
- If `validation.valid == false` AND attempts < 5 retry with next agent (see `{retryStrategy}`) - If `validation.valid == false` AND attempts < 5 retry with next agent (see `{retryStrategy}`)
@ -161,7 +165,7 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
- Return normalized schema only: `next_action`, `confidence`, `error_class`, `reasons` - Return normalized schema only: `next_action`, `confidence`, `error_class`, `reasons`
```bash ```bash
parsed=$("$scripts" orchestrator-helper parse-output "$(echo $result | jq -r '.output_file')" dev) parsed=$("$scripts" orchestrator-helper parse-output "$(printf '%s' "$result" | jq -r '.output_file')" dev)
next_action=$(echo "$parsed" | jq -r '.next_action') next_action=$(echo "$parsed" | jq -r '.next_action')
confidence=$(echo "$parsed" | jq -r '.confidence // 0.0') confidence=$(echo "$parsed" | jq -r '.confidence // 0.0')
error_class=$(echo "$parsed" | jq -r '.error_class // "none"') error_class=$(echo "$parsed" | jq -r '.error_class // "none"')
@ -171,7 +175,8 @@ reasons=$(echo "$parsed" | jq -c '.reasons // []')
- If `next_action == "proceed"`: - If `next_action == "proceed"`:
```bash ```bash
# Update Story Progress: mark dev-story done # Update Story Progress: mark dev-story done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | - | - | - | in-progress |/" "$state_file" 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) → proceed to C (next step)
- If `next_action == "retry"` OR `result.final_state == "crashed"`: - If `next_action == "retry"` OR `result.final_state == "crashed"`:

View File

@ -41,14 +41,16 @@ result=$("$scripts" monitor-session "$session" --json --agent "$current_agent")
- SUCCESS: - SUCCESS:
```bash ```bash
# Update Story Progress: mark automate done # Update Story Progress: mark automate done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | - | - | in-progress |/" "{outputFile}" 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` Display: `[story {N}/{total}] automate -> done`
→ proceed to D → proceed to D
- FAILURE → retry up to 3 attempts (non-blocking, so fewer retries), then log warning: - FAILURE → retry up to 3 attempts (non-blocking, so fewer retries), then log warning:
```bash ```bash
# Update Story Progress: mark automate skipped # Update Story Progress: mark automate skipped
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | skip | - | - | in-progress |/" "{outputFile}" 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)` Display: `[story {N}/{total}] automate -> skip (non-blocking)`
→ proceed to D → proceed to D
@ -88,7 +90,8 @@ Key points:
- **States:** `completed` (verified): - **States:** `completed` (verified):
```bash ```bash
# Update Story Progress: mark code-review done # Update Story Progress: mark code-review done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | - | in-progress |/" "{outputFile}" 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` Display: `[story {N}/{total}] review -> done`
→ E | `incomplete` → count as failed attempt, retry until maxCycles, then CRITICAL escalate (Trigger #8) → E | `incomplete` → count as failed attempt, retry until maxCycles, then CRITICAL escalate (Trigger #8)

View File

@ -25,7 +25,8 @@ ok=$(echo "$commit" | jq -r '.ok')
- If `ok == true`: - If `ok == true`:
```bash ```bash
# Update Story Progress: mark git-commit done # Update Story Progress: mark git-commit done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | in-progress |/" "{outputFile}" 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 → proceed to F
- If `ok == false` → log warning and escalate - If `ok == false` → log warning and escalate
@ -57,7 +58,8 @@ Display: "**✅ Story {N} complete.**"
echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Story {story_id}: ✅ complete (commit + sprint-status verified)" >> "{outputFile}" echo "- **[$(date -u +%Y-%m-%dT%H:%M:%SZ)]** Story {story_id}: ✅ complete (commit + sprint-status verified)" >> "{outputFile}"
# Update Story Progress: mark story done # Update Story Progress: mark story done
sed -i '' "s/^| ${story_id} |.*$/| ${story_id} | done | done | done | done | done | done |/" "{outputFile}" 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` Display: `[story {N}/{total}] finalize -> done`
@ -129,7 +131,9 @@ cmd=$("{scriptsDir}" tmux-wrapper build-cmd retro {epic_number} --agent "claude"
session=$("{scriptsDir}" tmux-wrapper spawn retro "" {epic_number} --agent "claude" --command "$cmd") session=$("{scriptsDir}" tmux-wrapper spawn retro "" {epic_number} --agent "claude" --command "$cmd")
# Monitor with safe failure (never escalate on retro failure) # Monitor with safe failure (never escalate on retro failure)
result=$("{scriptsDir}" monitor-session "$session" --json --agent "claude") retro_timeout=60
[ "$story_count" -gt 10 ] && retro_timeout=90
result=$("{scriptsDir}" monitor-session "$session" --json --agent "claude" --timeout "$retro_timeout")
"{scriptsDir}" tmux-wrapper kill "$session" "{scriptsDir}" tmux-wrapper kill "$session"
retro_status=$(echo "$result" | jq -r '.final_state') retro_status=$(echo "$result" | jq -r '.final_state')

View File

@ -91,12 +91,23 @@ tmp_sessions=$(mktemp)
("{validateState}" validate-state --state "{state_path}" > "$tmp_validation") & ("{validateState}" validate-state --state "{state_path}" > "$tmp_validation") &
validation_pid=$! validation_pid=$!
project_slug=$(echo "$("{deriveProjectSlug}" derive-project-slug --project-root "{project-root}")" | jq -r '.slug') 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") & ("{listSessions}" list-sessions --slug "$project_slug" > "$tmp_sessions") &
sessions_pid=$! sessions_pid=$!
wait "$validation_pid" wait "$validation_pid"; validation_status=$?
wait "$sessions_pid" 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") validation=$(cat "$tmp_validation")
sessions=$(cat "$tmp_sessions") sessions=$(cat "$tmp_sessions")

View File

@ -27,7 +27,7 @@ Use carried-forward context:
- `untracked_live` - `untracked_live`
- Prior structure/session issues - Prior structure/session issues
Load the selected state document again (resolved as `{outputFile}` for this run) to verify progress details. Load the selected state document again (resolved as `{state_path}` for this run) to verify progress details.
### 2. Validate Story Progress Thoroughly ### 2. Validate Story Progress Thoroughly
@ -64,8 +64,8 @@ If `story_count >= 4`, run per-story consistency checks in parallel and return c
```bash ```bash
story_ids=$(echo "$validation" | jq -r '.storyRange[]?') story_ids=$(echo "$validation" | jq -r '.storyRange[]?')
tmp_progress=$(mktemp) tmp_progress=$(mktemp)
printf "%s\n" $story_ids | xargs -I{} -P 4 sh -c \ printf "%s\n" "$story_ids" | xargs -I{} -P 4 sh -c \
'rg -n "^[[:space:]]*\\|[[:space:]]*{}[[:space:]]*\\|" "$0" | head -n 1 | sed "s/^/{}|/"' "$state_path" \ 'id="$1"; file="$2"; rg -n -F "| ${id} |" "$file" | head -n 1 | sed "s/^/${id}|/"' _ "{}" "$state_path" \
> "$tmp_progress" > "$tmp_progress"
progress_rows=$(wc -l < "$tmp_progress" | tr -d ' ') progress_rows=$(wc -l < "$tmp_progress" | tr -d ' ')
rm -f "$tmp_progress" rm -f "$tmp_progress"

View File

@ -94,16 +94,8 @@
</action> </action>
<check if="total_issues_found lt 3"> <check if="total_issues_found lt 3">
<critical>NOT LOOKING HARD ENOUGH - Find more problems!</critical> <action>Re-examine the review surface for missed issues, but report only verified findings.</action>
<action>Re-examine code for: <action>If no additional issues are found, continue with the smaller confirmed set.</action>
- Edge cases and null handling
- Architecture violations
- Documentation gaps
- Integration issues
- Dependency problems
- Git commit message quality (if applicable)
</action>
<action>Find at least 3 more specific, actionable issues</action>
</check> </check>
</step> </step>