Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
131 lines
7.2 KiB
Python
131 lines
7.2 KiB
Python
"""Console reporter: one narrative, two destinations.
|
|
|
|
Every line an ADW prints ALSO lands in the db as a `log` event, so the swim-lane
|
|
UI reads the same story the terminal does. Both go through `_emit` — print and
|
|
trace cannot drift. Plain sequential lines only: no spinners, no live displays,
|
|
so a CI log reads exactly like a terminal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from rich.console import Console as RichConsole
|
|
from rich.markup import escape
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
from .data_types import EnvelopeBase, EventRecord, Phase
|
|
|
|
KIND_COLOR = {"engineer": "cyan", "agent": "magenta", "code": "yellow"}
|
|
MAX_LINE = 160 # dynamic text (summaries, violations, errors) is clipped
|
|
|
|
|
|
def _clip(text: str, limit: int = MAX_LINE) -> str:
|
|
text = " ".join(str(text).split())
|
|
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
|
|
|
|
class Console:
|
|
"""Bound to one run's tracer. Reachable as `run.console` everywhere."""
|
|
|
|
def __init__(self, tracer, adw_id: str):
|
|
self.tracer = tracer
|
|
self.adw_id = adw_id
|
|
self.phase_id = "" # current lane — log events attach to it
|
|
self.phase_name = ""
|
|
self.results: list[str] = [] # phase statuses, for the summary
|
|
self._finished = False # the summary panel prints once
|
|
self._out = RichConsole(highlight=False, soft_wrap=True)
|
|
|
|
# ── the one helper: print AND trace, always together ────────────────────
|
|
def _emit(self, markup: str, level: str = "info", renderable=None) -> None:
|
|
text = Text.from_markup(markup)
|
|
self._out.print(renderable if renderable is not None else text)
|
|
self.tracer.event(EventRecord(
|
|
adw_id=self.adw_id, phase_id=self.phase_id, type="log",
|
|
name=self.phase_name or "console",
|
|
payload={"message": text.plain, "level": level}))
|
|
|
|
# ── session ─────────────────────────────────────────────────────────────
|
|
def session_started(self, adw_id: str, engineer: str) -> None:
|
|
self._emit(f"[bold cyan]adw_id:[/bold cyan] [bold]{escape(adw_id)}[/bold]"
|
|
f" [dim]engineer[/dim] {escape(engineer)}")
|
|
|
|
def session_finished(self, ok: bool, tokens: int, cost: float, db_path: str) -> None:
|
|
if self._finished:
|
|
return
|
|
self._finished = True
|
|
passed = sum(1 for r in self.results if r == "success")
|
|
status = "[green]✓ success[/green]" if ok else "[red]✗ fail[/red]"
|
|
rows = [f" [dim]status[/dim] {status}",
|
|
f" [dim]phases[/dim] {passed}/{len(self.results)} passed",
|
|
f" [dim]tokens[/dim] {tokens:,}",
|
|
f" [dim]cost[/dim] ${cost:.4f}",
|
|
f" [dim]adw_id[/dim] {escape(self.adw_id)}",
|
|
f" [dim]db[/dim] {escape(str(db_path))}",
|
|
f" [dim]next[/dim] [bold]just phases {escape(self.adw_id)}[/bold]"]
|
|
panel = Panel(Text.from_markup("\n".join(rows)),
|
|
title="[bold]ADW complete[/bold]",
|
|
border_style="green" if ok else "red", expand=False)
|
|
plain = (f"session {self.adw_id} {'success' if ok else 'fail'} · "
|
|
f"{passed}/{len(self.results)} phases · {tokens:,} tokens · ${cost:.4f}")
|
|
self._emit(escape(plain), level="info" if ok else "error", renderable=panel)
|
|
|
|
# ── phases ──────────────────────────────────────────────────────────────
|
|
def phase_started(self, phase: Phase) -> None:
|
|
self.phase_id, self.phase_name = phase.phase_id, phase.params.name
|
|
p = phase.params
|
|
color = KIND_COLOR.get(p.kind, "white")
|
|
line = (f"[bold {color}]▶ {phase.seq:02d} {escape(p.name)}[/bold {color}]"
|
|
f" [{color}]{p.kind}[/{color}] [dim]· {escape(p.owner)}[/dim]")
|
|
if p.description:
|
|
line += f" [dim]{escape(_clip(p.description))}[/dim]"
|
|
self._emit(line)
|
|
|
|
def phase_ended(self, phase: Phase, seconds: float) -> None:
|
|
ok = phase.status == "success"
|
|
self.results.append(phase.status)
|
|
line = (f" {'[green]✓[/green]' if ok else '[red]✗[/red]'} "
|
|
f"{escape(phase.params.name)} [dim]{seconds:.1f}s[/dim]")
|
|
if not ok and phase.error:
|
|
line += f" [red]{escape(_clip(phase.error))}[/red]"
|
|
self._emit(line, level="info" if ok else "error")
|
|
self.phase_id, self.phase_name = "", ""
|
|
|
|
def note(self, message: str) -> None:
|
|
"""Free-form detail inside the current phase — what `ph.log()` recorded."""
|
|
self._emit(f" [dim]· {escape(_clip(message))}[/dim]")
|
|
|
|
# ── agents ──────────────────────────────────────────────────────────────
|
|
def agent_started(self, name: str, model: str, session_id: str) -> None:
|
|
self._emit(f" [magenta]▸[/magenta] {escape(name)} [dim]{escape(model)}[/dim]"
|
|
f" [dim]session {escape(session_id)}[/dim]")
|
|
|
|
def agent_finished(self, name: str, tokens: int, cost: float) -> None:
|
|
self._emit(f" [dim]└ {escape(name)} used {tokens:,} tokens · ${cost:.4f}[/dim]")
|
|
|
|
def retry(self, name: str, attempt: int, limit: int, reason: str) -> None:
|
|
self._emit(f" [yellow]⟳[/yellow] {escape(name)} retry {attempt}/{limit} "
|
|
f"[dim]— same session · {escape(_clip(reason))}[/dim]", level="warn")
|
|
|
|
# ── verification ────────────────────────────────────────────────────────
|
|
def gate_result(self, name: str, report) -> None:
|
|
"""A gate reports WHAT it checked, not just whether it passed."""
|
|
ok = report.passed
|
|
mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
|
|
summary = (f"{len(report.checks)} checked" if ok
|
|
else f"[red]{len(report.violations)} of {len(report.checks)} failed[/red]")
|
|
self._emit(f" {mark} gate [dim]{escape(name)}[/dim] [dim]{summary}[/dim]",
|
|
level="info" if ok else "error")
|
|
for check in report.checks:
|
|
style = "dim" if check.ok else "dim red"
|
|
detail = f" — {_clip(check.note)}" if check.note else ""
|
|
self._emit(f" [{style}]{'·' if check.ok else '✗'} {escape(_clip(check.item))}"
|
|
f"{escape(detail)}[/{style}]", level="info" if check.ok else "error")
|
|
|
|
def envelope_summary(self, envelope: EnvelopeBase) -> None:
|
|
ok = envelope.status == "success"
|
|
line = (f" {'[green]✓[/green]' if ok else '[red]✗[/red]'} "
|
|
f"{type(envelope).__name__} [dim]{escape(_clip(envelope.summary))}[/dim]")
|
|
self._emit(line, level="info" if ok else "error")
|
|
if envelope.artifacts:
|
|
self._emit(f" [dim]artifacts: {escape(_clip(', '.join(envelope.artifacts)))}[/dim]")
|