From 85c13e4e82f6f43f199a6531a0f73405194a606d Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Mon, 29 Jun 2026 16:32:35 +0300 Subject: [PATCH] feat: add per-story metadata line to Ralph dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each story shows an optional second line with agent · worktree · summary: - _active_worktrees(): parses git worktree list --porcelain, maps feat/ branches - _story_meta(): joins agent name, worktree path, completionNotes/last commit subject - print_dashboard: renders metadata line indented below story title - --compact flag on show/watch suppresses metadata for tight one-line-per-story view Also wires worktree-by-default groundwork: _active_worktrees called once per render, worktree paths shown relative to repo root. Co-Authored-By: Claude Sonnet 4.6 --- scripts/ralph_progress.py | 113 +++++++++++++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/scripts/ralph_progress.py b/scripts/ralph_progress.py index 01d31e3..3ae366c 100644 --- a/scripts/ralph_progress.py +++ b/scripts/ralph_progress.py @@ -92,6 +92,75 @@ def _default_agent() -> str: 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)) @@ -229,28 +298,39 @@ def _non_negative_float(value: str) -> float: return parsed -def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph: bool = True) -> None: +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) - # 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 + # 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: - 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 "-" + 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:") @@ -266,7 +346,7 @@ def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph: symbol = _status_symbol(story, blocked=False) print(f" {symbol} {story_id:<6} {title}") if reason: - print(f" {reason}") + print(f" {shorten(reason, columns - 14, placeholder='…')}") print() blocked_ids = {str(s.get("id", "")) for s in blocked} @@ -284,12 +364,15 @@ def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph: ] if unmet: dep_text = f" waits: {', '.join(unmet)}" - # Show status label for non-done open stories - status_label = "" 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: @@ -403,7 +486,7 @@ def _run_custom_agent(agent_cmd: str, prompt_path: Path) -> int: def command_show(args: argparse.Namespace) -> int: - print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status) + print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status, compact=args.compact) return 0 @@ -411,7 +494,7 @@ 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) + 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) @@ -822,6 +905,7 @@ def build_parser() -> argparse.ArgumentParser: 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") @@ -830,6 +914,7 @@ def build_parser() -> argparse.ArgumentParser: 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") @@ -895,11 +980,11 @@ def main(argv: list[str]) -> int: # 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) + 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) + args = argparse.Namespace(prd=args.prd, git=False, no_ralph_status=False, compact=False) return command_show(args) return args.func(args)