feat: add per-story metadata line to Ralph dashboard

Each story shows an optional second line with agent · worktree · summary:
- _active_worktrees(): parses git worktree list --porcelain, maps feat/<id> 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 <noreply@anthropic.com>
This commit is contained in:
Dobromir Popov
2026-06-29 16:32:35 +03:00
parent 0152d5ed99
commit 85c13e4e82

View File

@@ -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/<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", "").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)