#!/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 / ".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 _active_worktrees() -> dict[str, str]: """Return {STORY-ID: relative-path} for worktrees on feat/ 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/ 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", "").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=\n" " 3. Re-run with --agent openrouter --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 ") 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"" 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))