Files
neuron-tai/scripts/ralph_progress.py
2026-06-29 15:54:00 +03:00

513 lines
20 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 review --output .ralph-tui/reviews/task-doc-code-review.md
python scripts/ralph_progress.py review --run-ralph
"""
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 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
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(story_by_id.get(str(dep), {}).get("passes") 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]]]:
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
def _next_ready(data: dict[str, Any]) -> dict[str, Any] | None:
_, ready, _ = _story_sets(data)
if not ready:
return None
return sorted(ready, key=lambda item: int(item.get("priority", 9999)))[0]
def _status_symbol(passes: bool, blocked: bool) -> str:
if passes:
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) -> None:
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)
total = len(stories)
percent = int(len(done) * 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)}")
if include_ralph:
status = _ralph_status_json()
if status:
status_name = status.get("status", "unknown")
session = status.get("session") if isinstance(status.get("session"), dict) else {}
session_id = session.get("id", "-") if isinstance(session, dict) else "-"
print(f"Ralph session: {status_name} {session_id}")
if include_git:
print("Git:")
print(_git_status_short() or "clean")
print()
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)
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 unmet:
dep_text = f" waits: {', '.join(unmet)}"
print(shorten(f"{symbol} {story_id:<6} {title}{dep_text}", width=columns, 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) -> list[str]:
cmd = [
"ralph-tui",
"run",
"--force",
"--agent",
agent,
"--prd",
str(prd_path),
"--iterations",
str(iterations),
"--headless",
"--no-setup",
]
if prompt is not None:
cmd.extend(["--prompt", str(prompt)])
return cmd
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 command_show(args: argparse.Namespace) -> int:
print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status)
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)
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
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)))
return 0
return _run_with_updates(_ralph_run_command(args.prd, agent=args.agent), args.prd, interval=args.interval)
def command_auto(args: argparse.Namespace) -> int:
completed_this_run = 0
while True:
data_before = _load_prd(args.prd)
story = _next_ready(data_before)
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.")
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)))
completed_this_run += 1
return 0
code = _run_with_updates(_ralph_run_command(args.prd, agent=args.agent), 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)
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, 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"- Blocked: {', '.join(str(s.get('id')) for s in blocked) or 'none'}")
lines.extend(["", "## Review targets", ""])
lines.append("### Stories")
for story in stories:
mark = "done" if story.get("passes") else "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
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=args.agent, prompt=prompt_path), args.prd, interval=args.interval)
finally:
try:
prompt_path.unlink()
except OSError:
pass
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.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.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=DEFAULT_AGENT)
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=DEFAULT_AGENT)
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.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("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
review.add_argument("--allow-dirty", action="store_true")
review.set_defaults(func=command_review)
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"}
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)
args = parser.parse_args(argv[1:])
if args.command is None:
args = argparse.Namespace(prd=args.prd, git=False, no_ralph_status=False)
return command_show(args)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main(sys.argv))