Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ Required handoff fields:
- `2026-03-21T22:00:00Z` CLAIM `5090-CLAUDE` scope: Final two — BoTZ PR #79 (8 merge conflicts in gateway.py, 16 CodeRabbit findings: 1 Critical broken doc link, 3 Major endpoint/CORS issues) + Supabase PR #1 merge + parent gitlink sync PR #1062.
- `2026-03-21T22:15:00Z` RELEASE `5090-CLAUDE` scope: 28→0 COMPLETE. BoTZ PR #79 merged (fail-closed JWT auth with CHIT attestation, PUBLIC_ENDPOINTS frozenset, corrected endpoint docs). Supabase PR #1 merged (pmoves_auth module). Parent PR #1062 merged. Zero submodule drift confirmed. AGNOTEs updated. No open PRs on main repo. BoTZ has 2 Dependabot PRs (#89, #91) remaining.

- `2026-03-22T10:00:00Z` CLAIM `4090-CLAUDE` scope: W1 terminal renderer — agent_terminal_theme.py (reads flat 1.0.0 signatures + node specialization, renders ANSI-themed banners/status bars). Gate 3 cross-machine TTS routing verification (p7.mesh.remote-tts + va.p7.remote-speak). Branch: feat/w1-agent-terminal-theme. Stale branch cleanup (7 merged locals).

## Graphiti Review Log
- `2026-02-21T10:35:03.6791631-05:00` REVIEW `CODEX-GPT5`
- Verified six top-level submodule `CLAUDE.md` files are clean (no `TODO`, `FIXME`, placeholder, or artifact markers).
Expand Down
1 change: 1 addition & 0 deletions pmoves/docs/AGENTS/AGNOTE4482_ROADMAP_W1-W5.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,4 @@ All resolved. No blockers.
| W4 (partial: content stubs) | 5090-claude | 2026-03-19 | SHIPPED `2a681471` | main |
| W4 (beats pipeline runner) | claude-opus | 2026-03-20 | SHIPPED #1039 | main |
| W5 (partial: TZ models, TACs) | 5090-claude | 2026-03-19 | SHIPPED `2a681471` | main |
| W1 (terminal renderer + Gate 3) | 4090-claude | 2026-03-22 | CLAIMED | feat/w1-agent-terminal-theme |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update W1 owner/status text for internal consistency.

This new claim row marks W1 as actively claimed, but the W1 section still says “Owner: Unclaimed” (Line 52). Please reconcile both sections in this doc.

As per coding guidelines, "pmoves/docs/**: Check docs for operational accuracy: Keep status claims aligned with evidence in runbooks and smokes."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pmoves/docs/AGENTS/AGNOTE4482_ROADMAP_W1-W5.md` at line 280, Update the W1
section so its owner/status matches the claimed row: change the "Owner:
Unclaimed" text in the W1 section to reflect the new claim "4090-claude" and set
status to "CLAIMED" (or mirror the exact phrasing used in the table row "W1
(terminal renderer + Gate 3) | 4090-claude | 2026-03-22 | CLAIMED |
feat/w1-agent-terminal-theme") so the section and the claim row are consistent.

316 changes: 316 additions & 0 deletions pmoves/tools/agent_terminal_theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Agent-Themed Terminal Renderer

Reads agent_signatures.yaml and node-agent-specialization.yaml to render
ANSI-themed terminal output: banners, status bars, session headers.

W1 deliverable — foundation for BoTZ CLI theme bridge and P7 terminal integration.

Usage:
python pmoves/tools/agent_terminal_theme.py --demo
python pmoves/tools/agent_terminal_theme.py --agent 4090-claude
python pmoves/tools/agent_terminal_theme.py --agent claude-opus --banner
python pmoves/tools/agent_terminal_theme.py --agent z890-claude --status "19 containers healthy"
"""
from __future__ import annotations

import argparse
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

# ---------------------------------------------------------------------------
# Resolve project root (pmoves/) so YAML paths work from any invocation dir.
# ---------------------------------------------------------------------------
_TOOLS_DIR = Path(__file__).resolve().parent
_PMOVES_ROOT = _TOOLS_DIR.parent

_SIGNATURES_PATH = _PMOVES_ROOT / "config" / "agent_signatures.yaml"
_NODE_SPEC_PATH = _PMOVES_ROOT / "configs" / "node-agent-specialization.yaml"


# ---------------------------------------------------------------------------
# ANSI helpers
# ---------------------------------------------------------------------------
def _supports_color() -> bool:
"""Check if terminal supports ANSI color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("FORCE_COLOR"):
return True
if sys.platform == "win32":
return os.environ.get("WT_SESSION") is not None or "TERM_PROGRAM" in os.environ
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""Convert #RRGGBB to (R, G, B)."""
h = hex_color.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


def _fg(hex_color: str) -> str:
"""ANSI 24-bit foreground escape for a hex color."""
if not _supports_color():
return ""
r, g, b = _hex_to_rgb(hex_color)
return f"\033[38;2;{r};{g};{b}m"


def _bg(hex_color: str) -> str:
"""ANSI 24-bit background escape for a hex color."""
if not _supports_color():
return ""
r, g, b = _hex_to_rgb(hex_color)
return f"\033[48;2;{r};{g};{b}m"


_RESET = "\033[0m" if _supports_color() else ""
_BOLD = "\033[1m" if _supports_color() else ""
_DIM = "\033[2m" if _supports_color() else ""


# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class AgentTheme:
agent_id: str
display_name: str
glyph: str
color: str
accent: str
voice: str
resonance: List[str]
description: str
co_author: str = ""
specialization: str = ""
routes_to: List[str] = None

def __post_init__(self):
if self.routes_to is None:
self.routes_to = []


@dataclass
class NodeSpec:
node_id: str
glyph: str
color: str
hardware: Dict[str, str]
specialization: str
cognitive_strengths: List[str]
work_types: List[str]
announce_subject: str


# ---------------------------------------------------------------------------
# Loaders
# ---------------------------------------------------------------------------
def _load_yaml(path: Path) -> Dict[str, Any]:
"""Load YAML file, returning empty dict on failure."""
try:
import yaml # type: ignore[import-untyped]
except ImportError:
print("WARNING: pyyaml not installed, using fallback", file=sys.stderr)
return {}
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}

Comment on lines +112 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle YAML/file errors explicitly in _load_yaml.

Malformed YAML or read failures currently raise and terminate the CLI. This should fail gracefully and return {} with an error message.

Suggested fix
 def _load_yaml(path: Path) -> Dict[str, Any]:
     """Load YAML file, returning empty dict on failure."""
     try:
         import yaml  # type: ignore[import-untyped]
     except ImportError:
         print("WARNING: pyyaml not installed, using fallback", file=sys.stderr)
         return {}
     if not path.exists():
         return {}
-    with open(path, encoding="utf-8") as f:
-        return yaml.safe_load(f) or {}
+    try:
+        with open(path, encoding="utf-8") as f:
+            loaded = yaml.safe_load(f) or {}
+    except (OSError, yaml.YAMLError) as exc:
+        print(f"ERROR: Failed to load {path}: {exc}", file=sys.stderr)
+        return {}
+    if not isinstance(loaded, dict):
+        print(f"ERROR: Unexpected YAML root type in {path}", file=sys.stderr)
+        return {}
+    return loaded
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pmoves/tools/agent_terminal_theme.py` around lines 112 - 123, The _load_yaml
function currently lets file I/O and YAML parse errors propagate; wrap the file
open and yaml.safe_load call in a try/except that catches exceptions from
reading/parsing (e.g., OSError, yaml.YAMLError, or a broad Exception), print a
concise warning to stderr that includes the path and the exception message, and
return {} on any failure; keep the existing ImportError handling for missing
pyyaml and the existing behavior when path.exists() is false. Use the function
name _load_yaml and reference yaml.safe_load and path to locate where to add the
try/except and error message.


def load_agent(agent_id: str) -> Optional[AgentTheme]:
"""Load a single agent's theme from agent_signatures.yaml."""
data = _load_yaml(_SIGNATURES_PATH)
sigs = data.get("signatures", {})
entry = sigs.get(agent_id)
if not entry:
return None
return AgentTheme(
agent_id=entry.get("agent_id", agent_id),
display_name=entry.get("display_name", agent_id),
glyph=entry.get("glyph", "?"),
color=entry.get("color", "#FFFFFF"),
accent=entry.get("accent", "#CCCCCC"),
voice=entry.get("voice", "analytical"),
resonance=entry.get("resonance", []),
description=entry.get("description", ""),
co_author=entry.get("co_author", ""),
specialization=entry.get("specialization", ""),
routes_to=entry.get("routes_to", []),
)


def load_all_agents() -> List[AgentTheme]:
"""Load all agent themes."""
data = _load_yaml(_SIGNATURES_PATH)
sigs = data.get("signatures", {})
agents = []
for aid, entry in sigs.items():
agents.append(AgentTheme(
agent_id=entry.get("agent_id", aid),
display_name=entry.get("display_name", aid),
glyph=entry.get("glyph", "?"),
color=entry.get("color", "#FFFFFF"),
accent=entry.get("accent", "#CCCCCC"),
voice=entry.get("voice", "analytical"),
resonance=entry.get("resonance", []),
description=entry.get("description", ""),
co_author=entry.get("co_author", ""),
specialization=entry.get("specialization", ""),
routes_to=entry.get("routes_to", []),
))
return agents


def load_node(node_id: str) -> Optional[NodeSpec]:
"""Load a node's spec from node-agent-specialization.yaml."""
data = _load_yaml(_NODE_SPEC_PATH)
nodes = data.get("nodes", {})
entry = nodes.get(node_id)
if not entry:
return None
return NodeSpec(
node_id=node_id,
glyph=entry.get("glyph", "?"),
color=entry.get("color", "#FFFFFF"),
hardware=entry.get("hardware", {}),
specialization=entry.get("specialization", ""),
cognitive_strengths=entry.get("cognitive_strengths", []),
work_types=entry.get("work_types", []),
announce_subject=entry.get("announce_subject", ""),
)


# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def colorize(text: str, hex_color: str) -> str:
"""Apply a hex color to text via ANSI 24-bit codes."""
return f"{_fg(hex_color)}{text}{_RESET}"


def render_banner(agent: AgentTheme, width: int = 52) -> str:
"""Render a themed banner with glyph and display name."""
fg = _fg(agent.color)
ac = _fg(agent.accent)
border = f"{fg}{'━' * width}{_RESET}"
title = f"{fg}{_BOLD}{agent.glyph} {agent.display_name}{_RESET}"
voice_line = f"{ac} voice: {agent.voice} | {', '.join(agent.resonance[:3])}{_RESET}"

lines = [border, title]
if agent.specialization:
lines.append(f"{ac} [{agent.specialization}]{_RESET}")
lines.append(voice_line)
if agent.description:
lines.append(f"{_DIM} {agent.description}{_RESET}")
lines.append(border)
return "\n".join(lines)


def render_status_bar(agent: AgentTheme, status: str, width: int = 52) -> str:
"""Render a themed single-line status bar."""
fg = _fg(agent.color)
label = f"{fg}{_BOLD}{agent.glyph} {agent.display_name}{_RESET}"
sep = f"{_fg(agent.accent)}│{_RESET}"
return f"{label} {sep} {status}"


def session_header(agent: AgentTheme, node: Optional[NodeSpec] = None) -> str:
"""Render a combined agent+node session header."""
fg = _fg(agent.color)
border = f"{fg}{'═' * 52}{_RESET}"
title = f"{fg}{_BOLD}{agent.glyph} {agent.display_name}{_RESET}"

lines = [border, title]

if node:
nfg = _fg(node.color)
hw = node.hardware
hw_line = f" {hw.get('gpu', '?')} | {hw.get('cpu', '?')} | {hw.get('ram', '?')}"
lines.append(f"{nfg} node: {node.node_id} ({node.specialization}){_RESET}")
lines.append(f"{_DIM}{hw_line}{_RESET}")

if agent.resonance:
ac = _fg(agent.accent)
lines.append(f"{ac} resonance: {', '.join(agent.resonance)}{_RESET}")

if node and node.cognitive_strengths:
lines.append(f"{_DIM} strengths: {', '.join(node.cognitive_strengths[:4])}{_RESET}")

lines.append(border)
return "\n".join(lines)


def render_roster(agents: List[AgentTheme]) -> str:
"""Render a compact roster of all agents."""
lines = [f"{_BOLD}Agent Roster ({len(agents)} agents){_RESET}", ""]
for a in agents:
fg = _fg(a.color)
spec = f" [{a.specialization}]" if a.specialization else ""
lines.append(f" {fg}{a.glyph} {a.display_name:<20}{_RESET}{spec}")
return "\n".join(lines)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
# Ensure UTF-8 stdout on Windows (Unicode glyphs in agent signatures)
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

parser = argparse.ArgumentParser(description="Agent-Themed Terminal Renderer")
parser.add_argument("--agent", "-a", help="Agent ID (e.g. claude-opus, 4090-claude)")
parser.add_argument("--node", "-n", help="Node ID for session header (e.g. 4090-claude)")
parser.add_argument("--banner", action="store_true", help="Render full banner")
parser.add_argument("--status", "-s", help="Status text for status bar")
parser.add_argument("--session", action="store_true", help="Render session header")
parser.add_argument("--roster", action="store_true", help="Render all agents roster")
parser.add_argument("--demo", action="store_true", help="Render demo of all agents")

args = parser.parse_args()

if args.demo:
agents = load_all_agents()
if not agents:
print("ERROR: Could not load agent_signatures.yaml", file=sys.stderr)
return 1
for agent in agents:
print(render_banner(agent))
print()
print(render_roster(agents))
return 0

if args.roster:
agents = load_all_agents()
print(render_roster(agents))
return 0
Comment on lines +289 to +292

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

--roster should fail closed when signatures cannot be loaded.

This path currently exits 0 even if the file is missing/empty and prints a 0-agent roster, which masks configuration errors.

Suggested fix
     if args.roster:
         agents = load_all_agents()
+        if not agents:
+            print("ERROR: Could not load agent_signatures.yaml", file=sys.stderr)
+            return 1
         print(render_roster(agents))
         return 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if args.roster:
agents = load_all_agents()
print(render_roster(agents))
return 0
if args.roster:
agents = load_all_agents()
if not agents:
print("ERROR: Could not load agent_signatures.yaml", file=sys.stderr)
return 1
print(render_roster(agents))
return 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pmoves/tools/agent_terminal_theme.py` around lines 288 - 291, The --roster
branch currently returns 0 even when no agents were loaded; change it to fail
closed by checking the result of load_all_agents() and returning a non-zero exit
code (or raising SystemExit) when agents is empty or loading failed: after
calling load_all_agents() in the args.roster block, detect if agents is falsy or
len(agents) == 0, print a clear error message about missing/failed signature
loading, and return 1 (or raise SystemExit(1)) instead of returning 0; keep the
successful path that prints render_roster(agents) and returns 0.


if not args.agent:
parser.print_help()
return 1

agent = load_agent(args.agent)
if not agent:
print(f"ERROR: Agent '{args.agent}' not found in {_SIGNATURES_PATH}", file=sys.stderr)
return 1

if args.session:
node = load_node(args.node or args.agent)
print(session_header(agent, node))
Comment on lines +303 to +305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Return an error when an explicit --node is unknown.

If --node is provided but not found, the command silently degrades to agent-only output. That makes bad input hard to detect.

Suggested fix
     if args.session:
         node = load_node(args.node or args.agent)
+        if args.node and node is None:
+            print(f"ERROR: Node '{args.node}' not found in {_NODE_SPEC_PATH}", file=sys.stderr)
+            return 1
         print(session_header(agent, node))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pmoves/tools/agent_terminal_theme.py` around lines 302 - 304, When
args.session is set, the code currently calls load_node(args.node or args.agent)
which silently falls back to agent-only output if an explicit --node is unknown;
change the logic to call load_node with args.node when args.node is provided,
then if args.node was supplied and load_node returns falsy, emit an explicit
error (e.g., print to stderr or log via the same logger) and exit with a
non-zero status instead of continuing; otherwise proceed to call
session_header(agent, node) as before. Ensure you modify the block that uses
args.session, referencing load_node, args.node, args.agent, agent, node, and
session_header.

elif args.status:
print(render_status_bar(agent, args.status))
elif args.banner:
print(render_banner(agent))
else:
print(render_banner(agent))

return 0


if __name__ == "__main__":
sys.exit(main())
Loading