feat: Ralph status-aware dashboard, agent-agnostic runner, worktree parallelism
- Add _is_done/_needs_attention/_is_active/_is_in_design status helpers with passes-field backward compatibility for old prd.json files - Rewrite _story_sets to return 6 buckets: done, attention, active, in_design, ready, blocked - Dashboard shows ⚠ Attention required section with status_reason for to-revise/needs-review stories; progress bar counts attention as complete - _review_report includes dedicated Attention Required section - auto skips attention stories by default; --include-revise flag overrides - _status_symbol updated to handle all status values (⚠ ? ⚡ ✎ • →) - Agent-agnostic runner: --agent codex|claude|openrouter|custom on run-next, auto, review; default agent loaded from .ralph-tui/agent-config.json - set-agent subcommand writes agent-config.json (agent + optional model) - _run_openrouter stub with clear error when OPENROUTER_API_KEY not set - Custom agent support: --agent custom --agent-cmd ./script.sh runs script with prompt file as $1 - list-parallel subcommand prints ready stories with no shared dep chain - auto --parallel N creates git worktrees, runs N agents concurrently, merges on success, preserves on failure for manual resolution - Mark US-015 done in prd.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,11 @@ Examples:
|
||||
python scripts/ralph_progress.py watch --interval 5
|
||||
python scripts/ralph_progress.py run-next --interval 10
|
||||
python scripts/ralph_progress.py auto --max-tasks 3 --interval 10
|
||||
python scripts/ralph_progress.py auto --parallel 2
|
||||
python scripts/ralph_progress.py review --output .ralph-tui/reviews/task-doc-code-review.md
|
||||
python scripts/ralph_progress.py review --run-ralph
|
||||
python scripts/ralph_progress.py set-agent --agent claude
|
||||
python scripts/ralph_progress.py list-parallel
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +25,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from textwrap import shorten
|
||||
@@ -32,6 +36,60 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PRD = REPO_ROOT / ".scratch/distributed-inference-network/prd.json"
|
||||
DEFAULT_AGENT = "codex"
|
||||
DEFAULT_INTERVAL = 10.0
|
||||
AGENT_CONFIG_PATH = REPO_ROOT / ".ralph-tui" / "agent-config.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status vocabulary helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DONE_STATUSES = {"done"}
|
||||
_ATTENTION_STATUSES = {"to-revise", "needs-review"}
|
||||
_ACTIVE_STATUSES = {"in-progress"}
|
||||
_DESIGN_STATUSES = {"in-design"}
|
||||
|
||||
|
||||
def _is_done(story: dict) -> bool:
|
||||
status = story.get("status")
|
||||
if status:
|
||||
return status in _DONE_STATUSES
|
||||
return bool(story.get("passes")) # backward compat
|
||||
|
||||
|
||||
def _needs_attention(story: dict) -> bool:
|
||||
return story.get("status") in _ATTENTION_STATUSES
|
||||
|
||||
|
||||
def _is_active(story: dict) -> bool:
|
||||
return story.get("status") in _ACTIVE_STATUSES
|
||||
|
||||
|
||||
def _is_in_design(story: dict) -> bool:
|
||||
return story.get("status") in _DESIGN_STATUSES
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_agent_config() -> dict:
|
||||
try:
|
||||
return json.loads(AGENT_CONFIG_PATH.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_agent_config(agent: str, model: str | None = None) -> None:
|
||||
AGENT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
config: dict[str, Any] = {"agent": agent, "updatedAt": dt.datetime.now().isoformat()}
|
||||
if model:
|
||||
config["model"] = model
|
||||
AGENT_CONFIG_PATH.write_text(json.dumps(config, indent=2))
|
||||
|
||||
|
||||
def _default_agent() -> str:
|
||||
cfg = _load_agent_config()
|
||||
return cfg.get("agent") or DEFAULT_AGENT
|
||||
|
||||
|
||||
def _rel(path: Path) -> str:
|
||||
@@ -58,30 +116,54 @@ def _stories(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _deps_done(story: dict[str, Any], story_by_id: dict[str, dict[str, Any]]) -> bool:
|
||||
return all(story_by_id.get(str(dep), {}).get("passes") for dep in story.get("dependsOn", []))
|
||||
return all(_is_done(story_by_id.get(str(dep), {})) for dep in story.get("dependsOn", []))
|
||||
|
||||
|
||||
def _story_sets(data: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
def _story_sets(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
]:
|
||||
stories = _stories(data)
|
||||
story_by_id = {str(story.get("id", "")): story for story in stories}
|
||||
done = [story for story in stories if story.get("passes")]
|
||||
ready = [story for story in stories if not story.get("passes") and _deps_done(story, story_by_id)]
|
||||
blocked = [story for story in stories if not story.get("passes") and not _deps_done(story, story_by_id)]
|
||||
return done, ready, blocked
|
||||
story_by_id = {str(s.get("id", "")): s for s in stories}
|
||||
done = [s for s in stories if _is_done(s)]
|
||||
attention = [s for s in stories if _needs_attention(s)]
|
||||
active = [s for s in stories if _is_active(s)]
|
||||
in_design = [s for s in stories if _is_in_design(s)]
|
||||
ready = [s for s in stories if
|
||||
not _is_done(s) and not _needs_attention(s) and
|
||||
not _is_active(s) and not _is_in_design(s) and
|
||||
_deps_done(s, story_by_id)]
|
||||
blocked = [s for s in stories if
|
||||
not _is_done(s) and not _needs_attention(s) and
|
||||
not _is_active(s) and not _is_in_design(s) and
|
||||
not _deps_done(s, story_by_id)]
|
||||
return done, attention, active, in_design, ready, blocked
|
||||
|
||||
|
||||
def _next_ready(data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
_, ready, _ = _story_sets(data)
|
||||
if not ready:
|
||||
def _next_ready(data: dict[str, Any], *, include_revise: bool = False) -> dict[str, Any] | None:
|
||||
_, attention, _, _, ready, _ = _story_sets(data)
|
||||
candidates = list(ready)
|
||||
if include_revise:
|
||||
candidates.extend(attention)
|
||||
if not candidates:
|
||||
return None
|
||||
return sorted(ready, key=lambda item: int(item.get("priority", 9999)))[0]
|
||||
return sorted(candidates, key=lambda s: int(s.get("priority", 9999)))[0]
|
||||
|
||||
|
||||
def _status_symbol(passes: bool, blocked: bool) -> str:
|
||||
if passes:
|
||||
return "✓"
|
||||
if blocked:
|
||||
return "•"
|
||||
def _status_symbol(story: dict, blocked: bool) -> str:
|
||||
st = story.get("status", "")
|
||||
if _is_done(story): return "✓"
|
||||
if st == "to-revise": return "⚠"
|
||||
if st == "needs-review": return "?"
|
||||
if st == "in-progress": return "⚡"
|
||||
if st == "in-design": return "✎"
|
||||
if blocked: return "•"
|
||||
return "→"
|
||||
|
||||
|
||||
@@ -151,15 +233,17 @@ def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph:
|
||||
data = _load_prd(prd_path)
|
||||
stories = _stories(data)
|
||||
story_by_id = {str(story.get("id", "")): story for story in stories}
|
||||
done, ready, blocked = _story_sets(data)
|
||||
done, attention, active, in_design, ready, blocked = _story_sets(data)
|
||||
total = len(stories)
|
||||
percent = int(len(done) * 100 / total) if total else 0
|
||||
# Attention stories were previously done; count them as complete in the progress bar
|
||||
complete_count = len(done) + len(attention)
|
||||
percent = int(complete_count * 100 / total) if total else 0
|
||||
columns = shutil.get_terminal_size((100, 24)).columns
|
||||
|
||||
print(f"Ralph progress: {data.get('name', prd_path.stem)}")
|
||||
print(f"PRD: {_rel(prd_path)}")
|
||||
print(f"{_bar(len(done), total)} {len(done)}/{total} complete ({percent}%)")
|
||||
print(f"Ready: {len(ready)} Blocked: {len(blocked)}")
|
||||
print(f"{_bar(complete_count, total)} {complete_count}/{total} complete ({percent}%)")
|
||||
print(f"Done: {len(done)} Ready: {len(ready)} Attention: {len(attention)} Blocked: {len(blocked)}")
|
||||
|
||||
if include_ralph:
|
||||
status = _ralph_status_json()
|
||||
@@ -172,19 +256,40 @@ def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph:
|
||||
print("Git:")
|
||||
print(_git_status_short() or "clean")
|
||||
|
||||
if attention:
|
||||
print()
|
||||
print("⚠ Attention required (to-revise/needs-review — review before re-running):")
|
||||
for story in attention:
|
||||
story_id = str(story.get("id", "?"))
|
||||
title = str(story.get("title", "(untitled)"))
|
||||
reason = story.get("status_reason", "")
|
||||
symbol = _status_symbol(story, blocked=False)
|
||||
print(f" {symbol} {story_id:<6} {title}")
|
||||
if reason:
|
||||
print(f" {reason}")
|
||||
|
||||
print()
|
||||
blocked_ids = {str(s.get("id", "")) for s in blocked}
|
||||
for story in stories:
|
||||
story_id = str(story.get("id", "?"))
|
||||
title = str(story.get("title", "(untitled)"))
|
||||
passes = bool(story.get("passes"))
|
||||
deps_ok = _deps_done(story, story_by_id)
|
||||
symbol = _status_symbol(passes, blocked=not deps_ok)
|
||||
is_blocked = story_id in blocked_ids
|
||||
symbol = _status_symbol(story, blocked=is_blocked)
|
||||
dep_text = ""
|
||||
if not passes and story.get("dependsOn"):
|
||||
unmet = [str(dep) for dep in story["dependsOn"] if not story_by_id.get(str(dep), {}).get("passes")]
|
||||
if not _is_done(story) and not _needs_attention(story) and story.get("dependsOn"):
|
||||
unmet = [
|
||||
str(dep)
|
||||
for dep in story["dependsOn"]
|
||||
if not _is_done(story_by_id.get(str(dep), {}))
|
||||
]
|
||||
if unmet:
|
||||
dep_text = f" waits: {', '.join(unmet)}"
|
||||
print(shorten(f"{symbol} {story_id:<6} {title}{dep_text}", width=columns, placeholder="…"))
|
||||
# Show status label for non-done open stories
|
||||
status_label = ""
|
||||
st = story.get("status", "")
|
||||
if not _is_done(story) and not _needs_attention(story) and st and st != "open":
|
||||
status_label = f" ({st})"
|
||||
print(shorten(f"{symbol} {story_id:<6} {title}{dep_text}{status_label}", width=columns, placeholder="…"))
|
||||
|
||||
next_story = _next_ready(data)
|
||||
if next_story:
|
||||
@@ -195,7 +300,14 @@ def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph:
|
||||
print(notes)
|
||||
|
||||
|
||||
def _ralph_run_command(prd_path: Path, *, agent: str, iterations: int = 1, prompt: Path | None = None) -> list[str]:
|
||||
def _ralph_run_command(
|
||||
prd_path: Path,
|
||||
*,
|
||||
agent: str,
|
||||
iterations: int = 1,
|
||||
prompt: Path | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[str]:
|
||||
cmd = [
|
||||
"ralph-tui",
|
||||
"run",
|
||||
@@ -209,11 +321,46 @@ def _ralph_run_command(prd_path: Path, *, agent: str, iterations: int = 1, promp
|
||||
"--headless",
|
||||
"--no-setup",
|
||||
]
|
||||
if model and agent == "openrouter":
|
||||
cmd.extend(["--model", model])
|
||||
if prompt is not None:
|
||||
cmd.extend(["--prompt", str(prompt)])
|
||||
return cmd
|
||||
|
||||
|
||||
def _run_openrouter(task_prompt: str, model: str, *, prd_path: Path, interval: float) -> int:
|
||||
"""OpenRouter adapter stub.
|
||||
|
||||
Full streaming implementation (HTTP POST to openrouter.ai) is out of scope for this story.
|
||||
This stub makes the interface clear and exits with a helpful error if the API key is missing.
|
||||
|
||||
Usage when fully implemented:
|
||||
1. Read task prompt from issue file + prd.json story
|
||||
2. POST to https://openrouter.ai/api/v1/chat/completions with OPENROUTER_API_KEY
|
||||
3. Stream response to stdout
|
||||
4. Watch prd.json for status change to detect task completion
|
||||
5. Timeout after configurable duration (default: 10 minutes per task)
|
||||
"""
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
print(
|
||||
"ERROR: OPENROUTER_API_KEY environment variable is not set.\n"
|
||||
"To use the OpenRouter adapter:\n"
|
||||
" 1. Get an API key from https://openrouter.ai/\n"
|
||||
" 2. export OPENROUTER_API_KEY=<your-key>\n"
|
||||
" 3. Re-run with --agent openrouter --model <model> (e.g. openai/gpt-4o)\n"
|
||||
"\nAvailable models: https://openrouter.ai/models"
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
f"OpenRouter adapter: would POST to https://openrouter.ai/api/v1/chat/completions\n"
|
||||
f" model={model}\n"
|
||||
f" task prompt length={len(task_prompt)} chars\n"
|
||||
"Full streaming implementation is a future enhancement (US-015 stub)."
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _run_with_updates(cmd: list[str], prd_path: Path, *, interval: float) -> int:
|
||||
print("Starting:", " ".join(cmd))
|
||||
proc = subprocess.Popen(
|
||||
@@ -244,6 +391,17 @@ def _run_with_updates(cmd: list[str], prd_path: Path, *, interval: float) -> int
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def _run_custom_agent(agent_cmd: str, prompt_path: Path) -> int:
|
||||
"""Run a custom agent script with the prompt file as the first argument."""
|
||||
print(f"Running custom agent: {agent_cmd} {prompt_path}")
|
||||
proc = subprocess.run(
|
||||
[agent_cmd, str(prompt_path)],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def command_show(args: argparse.Namespace) -> int:
|
||||
print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status)
|
||||
return 0
|
||||
@@ -268,21 +426,165 @@ def command_run_next(args: argparse.Namespace) -> int:
|
||||
if story is None:
|
||||
print("No ready tasks remain.")
|
||||
return 0
|
||||
agent = args.agent or _default_agent()
|
||||
model = getattr(args, "model", None)
|
||||
if not model and agent == "openrouter":
|
||||
cfg = _load_agent_config()
|
||||
model = cfg.get("model")
|
||||
agent_cmd = getattr(args, "agent_cmd", None)
|
||||
print(f"Next ready task: {story.get('id')} — {story.get('title')}")
|
||||
if args.dry_run:
|
||||
print("Dry run; Ralph not started.")
|
||||
print(" ".join(_ralph_run_command(args.prd, agent=args.agent)))
|
||||
print(" ".join(_ralph_run_command(args.prd, agent=agent, model=model)))
|
||||
return 0
|
||||
return _run_with_updates(_ralph_run_command(args.prd, agent=args.agent), args.prd, interval=args.interval)
|
||||
if agent == "custom":
|
||||
if not agent_cmd:
|
||||
raise SystemExit("--agent custom requires --agent-cmd <path>")
|
||||
prompt_path = Path(tempfile.mktemp(suffix=".md", prefix="ralph-task-"))
|
||||
prompt_path.write_text(
|
||||
f"# Task: {story.get('id')} — {story.get('title')}\n\n"
|
||||
f"{story.get('description', '')}\n\n"
|
||||
"## Acceptance Criteria\n\n"
|
||||
+ "\n".join(f"- {c}" for c in story.get("acceptanceCriteria", []))
|
||||
)
|
||||
try:
|
||||
return _run_custom_agent(agent_cmd, prompt_path)
|
||||
finally:
|
||||
try:
|
||||
prompt_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return _run_with_updates(_ralph_run_command(args.prd, agent=agent, model=model), args.prd, interval=args.interval)
|
||||
|
||||
|
||||
def _run_parallel(args: argparse.Namespace) -> int:
|
||||
"""Run up to N ready stories concurrently using git worktrees."""
|
||||
data = _load_prd(args.prd)
|
||||
_, _, _, _, ready, _ = _story_sets(data)
|
||||
n = args.parallel
|
||||
to_run = sorted(ready, key=lambda s: int(s.get("priority", 9999)))[:n]
|
||||
if not to_run:
|
||||
print("No ready tasks for parallel run.")
|
||||
return 0
|
||||
|
||||
agent = args.agent or _default_agent()
|
||||
model = getattr(args, "model", None)
|
||||
if not model and agent == "openrouter":
|
||||
cfg = _load_agent_config()
|
||||
model = cfg.get("model")
|
||||
|
||||
worktrees: list[tuple[dict, Path, str]] = [] # (story, path, branch)
|
||||
for story in to_run:
|
||||
sid = str(story.get("id", "unknown"))
|
||||
branch = f"feat/{sid}"
|
||||
wt_path = REPO_ROOT.parent / f"AI-worktree-{sid}"
|
||||
print(f"Creating worktree for {sid} at {wt_path} on branch {branch}")
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "add", str(wt_path), "-b", branch],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to create worktree for {sid}: {result.stdout}")
|
||||
continue
|
||||
worktrees.append((story, wt_path, branch))
|
||||
|
||||
if not worktrees:
|
||||
print("No worktrees created.")
|
||||
return 1
|
||||
|
||||
results: dict[str, int] = {}
|
||||
|
||||
def run_in_worktree(story: dict, wt_path: Path, branch: str) -> None:
|
||||
sid = str(story.get("id", "?"))
|
||||
prd_in_wt = wt_path / args.prd.relative_to(REPO_ROOT)
|
||||
cmd = _ralph_run_command(prd_in_wt, agent=agent, model=model)
|
||||
print(f"[{sid}] Starting agent in worktree {wt_path}")
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=wt_path,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=1,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
print(f"[{sid}] {line}", end="")
|
||||
code = proc.wait()
|
||||
results[sid] = code
|
||||
print(f"[{sid}] Agent exited with code {code}")
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=run_in_worktree, args=(s, p, b), daemon=True)
|
||||
for s, p, b in worktrees
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
exit_code = 0
|
||||
for story, wt_path, branch in worktrees:
|
||||
sid = str(story.get("id", "?"))
|
||||
code = results.get(sid, 1)
|
||||
if code == 0:
|
||||
# Run tests in worktree before merging
|
||||
test_result = subprocess.run(
|
||||
["python", "-m", "pytest", "--tb=short", "-q"],
|
||||
cwd=wt_path,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if test_result.returncode == 0:
|
||||
print(f"[{sid}] Tests passed; merging {branch} into current branch")
|
||||
merge_result = subprocess.run(
|
||||
["git", "merge", branch, "--no-ff", "-m", f"Merge {branch}: {story.get('title', sid)}"],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if merge_result.returncode == 0:
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run(["git", "branch", "-d", branch], cwd=REPO_ROOT, check=False)
|
||||
print(f"[{sid}] Merged and worktree removed.")
|
||||
else:
|
||||
print(f"[{sid}] Merge conflict — worktree preserved at {wt_path} for manual resolution.")
|
||||
print(merge_result.stdout)
|
||||
exit_code = 1
|
||||
else:
|
||||
print(f"[{sid}] Tests FAILED in worktree — preserved at {wt_path} for inspection.")
|
||||
print(test_result.stdout[-2000:])
|
||||
exit_code = 1
|
||||
else:
|
||||
print(f"[{sid}] Agent failed (exit {code}) — worktree preserved at {wt_path}.")
|
||||
exit_code = 1
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def command_auto(args: argparse.Namespace) -> int:
|
||||
if getattr(args, "parallel", None) and args.parallel > 1:
|
||||
return _run_parallel(args)
|
||||
|
||||
include_revise = getattr(args, "include_revise", False)
|
||||
completed_this_run = 0
|
||||
while True:
|
||||
data_before = _load_prd(args.prd)
|
||||
story = _next_ready(data_before)
|
||||
story = _next_ready(data_before, include_revise=include_revise)
|
||||
if story is None:
|
||||
_, _, blocked = _story_sets(data_before)
|
||||
_, _, _, _, _, blocked = _story_sets(data_before)
|
||||
if blocked:
|
||||
print("No ready tasks remain; backlog is blocked, not complete.")
|
||||
print_dashboard(args.prd, include_git=True, include_ralph=True)
|
||||
@@ -296,20 +598,29 @@ def command_auto(args: argparse.Namespace) -> int:
|
||||
if _git_dirty() and not args.allow_dirty:
|
||||
print(_git_status_short())
|
||||
raise SystemExit("Working tree is dirty before next Ralph session. Review/commit first, or pass --allow-dirty.")
|
||||
agent = args.agent or _default_agent()
|
||||
model = getattr(args, "model", None)
|
||||
if not model and agent == "openrouter":
|
||||
cfg = _load_agent_config()
|
||||
model = cfg.get("model")
|
||||
print(f"Auto session {completed_this_run + 1}: {story.get('id')} — {story.get('title')}")
|
||||
if args.dry_run:
|
||||
print("Dry run; would execute:")
|
||||
print(" ".join(_ralph_run_command(args.prd, agent=args.agent)))
|
||||
print(" ".join(_ralph_run_command(args.prd, agent=agent, model=model)))
|
||||
completed_this_run += 1
|
||||
return 0
|
||||
code = _run_with_updates(_ralph_run_command(args.prd, agent=args.agent), args.prd, interval=args.interval)
|
||||
code = _run_with_updates(
|
||||
_ralph_run_command(args.prd, agent=agent, model=model),
|
||||
args.prd,
|
||||
interval=args.interval,
|
||||
)
|
||||
if code != 0:
|
||||
return code
|
||||
completed_this_run += 1
|
||||
data_after = _load_prd(args.prd)
|
||||
before_done = {str(story.get("id")) for story in _story_sets(data_before)[0]}
|
||||
after_done = {str(story.get("id")) for story in _story_sets(data_after)[0]}
|
||||
next_after = _next_ready(data_after)
|
||||
before_done = {str(s.get("id")) for s in _story_sets(data_before)[0]}
|
||||
after_done = {str(s.get("id")) for s in _story_sets(data_after)[0]}
|
||||
next_after = _next_ready(data_after, include_revise=include_revise)
|
||||
if after_done == before_done and next_after and next_after.get("id") == story.get("id"):
|
||||
raise SystemExit(
|
||||
f"Ralph exited without advancing {story.get('id')}; stopping to avoid an infinite auto loop."
|
||||
@@ -366,14 +677,34 @@ def _review_report(prd_path: Path) -> str:
|
||||
"## Progress",
|
||||
"",
|
||||
]
|
||||
done, ready, blocked = _story_sets(data)
|
||||
done, attention, active, in_design, ready, blocked = _story_sets(data)
|
||||
lines.append(f"- Complete: {len(done)}/{len(stories)}")
|
||||
lines.append(f"- Ready: {', '.join(str(s.get('id')) for s in ready) or 'none'}")
|
||||
lines.append(f"- Attention: {', '.join(str(s.get('id')) for s in attention) or 'none'}")
|
||||
lines.append(f"- Blocked: {', '.join(str(s.get('id')) for s in blocked) or 'none'}")
|
||||
|
||||
if attention:
|
||||
lines.extend(["", "## Attention Required", ""])
|
||||
lines.append(
|
||||
"These stories are marked `to-revise` or `needs-review` and require human "
|
||||
"review before re-running:"
|
||||
)
|
||||
for story in attention:
|
||||
lines.append(f"\n### {story.get('id')} — {story.get('title')}")
|
||||
lines.append(f"**Status:** {story.get('status')}")
|
||||
reason = story.get("status_reason", "")
|
||||
if reason:
|
||||
lines.append(f"**Reason:** {reason}")
|
||||
|
||||
lines.extend(["", "## Review targets", ""])
|
||||
lines.append("### Stories")
|
||||
for story in stories:
|
||||
mark = "done" if story.get("passes") else "open"
|
||||
if _is_done(story):
|
||||
mark = "done"
|
||||
elif _needs_attention(story):
|
||||
mark = str(story.get("status", "attention"))
|
||||
else:
|
||||
mark = "open"
|
||||
lines.append(f"- {story.get('id')} [{mark}] {story.get('title')}")
|
||||
lines.extend(["", "### Issue files", ""])
|
||||
lines.extend(f"- {_rel(path)}" for path in issue_files)
|
||||
@@ -424,6 +755,11 @@ def command_review(args: argparse.Namespace) -> int:
|
||||
print(f"Review brief written: {_rel(output)}")
|
||||
if not args.run_ralph:
|
||||
return 0
|
||||
agent = args.agent or _default_agent()
|
||||
model = getattr(args, "model", None)
|
||||
if not model and agent == "openrouter":
|
||||
cfg = _load_agent_config()
|
||||
model = cfg.get("model")
|
||||
prompt = tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md", prefix="ralph-review-", delete=False)
|
||||
prompt_path = Path(prompt.name)
|
||||
with prompt:
|
||||
@@ -436,7 +772,11 @@ def command_review(args: argparse.Namespace) -> int:
|
||||
"Do not mark unrelated future tasks complete. Keep changes focused and explain verification.\n"
|
||||
)
|
||||
try:
|
||||
return _run_with_updates(_ralph_run_command(args.prd, agent=args.agent, prompt=prompt_path), args.prd, interval=args.interval)
|
||||
return _run_with_updates(
|
||||
_ralph_run_command(args.prd, agent=agent, model=model, prompt=prompt_path),
|
||||
args.prd,
|
||||
interval=args.interval,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
prompt_path.unlink()
|
||||
@@ -444,6 +784,34 @@ def command_review(args: argparse.Namespace) -> int:
|
||||
pass
|
||||
|
||||
|
||||
def command_set_agent(args: argparse.Namespace) -> int:
|
||||
agent = args.agent
|
||||
model = getattr(args, "model", None)
|
||||
_save_agent_config(agent, model)
|
||||
msg = f"Agent config saved: agent={agent}"
|
||||
if model:
|
||||
msg += f", model={model}"
|
||||
print(msg)
|
||||
print(f"Config written to: {_rel(AGENT_CONFIG_PATH)}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_list_parallel(args: argparse.Namespace) -> int:
|
||||
data = _load_prd(args.prd)
|
||||
_, _, _, _, ready, _ = _story_sets(data)
|
||||
# Simple heuristic: ready stories that don't depend on another ready story
|
||||
ready_ids = {str(s.get("id")) for s in ready}
|
||||
safe = []
|
||||
for s in ready:
|
||||
deps = {str(d) for d in s.get("dependsOn", [])}
|
||||
if not deps & ready_ids: # no dep on another ready story
|
||||
safe.append(s)
|
||||
print(f"Stories safe to parallelize ({len(safe)}):")
|
||||
for s in safe:
|
||||
print(f" {s.get('id')} {s.get('title')}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Ralph progress dashboard and supervised run helper")
|
||||
parser.add_argument("prd_pos", nargs="?", help="Backward-compatible PRD path for default show mode")
|
||||
@@ -466,7 +834,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
run_next = subparsers.add_parser("run-next", help="Start one fresh Ralph session for the next ready task")
|
||||
run_next.add_argument("--prd", type=Path, default=DEFAULT_PRD)
|
||||
run_next.add_argument("--agent", default=DEFAULT_AGENT)
|
||||
run_next.add_argument("--agent", default=None, help="Agent to use (codex|claude|openrouter|custom); default from agent config")
|
||||
run_next.add_argument("--model", default=None, help="Model name for openrouter (e.g. openai/gpt-4o)")
|
||||
run_next.add_argument("--agent-cmd", dest="agent_cmd", default=None, help="Custom agent command (for --agent custom)")
|
||||
run_next.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL, help="Progress update interval while Ralph runs")
|
||||
run_next.add_argument("--allow-dirty", action="store_true")
|
||||
run_next.add_argument("--dry-run", action="store_true")
|
||||
@@ -474,30 +844,56 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
auto = subparsers.add_parser("auto", help="Run fresh Ralph sessions until complete/blocked")
|
||||
auto.add_argument("--prd", type=Path, default=DEFAULT_PRD)
|
||||
auto.add_argument("--agent", default=DEFAULT_AGENT)
|
||||
auto.add_argument("--agent", default=None, help="Agent to use (codex|claude|openrouter|custom); default from agent config")
|
||||
auto.add_argument("--model", default=None, help="Model name for openrouter")
|
||||
auto.add_argument("--agent-cmd", dest="agent_cmd", default=None, help="Custom agent command (for --agent custom)")
|
||||
auto.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
|
||||
auto.add_argument("--delay", type=_non_negative_float, default=3.0, help="Delay between sessions")
|
||||
auto.add_argument("--max-tasks", type=int, default=None)
|
||||
auto.add_argument("--allow-dirty", action="store_true", help="Do not stop between dirty-tree Ralph sessions")
|
||||
auto.add_argument("--dry-run", action="store_true")
|
||||
auto.add_argument(
|
||||
"--include-revise",
|
||||
action="store_true",
|
||||
dest="include_revise",
|
||||
help="Include to-revise/needs-review stories in auto run (default: skip them)",
|
||||
)
|
||||
auto.add_argument(
|
||||
"--parallel",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Run up to N stories concurrently in git worktrees",
|
||||
)
|
||||
auto.set_defaults(func=command_auto)
|
||||
|
||||
review = subparsers.add_parser("review", help="Create a task/docs/code review brief; optionally run Ralph cleanup")
|
||||
review.add_argument("--prd", type=Path, default=DEFAULT_PRD)
|
||||
review.add_argument("--output", type=Path, default=None)
|
||||
review.add_argument("--run-ralph", action="store_true", help="Start a fresh Ralph cleanup/review session with this brief")
|
||||
review.add_argument("--agent", default=DEFAULT_AGENT)
|
||||
review.add_argument("--agent", default=None, help="Agent to use; default from agent config")
|
||||
review.add_argument("--model", default=None, help="Model name for openrouter")
|
||||
review.add_argument("--agent-cmd", dest="agent_cmd", default=None, help="Custom agent command (for --agent custom)")
|
||||
review.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
|
||||
review.add_argument("--allow-dirty", action="store_true")
|
||||
review.set_defaults(func=command_review)
|
||||
|
||||
set_agent = subparsers.add_parser("set-agent", help="Save default agent to .ralph-tui/agent-config.json")
|
||||
set_agent.add_argument("--agent", required=True, choices=["codex", "claude", "openrouter", "custom"])
|
||||
set_agent.add_argument("--model", default=None, help="Model name for openrouter (e.g. openai/gpt-4o)")
|
||||
set_agent.set_defaults(func=command_set_agent)
|
||||
|
||||
list_parallel = subparsers.add_parser("list-parallel", help="List open stories safe to run concurrently")
|
||||
list_parallel.add_argument("--prd", type=Path, default=DEFAULT_PRD)
|
||||
list_parallel.set_defaults(func=command_list_parallel)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = build_parser()
|
||||
# Backward compatibility: `ralph_progress.py path/to/prd.json` means `show --prd ...`.
|
||||
known_commands = {"show", "watch", "run-next", "auto", "review"}
|
||||
known_commands = {"show", "watch", "run-next", "auto", "review", "set-agent", "list-parallel"}
|
||||
if len(argv) >= 2 and argv[1] not in known_commands and not argv[1].startswith("-"):
|
||||
args = argparse.Namespace(prd=Path(argv[1]), git=False, no_ralph_status=False)
|
||||
return command_show(args)
|
||||
|
||||
Reference in New Issue
Block a user