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:
@@ -92,6 +92,75 @@ def _default_agent() -> str:
|
|||||||
return cfg.get("agent") or DEFAULT_AGENT
|
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:
|
def _rel(path: Path) -> str:
|
||||||
try:
|
try:
|
||||||
return str(path.relative_to(REPO_ROOT))
|
return str(path.relative_to(REPO_ROOT))
|
||||||
@@ -229,28 +298,39 @@ def _non_negative_float(value: str) -> float:
|
|||||||
return parsed
|
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)
|
data = _load_prd(prd_path)
|
||||||
stories = _stories(data)
|
stories = _stories(data)
|
||||||
story_by_id = {str(story.get("id", "")): story for story in stories}
|
story_by_id = {str(story.get("id", "")): story for story in stories}
|
||||||
done, attention, active, in_design, ready, blocked = _story_sets(data)
|
done, attention, active, in_design, ready, blocked = _story_sets(data)
|
||||||
total = len(stories)
|
total = len(stories)
|
||||||
# Attention stories were previously done; count them as complete in the progress bar
|
|
||||||
complete_count = len(done) + len(attention)
|
complete_count = len(done) + len(attention)
|
||||||
percent = int(complete_count * 100 / total) if total else 0
|
percent = int(complete_count * 100 / total) if total else 0
|
||||||
columns = shutil.get_terminal_size((100, 24)).columns
|
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"Ralph progress: {data.get('name', prd_path.stem)}")
|
||||||
print(f"PRD: {_rel(prd_path)}")
|
print(f"PRD: {_rel(prd_path)}")
|
||||||
print(f"{_bar(complete_count, total)} {complete_count}/{total} complete ({percent}%)")
|
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)}")
|
print(f"Done: {len(done)} Ready: {len(ready)} Attention: {len(attention)} Blocked: {len(blocked)}")
|
||||||
|
|
||||||
if include_ralph:
|
if include_ralph:
|
||||||
status = _ralph_status_json()
|
ralph_status = _ralph_status_json()
|
||||||
if status:
|
if ralph_status:
|
||||||
status_name = status.get("status", "unknown")
|
session = ralph_status # used by _story_meta
|
||||||
session = status.get("session") if isinstance(status.get("session"), dict) else {}
|
status_name = ralph_status.get("status", "unknown")
|
||||||
session_id = session.get("id", "-") if isinstance(session, dict) else "-"
|
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}")
|
print(f"Ralph session: {status_name} {session_id}")
|
||||||
if include_git:
|
if include_git:
|
||||||
print("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)
|
symbol = _status_symbol(story, blocked=False)
|
||||||
print(f" {symbol} {story_id:<6} {title}")
|
print(f" {symbol} {story_id:<6} {title}")
|
||||||
if reason:
|
if reason:
|
||||||
print(f" {reason}")
|
print(f" {shorten(reason, columns - 14, placeholder='…')}")
|
||||||
|
|
||||||
print()
|
print()
|
||||||
blocked_ids = {str(s.get("id", "")) for s in blocked}
|
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:
|
if unmet:
|
||||||
dep_text = f" waits: {', '.join(unmet)}"
|
dep_text = f" waits: {', '.join(unmet)}"
|
||||||
# Show status label for non-done open stories
|
|
||||||
status_label = ""
|
|
||||||
st = story.get("status", "")
|
st = story.get("status", "")
|
||||||
|
status_label = ""
|
||||||
if not _is_done(story) and not _needs_attention(story) and st and st != "open":
|
if not _is_done(story) and not _needs_attention(story) and st and st != "open":
|
||||||
status_label = f" ({st})"
|
status_label = f" ({st})"
|
||||||
print(shorten(f"{symbol} {story_id:<6} {title}{dep_text}{status_label}", width=columns, placeholder="…"))
|
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)
|
next_story = _next_ready(data)
|
||||||
if next_story:
|
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:
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -411,7 +494,7 @@ def command_watch(args: argparse.Namespace) -> int:
|
|||||||
while True:
|
while True:
|
||||||
os.system("clear" if sys.stdout.isatty() else "true")
|
os.system("clear" if sys.stdout.isatty() else "true")
|
||||||
print(dt.datetime.now().isoformat(timespec="seconds"))
|
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:
|
if args.once:
|
||||||
return 0
|
return 0
|
||||||
time.sleep(args.interval)
|
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("--prd", type=Path, default=DEFAULT_PRD)
|
||||||
show.add_argument("--git", action="store_true", help="Include git status")
|
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("--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)
|
show.set_defaults(func=command_show)
|
||||||
|
|
||||||
watch = subparsers.add_parser("watch", help="Refresh progress every N seconds")
|
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("--git", action="store_true")
|
||||||
watch.add_argument("--no-ralph-status", action="store_true", help="Skip ralph-tui status lookup")
|
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("--once", action="store_true")
|
||||||
|
watch.add_argument("--compact", action="store_true", help="One line per story, no metadata")
|
||||||
watch.set_defaults(func=command_watch)
|
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 = 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 ...`.
|
# Backward compatibility: `ralph_progress.py path/to/prd.json` means `show --prd ...`.
|
||||||
known_commands = {"show", "watch", "run-next", "auto", "review", "set-agent", "list-parallel"}
|
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("-"):
|
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)
|
return command_show(args)
|
||||||
args = parser.parse_args(argv[1:])
|
args = parser.parse_args(argv[1:])
|
||||||
if args.command is None:
|
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 command_show(args)
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user