Files
neuron-tai/scripts/ralph_progress.py
Dobromir Popov d1e75ddded docs: consolidate all docs under docs/ — single source of truth
Move issues (01–29) and PRD from .scratch/distributed-inference-network/
into docs/issues/ and docs/. Update ralph_progress.py DEFAULT_PRD path
and rewrite docs/agents/issue-tracker.md to reflect the new layout.

The distributed_inference_network.egg-info/docs/ mirror is a build
artifact already covered by *.egg-info/ in .gitignore — not committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 14:18:26 +03:00

994 lines
39 KiB
Python

#!/usr/bin/env python3
"""Ralph/prd.json progress dashboard and supervised run helper.
Examples:
python scripts/ralph_progress.py
python scripts/ralph_progress.py show
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
import argparse
import datetime as dt
import json
import os
import select
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from textwrap import shorten
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PRD = REPO_ROOT / "docs/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 _active_worktrees() -> dict[str, str]:
"""Return {STORY-ID: relative-path} for worktrees on feat/<story-id> branches."""
r = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=REPO_ROOT, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
)
worktrees: dict[str, str] = {}
current_path: str | None = None
for line in r.stdout.splitlines():
if line.startswith("worktree "):
current_path = line[len("worktree "):].strip()
elif line.startswith("branch refs/heads/feat/") and current_path:
slug = line[len("branch refs/heads/feat/"):].strip() # e.g. "us-015"
story_id = slug.upper() # "US-015"
try:
rel = str(Path(current_path).relative_to(REPO_ROOT))
except ValueError:
rel = current_path
worktrees[story_id] = rel
return worktrees
def _story_last_commit(story_id: str) -> str:
"""Latest commit subject on feat/<story-id> branch, or empty string."""
r = subprocess.run(
["git", "log", "-1", "--format=%s", f"feat/{story_id.lower()}"],
cwd=REPO_ROOT, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
)
return r.stdout.strip()
def _story_meta(
story: dict,
worktrees: dict[str, str],
session: dict | None,
) -> str | None:
"""One-line metadata string shown below the story title, or None."""
sid = str(story.get("id", ""))
parts: list[str] = []
# agent ---------------------------------------------------------------
agent: str | None = None
if session:
for t in session.get("trackerState", {}).get("tasks", []):
if str(t.get("id")) == sid and t.get("completedInSession"):
agent = session.get("agentPlugin")
break
if not agent and _is_active(story):
agent = _load_agent_config().get("agent")
if agent:
parts.append(agent)
# worktree ------------------------------------------------------------
wt = worktrees.get(sid)
if wt:
parts.append(f"worktree: {wt}")
# summary -------------------------------------------------------------
notes = (story.get("completionNotes") or "").strip()
if not notes and wt:
notes = _story_last_commit(sid)
if notes:
parts.append(f'"{shorten(notes, 72, placeholder="")}"')
return " · ".join(parts) if parts else None
def _rel(path: Path) -> str:
try:
return str(path.relative_to(REPO_ROOT))
except ValueError:
return str(path)
def _load_prd(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise SystemExit(f"PRD not found: {path}")
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON in {path}: {exc}")
def _stories(data: dict[str, Any]) -> list[dict[str, Any]]:
stories = data.get("userStories", [])
if not isinstance(stories, list):
raise SystemExit("PRD userStories must be a list")
return [story for story in stories if isinstance(story, dict)]
def _deps_done(story: dict[str, Any], story_by_id: dict[str, dict[str, Any]]) -> bool:
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]],
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, Any]],
]:
stories = _stories(data)
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], *, 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(candidates, key=lambda s: int(s.get("priority", 9999)))[0]
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 ""
def _bar(done: int, total: int, width: int = 28) -> str:
if total <= 0:
return "[" + "-" * width + "]"
filled = round(width * done / total)
return "[" + "#" * filled + "-" * (width - filled) + "]"
def _git_dirty() -> bool:
result = subprocess.run(
["git", "status", "--porcelain", "--untracked-files=all"],
cwd=REPO_ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return bool(result.stdout.strip())
def _git_status_short() -> str:
result = subprocess.run(
["git", "status", "--branch", "--short", "--untracked-files=all"],
cwd=REPO_ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
return result.stdout.strip()
def _ralph_status_json() -> dict[str, Any] | None:
result = subprocess.run(
["ralph-tui", "status", "--json"],
cwd=REPO_ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if not result.stdout.strip():
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"status": "unknown", "raw": result.stdout.strip()}
def _positive_float(value: str) -> float:
parsed = float(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("value must be > 0")
return parsed
def _non_negative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("value must be >= 0")
return parsed
def print_dashboard(
prd_path: Path,
*,
include_git: bool = False,
include_ralph: bool = True,
compact: bool = False,
) -> None:
data = _load_prd(prd_path)
stories = _stories(data)
story_by_id = {str(story.get("id", "")): story for story in stories}
done, attention, active, in_design, ready, blocked = _story_sets(data)
total = len(stories)
complete_count = len(done) + len(attention)
percent = int(complete_count * 100 / total) if total else 0
columns = shutil.get_terminal_size((100, 24)).columns
# collect metadata once for all stories
worktrees = _active_worktrees() if not compact else {}
session: dict | None = None
print(f"Ralph progress: {data.get('name', prd_path.stem)}")
print(f"PRD: {_rel(prd_path)}")
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:
ralph_status = _ralph_status_json()
if ralph_status:
session = ralph_status # used by _story_meta
status_name = ralph_status.get("status", "unknown")
raw_session = ralph_status.get("session")
session_dict = raw_session if isinstance(raw_session, dict) else {}
session_id = session_dict.get("id", "-")
print(f"Ralph session: {status_name} {session_id}")
if include_git:
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" {shorten(reason, columns - 14, placeholder='')}")
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)"))
is_blocked = story_id in blocked_ids
symbol = _status_symbol(story, blocked=is_blocked)
dep_text = ""
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)}"
st = story.get("status", "")
status_label = ""
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=""))
if not compact:
meta = _story_meta(story, worktrees, session)
if meta:
print(f" {shorten(meta, columns - 10, placeholder='')}")
next_story = _next_ready(data)
if next_story:
print()
print(f"Next ready task: {next_story.get('id')}{next_story.get('title')}")
notes = next_story.get("notes")
if notes:
print(notes)
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",
"--force",
"--agent",
agent,
"--prd",
str(prd_path),
"--iterations",
str(iterations),
"--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(
cmd,
cwd=REPO_ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
next_update = time.monotonic() + interval
assert proc.stdout is not None
stdout_fd = proc.stdout.fileno()
while proc.poll() is None:
timeout = max(0.1, next_update - time.monotonic())
readable, _, _ = select.select([stdout_fd], [], [], timeout)
if readable:
line = proc.stdout.readline()
if line:
print(line, end="")
if time.monotonic() >= next_update:
print("\n--- Ralph progress update ---")
print_dashboard(prd_path, include_git=True, include_ralph=True)
print("--- end update ---\n")
next_update = time.monotonic() + interval
for line in proc.stdout:
print(line, end="")
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, compact=args.compact)
return 0
def command_watch(args: argparse.Namespace) -> int:
while True:
os.system("clear" if sys.stdout.isatty() else "true")
print(dt.datetime.now().isoformat(timespec="seconds"))
print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status, compact=args.compact)
if args.once:
return 0
time.sleep(args.interval)
def command_run_next(args: argparse.Namespace) -> int:
if _git_dirty() and not args.allow_dirty:
print(_git_status_short())
raise SystemExit("Working tree is dirty. Commit/review changes first, or pass --allow-dirty.")
data = _load_prd(args.prd)
story = _next_ready(data)
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=agent, model=model)))
return 0
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, include_revise=include_revise)
if story is None:
_, _, _, _, _, 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)
return 2
print("No ready tasks remain. Backlog is complete.")
print_dashboard(args.prd, include_git=True, include_ralph=True)
return 0
if args.max_tasks is not None and completed_this_run >= args.max_tasks:
print(f"Reached --max-tasks={args.max_tasks}.")
return 0
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=agent, model=model)))
completed_this_run += 1
return 0
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(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."
)
if _git_dirty() and not args.allow_dirty:
print(_git_status_short())
raise SystemExit(
"Ralph produced changes. Stop for controller review, gates, and commit. "
"Re-run auto after commit, or pass --allow-dirty for unattended Ralph-only sessions."
)
time.sleep(args.delay)
def _read_first_lines(path: Path, limit: int = 80) -> str:
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as exc:
return f"<could not read: {exc}>"
return "\n".join(lines[:limit])
IGNORED_REVIEW_PARTS = {".git", ".venv", "__pycache__", "build", "dist", ".mypy_cache", ".pytest_cache"}
def _review_path_allowed(path: Path) -> bool:
return not (IGNORED_REVIEW_PARTS & set(path.relative_to(REPO_ROOT).parts))
def _iter_review_files(pattern: str) -> list[Path]:
return [path for path in REPO_ROOT.glob(pattern) if path.is_file() and _review_path_allowed(path)]
def _iter_files(root: Path, pattern: str) -> list[Path]:
return [path for path in root.rglob(pattern) if path.is_file() and _review_path_allowed(path)]
def _review_report(prd_path: Path) -> str:
data = _load_prd(prd_path)
stories = _stories(data)
issue_files = sorted((prd_path.parent / "issues").glob("*.md"))
adr_files = sorted((REPO_ROOT / "docs/adr").glob("*.md"))
doc_files = sorted((REPO_ROOT / "docs").rglob("*.md"))
grill_files = sorted(_iter_review_files("**/*grill*.md"))
code_roots = [REPO_ROOT / "packages", REPO_ROOT / "tests", REPO_ROOT / "scripts"]
code_files = sorted(path for root in code_roots if root.exists() for path in _iter_files(root, "*.py"))
config_files = [path for pattern in ("*.toml", "*.md") for path in _iter_review_files(pattern)]
lines = [
"# Task / docs / code review brief",
"",
f"Generated: {dt.datetime.now().isoformat(timespec='seconds')}",
f"PRD: {_rel(prd_path)}",
"",
"## Progress",
"",
]
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:
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)
lines.extend(["", "### ADR / generated design docs", ""])
lines.extend(f"- {_rel(path)}" for path in adr_files)
lines.extend(["", "### Other docs", ""])
lines.extend(f"- {_rel(path)}" for path in doc_files if path not in adr_files)
if grill_files:
lines.extend(["", "### Grill-generated docs", ""])
lines.extend(f"- {_rel(path)}" for path in grill_files)
else:
lines.extend(["", "### Grill-generated docs", "", "- No *grill*.md files found."])
lines.extend(["", "### Code files", ""])
lines.extend(f"- {_rel(path)}" for path in sorted(code_files)[:200])
lines.extend(["", "### Config / top-level docs", ""])
lines.extend(f"- {_rel(path)}" for path in sorted(config_files)[:100])
lines.extend([
"",
"## Suggested review instructions for an agent",
"",
"Review the code against the PRD, issue files, ADRs, and any grill-generated docs. Find conflicts between docs and implementation, stale docs, missing tests, and code that implements rejected design decisions. Prefer small cleanup patches that align docs and code with accepted ADRs and completed tasks. Do not mark new stories complete unless their acceptance criteria are implemented and verified.",
"",
"## Key source excerpts",
"",
])
for path in [prd_path, *issue_files[-4:], *adr_files[-4:], *grill_files[-4:]]:
lines.append(f"### {_rel(path)}")
lines.append("```")
lines.append(_read_first_lines(path, limit=80))
lines.append("```")
lines.append("")
return "\n".join(lines)
def command_review(args: argparse.Namespace) -> int:
if args.run_ralph and _git_dirty() and not args.allow_dirty:
print(_git_status_short())
raise SystemExit("Working tree is dirty. Commit/review changes first, or pass --allow-dirty.")
report = _review_report(args.prd)
output = args.output
if output is None:
output_dir = REPO_ROOT / ".ralph-tui" / "reviews"
output_dir.mkdir(parents=True, exist_ok=True)
output = output_dir / f"task-doc-code-review-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.md"
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(report, encoding="utf-8")
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:
prompt.write(
"You are reviewing and cleaning a distributed inference network repo.\n\n"
f"Use this generated brief: {_rel(output)}\n\n"
"Review existing code against the PRD, issue files, ADRs, and any grill-generated docs. "
"Resolve conflicts by aligning docs and code with accepted ADRs and completed story acceptance criteria. "
"Clean stale docs/code only where the evidence is clear. Add or update tests for behavior changes. "
"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=agent, model=model, prompt=prompt_path),
args.prd,
interval=args.interval,
)
finally:
try:
prompt_path.unlink()
except OSError:
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")
parser.add_argument("--prd", type=Path, default=DEFAULT_PRD, help="Path to prd.json")
subparsers = parser.add_subparsers(dest="command")
show = subparsers.add_parser("show", help="Show current progress")
show.add_argument("--prd", type=Path, default=DEFAULT_PRD)
show.add_argument("--git", action="store_true", help="Include git status")
show.add_argument("--no-ralph-status", action="store_true", help="Skip ralph-tui status lookup")
show.add_argument("--compact", action="store_true", help="One line per story, no metadata")
show.set_defaults(func=command_show)
watch = subparsers.add_parser("watch", help="Refresh progress every N seconds")
watch.add_argument("--prd", type=Path, default=DEFAULT_PRD)
watch.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
watch.add_argument("--git", action="store_true")
watch.add_argument("--no-ralph-status", action="store_true", help="Skip ralph-tui status lookup")
watch.add_argument("--once", action="store_true")
watch.add_argument("--compact", action="store_true", help="One line per story, no metadata")
watch.set_defaults(func=command_watch)
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=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")
run_next.set_defaults(func=command_run_next)
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=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=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", "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, compact=False)
return command_show(args)
args = parser.parse_args(argv[1:])
if args.command is None:
args = argparse.Namespace(prd=args.prd, git=False, no_ralph_status=False, compact=False)
return command_show(args)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv))