From 71eebd30affd27488ff2aea44cd9878b5c033727 Mon Sep 17 00:00:00 2001 From: POWERFULMOVES <142271328+POWERFULMOVES@users.noreply.github.com> Date: Sun, 22 Mar 2026 04:43:11 -0400 Subject: [PATCH 1/2] feat(w1): agent terminal theme renderer + 4090-claude W1 claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tool: pmoves/tools/agent_terminal_theme.py — reads flat 1.0.0 agent_signatures.yaml + node-agent-specialization.yaml, renders ANSI 24-bit themed output (banners, status bars, session headers, agent roster). Cross-platform (Windows UTF-8 reconfigure). Functions: load_agent(), render_banner(), render_status_bar(), session_header(), colorize(), render_roster(). Integration-ready for sign_trail.py and pr_hedge_trim.py. Claims W1 (terminal renderer + Gate 3) in AGNOTE4482PHI.t1.md and AGNOTE4482_ROADMAP_W1-W5.md Agent Claim Register. Co-Authored-By: Claude Opus 4.6 (1M context) --- pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md b/pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md index 3e743bcc8..850b738c2 100644 --- a/pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md +++ b/pmoves/docs/AGENTS/AGNOTE4482PHI.t1.md @@ -96,6 +96,8 @@ Required handoff fields: - `2026-03-25T01:30:00Z` RELEASE `Z890-CLAUDE` scope: Full infrastructure session. 10 PRs merged (#1073-1083). Docker disk move C:→D: (4.4GB→195GB free). GHCR push 403 root-caused and fixed (permission-packages:write, PR #1083). Publisher-discord published to GHCR. Firefly III activated (port 8075). CI runner stabilized (PAT auth, persistent). 44 Dockerfiles migrated to DHI base images (PR #1084). P7 SKILL.md registration for services launcher + remote access (PR #1085). GitHub App TAC tree + runner 3-tier auth (PR #1080). Jetson Orin TAC + hardware profile (PR #1080). Hardened branch reconciliation (44-submodule gap analysis, PR #1080). W6 Life+Persona+Matrix roadmap written on AGNOTE4482. Pinokio 40GB crash-loop log identified (procs.js V8 RangeError) and truncated. 7 credential types audited. 3 memories updated. Handoff: #1084 (DHI) + #1085 (P7 SKILL.md) ready for review. GHCR build run dispatched. 5090 trimming #1082 in worktree. +- `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). From 59b7e25bdb6fb00a280b20979a0066cf9a0102b2 Mon Sep 17 00:00:00 2001 From: POWERFULMOVES <142271328+POWERFULMOVES@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:15:43 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(w1):=20complete=20agent=20terminal=20r?= =?UTF-8?q?enderer=20=E2=80=94=20JSON,=20whoami,=20remote=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the W1 agent_terminal_theme.py skeleton with production features: - --json: machine-readable output for BoTZ CLI and P7 pterm integration - --whoami: resolve current agent identity (PMOVES_AGENT_ID env → hostname heuristic → BoTZ Gateway /v1/agent/whoami fallback) - --remote: fetch theme from BoTZ Gateway (:8054) with graceful fallback to local YAML when gateway is unreachable Add comprehensive test suite (15 tests): - Banner rendering, NO_COLOR fallback, unknown agent error handling - JSON output for agent, session, roster, demo modes - Whoami resolution from env var and hostname heuristic - Remote gateway fallback behavior - Windows UTF-8 subprocess encoding fix Co-Authored-By: Claude Opus 4.6 (1M context) --- pmoves/tests/test_agent_terminal_theme.py | 166 ++++++++++++++++++++++ pmoves/tools/agent_terminal_theme.py | 129 ++++++++++++++++- 2 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 pmoves/tests/test_agent_terminal_theme.py diff --git a/pmoves/tests/test_agent_terminal_theme.py b/pmoves/tests/test_agent_terminal_theme.py new file mode 100644 index 000000000..9e6d6286b --- /dev/null +++ b/pmoves/tests/test_agent_terminal_theme.py @@ -0,0 +1,166 @@ +"""Tests for agent_terminal_theme.py — W1 Agent Theming renderer.""" +import json +import os +import subprocess +import sys + +import pytest + +_SCRIPT = os.path.join(os.path.dirname(__file__), "..", "tools", "agent_terminal_theme.py") + + +def _run(args: list[str], env_override: dict[str, str] | None = None) -> subprocess.CompletedProcess: + """Run the theme script and return result.""" + env = {**os.environ, "PYTHONUTF8": "1", **(env_override or {})} + return subprocess.run( + [sys.executable, _SCRIPT, *args], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env=env, timeout=10, + ) + + +# --------------------------------------------------------------------------- +# Banner output +# --------------------------------------------------------------------------- +class TestBanner: + def test_known_agent_banner(self): + result = _run(["--agent", "4090-claude", "--banner"]) + assert result.returncode == 0 + assert "4090 Claude" in result.stdout + assert "noise-reducer" in result.stdout + + def test_unknown_agent_fails(self): + result = _run(["--agent", "nonexistent-agent", "--banner"]) + assert result.returncode == 1 + assert "not found" in result.stderr + + def test_no_color_strips_ansi(self): + result = _run(["--agent", "claude-opus", "--banner"], env_override={"NO_COLOR": "1"}) + assert result.returncode == 0 + assert "\033[" not in result.stdout + assert "Claude Opus" in result.stdout + + +# --------------------------------------------------------------------------- +# Status bar +# --------------------------------------------------------------------------- +class TestStatusBar: + def test_status_output(self): + result = _run(["--agent", "4090-claude", "--status", "P7 live, 11/20 TAC"]) + assert result.returncode == 0 + assert "P7 live" in result.stdout + assert "4090 Claude" in result.stdout + + +# --------------------------------------------------------------------------- +# Session header +# --------------------------------------------------------------------------- +class TestSessionHeader: + def test_session_with_node(self): + result = _run(["--agent", "4090-claude", "--session"]) + assert result.returncode == 0 + assert "RTX 4090" in result.stdout + assert "noise-reducer" in result.stdout + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- +class TestJsonOutput: + def test_agent_json(self): + result = _run(["--agent", "4090-claude", "--json"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["agent_id"] == "4090-claude" + assert data["color"] == "#0D9488" + assert data["glyph"] == "\u25c9" + assert "noise-reducer" == data["specialization"] + + def test_session_json_includes_node(self): + result = _run(["--agent", "z890-claude", "--session", "--json"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["agent_id"] == "z890-claude" + assert "node" in data + assert data["node"]["hardware"]["gpu"] == "RTX 3090 Ti 24GB" + + def test_roster_json(self): + result = _run(["--roster", "--json"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert len(data) >= 10 + ids = [a["agent_id"] for a in data] + assert "claude-opus" in ids + assert "4090-claude" in ids + + def test_demo_json(self): + result = _run(["--demo", "--json"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert all("glyph" in a for a in data) + + +# --------------------------------------------------------------------------- +# Whoami +# --------------------------------------------------------------------------- +class TestWhoami: + def test_whoami_with_env(self): + result = _run(["--whoami", "--json"], env_override={"PMOVES_AGENT_ID": "4090-claude"}) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["agent_id"] == "4090-claude" + assert data["source"] == "env" + assert data["theme"]["color"] == "#0D9488" + + def test_whoami_heuristic_fallback(self): + """Without PMOVES_AGENT_ID, whoami uses hostname heuristic.""" + env = {k: v for k, v in os.environ.items() if k != "PMOVES_AGENT_ID"} + env["PYTHONUTF8"] = "1" + result = subprocess.run( + [sys.executable, _SCRIPT, "--whoami", "--json"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + env=env, timeout=10, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "agent_id" in data + assert data["source"] == "heuristic" + + +# --------------------------------------------------------------------------- +# Remote fallback +# --------------------------------------------------------------------------- +class TestRemoteFallback: + def test_remote_falls_back_to_local(self): + """When gateway is unreachable, --remote should fallback to local YAML.""" + result = _run( + ["--agent", "claude-opus", "--remote", "--banner"], + env_override={"BOTZ_GATEWAY_URL": "http://localhost:19999"}, + ) + assert result.returncode == 0 + assert "Claude Opus" in result.stdout + assert "falling back" in result.stderr.lower() + + +# --------------------------------------------------------------------------- +# Demo and roster +# --------------------------------------------------------------------------- +class TestDemoRoster: + def test_demo_renders_all(self): + result = _run(["--demo"]) + assert result.returncode == 0 + assert "Claude Opus" in result.stdout + assert "4090 Claude" in result.stdout + assert "Agent Roster" in result.stdout + + def test_roster_compact(self): + result = _run(["--roster"]) + assert result.returncode == 0 + assert "Agent Roster" in result.stdout + + def test_no_args_shows_help(self): + result = _run([]) + assert result.returncode == 1 + assert "usage:" in result.stdout.lower() or "usage:" in result.stderr.lower() diff --git a/pmoves/tools/agent_terminal_theme.py b/pmoves/tools/agent_terminal_theme.py index 1ff5d1988..8a7f2e674 100644 --- a/pmoves/tools/agent_terminal_theme.py +++ b/pmoves/tools/agent_terminal_theme.py @@ -11,13 +11,18 @@ 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" + python pmoves/tools/agent_terminal_theme.py --whoami + python pmoves/tools/agent_terminal_theme.py --agent 4090-claude --json + python pmoves/tools/agent_terminal_theme.py --agent z890-claude --remote --banner """ from __future__ import annotations import argparse +import json as json_mod import os +import socket import sys -from dataclasses import dataclass +from dataclasses import dataclass, asdict from pathlib import Path from typing import Any, Dict, List, Optional @@ -185,6 +190,72 @@ def load_node(node_id: str) -> Optional[NodeSpec]: ) +# --------------------------------------------------------------------------- +# Remote loader (BoTZ Gateway at :8054) +# --------------------------------------------------------------------------- +_BOTZ_GATEWAY_URL = os.getenv("BOTZ_GATEWAY_URL", "http://localhost:8054") + + +def _fetch_remote_theme(agent_id: str) -> Optional[AgentTheme]: + """Fetch agent theme from BoTZ Gateway API. Returns None on failure.""" + try: + from urllib.request import urlopen, Request + from urllib.error import URLError + except ImportError: + return None + try: + req = Request(f"{_BOTZ_GATEWAY_URL}/v1/agent/theme/{agent_id}") + req.add_header("Accept", "application/json") + with urlopen(req, timeout=3) as resp: + data = json_mod.loads(resp.read()) + return AgentTheme( + agent_id=data.get("agent_id", agent_id), + display_name=data.get("display_name", agent_id), + glyph=data.get("glyph", "?"), + color=data.get("color", "#FFFFFF"), + accent=data.get("accent", "#CCCCCC"), + voice=data.get("voice", "analytical"), + resonance=data.get("resonance", []), + description=data.get("description", ""), + ) + except (URLError, OSError, json_mod.JSONDecodeError, KeyError): + return None + + +def _resolve_whoami() -> str: + """Resolve current agent identity from environment, hostname, or gateway.""" + # 1. Explicit env var (set by BoTZ session or Claude Code hook) + agent_id = os.environ.get("PMOVES_AGENT_ID") + if agent_id: + return agent_id + + # 2. Hostname heuristic — match against known node patterns + hostname = socket.gethostname().lower() + _HOST_MAP = { + "z890": "z890-claude", + "pmoves-z890": "z890-claude", + "powerfulmoves": "5090-claude", + "pmoves-powerfulmoves": "5090-claude", + "laptop": "4090-claude", + "pmoves-laptop": "4090-claude", + } + for pattern, aid in _HOST_MAP.items(): + if pattern in hostname: + return aid + + # 3. Gateway fallback + try: + from urllib.request import urlopen, Request + from urllib.error import URLError + req = Request(f"{_BOTZ_GATEWAY_URL}/v1/agent/whoami") + req.add_header("Accept", "application/json") + with urlopen(req, timeout=3) as resp: + data = json_mod.loads(resp.read()) + return data.get("agent_id", "unknown") + except Exception: + return "unknown" + + # --------------------------------------------------------------------------- # Renderers # --------------------------------------------------------------------------- @@ -272,14 +343,45 @@ def main() -> int: 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") + parser.add_argument("--json", action="store_true", dest="json_out", help="Output as JSON (for BoTZ CLI / P7 integration)") + parser.add_argument("--remote", action="store_true", help="Fetch theme from BoTZ Gateway instead of local YAML") + parser.add_argument("--whoami", action="store_true", help="Resolve and display current agent identity") args = parser.parse_args() + # --- whoami: resolve identity and display --- + if args.whoami: + agent_id = _resolve_whoami() + agent = load_agent(agent_id) + node = load_node(agent_id) + if args.json_out: + result = {"agent_id": agent_id, "source": "env" if os.environ.get("PMOVES_AGENT_ID") else "heuristic"} + if agent: + result["theme"] = {"glyph": agent.glyph, "color": agent.color, "accent": agent.accent, "voice": agent.voice} + result["display_name"] = agent.display_name + result["specialization"] = agent.specialization + result["resonance"] = agent.resonance + if node: + result["node"] = {"hardware": node.hardware, "specialization": node.specialization} + print(json_mod.dumps(result, indent=2)) + else: + if agent: + print(render_banner(agent)) + if node: + print() + print(session_header(agent, node)) + else: + print(f"Resolved agent: {agent_id} (not in signatures)") + return 0 + if args.demo: agents = load_all_agents() if not agents: print("ERROR: Could not load agent_signatures.yaml", file=sys.stderr) return 1 + if args.json_out: + print(json_mod.dumps([asdict(a) for a in agents], indent=2)) + return 0 for agent in agents: print(render_banner(agent)) print() @@ -288,6 +390,9 @@ def main() -> int: if args.roster: agents = load_all_agents() + if args.json_out: + print(json_mod.dumps([{"agent_id": a.agent_id, "glyph": a.glyph, "color": a.color, "specialization": a.specialization} for a in agents], indent=2)) + return 0 print(render_roster(agents)) return 0 @@ -295,11 +400,31 @@ def main() -> int: parser.print_help() return 1 - agent = load_agent(args.agent) + # --- Load agent (local or remote) --- + agent = None + if args.remote: + agent = _fetch_remote_theme(args.agent) + if agent: + print(f"{_DIM}(remote: {_BOTZ_GATEWAY_URL}){_RESET}", file=sys.stderr) + else: + print(f"WARNING: Gateway unreachable, falling back to local YAML", file=sys.stderr) + if not agent: + agent = load_agent(args.agent) if not agent: print(f"ERROR: Agent '{args.agent}' not found in {_SIGNATURES_PATH}", file=sys.stderr) return 1 + # --- JSON output mode --- + if args.json_out: + result = asdict(agent) + if args.session: + node = load_node(args.node or args.agent) + if node: + result["node"] = asdict(node) + print(json_mod.dumps(result, indent=2)) + return 0 + + # --- ANSI render modes --- if args.session: node = load_node(args.node or args.agent) print(session_header(agent, node))