# WAVE 140 DISPATCH — Agent-observable avatar telemetry v1 — AD-722

**Wave:** 140
**Mode:** main (continuous build, Captain offline)
**Depends on:** 137 (AD-718d emotional voice modulation, browser-side `applyEmotionalModulation`), 138 (AD-721b phoneme lipsync, browser-side), AD-721 (3D avatar surface), AD-721d (`AppearanceProfile.dsl` agent-authored DSL persisted on `crew_profiles.data`).
**Builder required:** yes
**Issues to close:** [#545](https://github.com/seangalliher/ProbOS/issues/545) (AD-722 v1).
**Date:** 2026-05-10

---

## 1. Goal

The avatar surface (Wave 138 + 137 + AD-721 + AD-721d) is a one-way pipe: **runtime → avatar**. Trust deltas, working state, voice modulation, lipsync, and the agent-authored `AvatarDSL` all flow outward; the agent itself has no read-back. Captain ruling 2026-05-09 ([#545](https://github.com/seangalliher/ProbOS/issues/545) — Open-LLM-VTuber, kimjammer/Neuro, super-agent-party prior-art review): **novel, no public OSS prior art** for inverting the pattern. AD-722 is the most architecturally interesting wave on the board because it closes the loop — *"does the agent know what it looks like right now?"*

Wave 140 ships **AD-722 v1 only**: the read-side telemetry channel. Agents observe their own avatar state via a new `observe_self_avatar()` method on `CognitiveAgent` (poll, not push) and via a new HTTP endpoint `GET /api/agent/{agent_id}/avatar-telemetry` for HXI consumption. **The agent cannot drive the avatar from this AD.** The first consumer (intent-vs-presentation divergence detector → trust/Hebbian update) is forward marker AD-722a.

**Hard scope constraints (no Captain ruling needed — pre-applied):**

1. **Read-only.** v1 is observation, not action. The agent cannot self-modify trust, Hebbian, or the AvatarDSL based on what they observe. Driving stays in AD-721d (Captain-approved DSL) and AD-721 (`AgentSignals` selector). Forward marker: AD-722a.
2. **Poll, not push.** v1 = `await agent.observe_self_avatar()` from agent code; HTTP polling at 2-second cadence from HXI. Push (WebSocket-driven server-sent telemetry) deferred to AD-722b.
3. **Structured state, no image bytes.** v1 returns `AvatarTelemetrySnapshot` (dataclass). Visual self-perception (agent renders own avatar to bytes and compares) deferred to AD-722e.
4. **No new deps.** Python or JS. Pure module work on top of existing `AppearanceProfile`, `VoiceProfile`, `TrustNetwork`, runtime state, and (UI side) the existing store + `deriveAgentSignals` selector.
5. **Feature-gated prompt injection.** `AvatarTelemetryConfig.inject_into_agent_context: bool = False` default. When `True`, an optional "your current avatar state" block appends to the agent's system prompt at reasoning time. v1 ships with it OFF so the existing agent prompt budget isn't perturbed. Operator flips it to test the loop.
6. **No Ship's Records auto-write.** v1 doesn't persist telemetry snapshots to `RecordsStore`. Forward marker: AD-722d.
7. **HXI surface = `<SelfImageTab>`.** New tab in `AgentProfilePanel` (next to Profile / Health / Memory / Work / Chat). Stroke-SVG only — HXI Design Principle #3.
8. **`mouth_active` semantics are best-effort.** Speech happens browser-side via Web Speech API; the backend has no authoritative "currently speaking" signal. v1 derives `mouth_active` from a **3-second window after the agent's last reply emit timestamp** (`_last_reply_emit_ts` — added to `CognitiveAgent` in this AD). Drafter must explicitly state this in the prompt as a known approximation; AD-722b's WebSocket channel is what makes `mouth_active` authoritative.
9. **Trust-delta is a single-cycle quantity.** Backend telemetry's `trust_delta` is `current_score - previous_score` from `TrustNetwork.history_for(agent_id)` (last two entries). If history < 2, return `0.0`. No magnitude smoothing, no decay — same convention as `deriveAgentSignals` UI semantics.

---

## 2. Prior-work + license disposition

| Prior work / candidate | What we found at HEAD | Disposition |
|---|---|---|
| `src/probos/avatars/dsl.py` `AvatarDSL` (Pydantic) | Verified at HEAD lines 117–142. Top-level model with `body / hair / face / outfit / expression_resting / notes` — `AvatarDSL()` succeeds with defaults; `model_config = ConfigDict(extra="forbid")`. | **Read-only consumer.** Telemetry module re-validates the persisted `AppearanceProfile.dsl: dict` via `AvatarDSL.model_validate(...)` and pulls a stable summary (`expression_resting`, `body.type`, `hair.style`, `outfit.primary_color`, `outfit.style`). v1 does NOT add fields to `AvatarDSL`. |
| `src/probos/crew_profile.py` `AppearanceProfile` | Verified at HEAD lines 156–180. `vrm_url: str / expression_overrides: dict / color_palette_hint: str / dsl: dict | None`. Round-trips through `to_dict / from_dict`. Stored on the existing `crew_profiles.data` JSON-blob column (no new SQLite table). | **Read-only consumer.** Telemetry reads `runtime.profile_store.get(agent_id)` (verified at HEAD `routers/agents.py:124`, same pattern as `agent_profile`). When `crew is None` or `crew.appearance.dsl is None`, telemetry returns `dsl_summary=None` with a structured "no DSL persisted" reason — never raises. |
| `ui/src/components/profile/avatarSignals.ts` `deriveAgentSignals` | Verified at HEAD lines 27–53. Pure function over the Zustand store snapshot. Returns `{trust_delta: 0 (placeholder), load: 0 or 1.0, working_state, tier3_alert}`. | **Pattern absorption — re-implement on the backend.** Backend `current_signals` re-derives the same four fields from runtime state. **Verified backend sources** (drafter pastes the grep hits in the impl-prompt verified footer): (a) `trust_delta` = last two entries of `runtime.trust_network.get_history(agent_id, limit=2)` (verified at HEAD `routers/agents.py:92` — the method is `get_history`, NOT `history_for`); (b) `tier3_alert` = any entry of `runtime.bridge_alerts.get_recent_alerts(10)` with `severity == AlertSeverity.ALERT` and matching `agent_id` (verified at HEAD `runtime.py:455` `self.bridge_alerts: BridgeAlertService | None = None`, `runtime.py:3229` `bridge_alerts.get_recent_alerts(10)`, `bridge_alerts.py:28` `ALERT = "alert"` enum). When `runtime.bridge_alerts is None` → `tier3_alert=False` + degraded reason `"bridge_alerts_unavailable"`; (c) `working_state` = `"blocked"` when `agent.state == AgentState.DEGRADED`, `"responding"` when `load > 0`, else `"idle"`; (d) `load` has **no canonical per-agent backend source** at HEAD (UI binds to `store.processing` which is a global flag) — see Reminder 13 below for the v1 approximation rule. **Reviewer fails any prompt that imports from `ui/` into Python (impossible).** Single-source-of-truth concern is real but pragmatic: the UI selector is browser-only; backend telemetry needs the same shape. Document the rule table in **one** place (`src/probos/avatars/telemetry.py` module docstring) and cite it from the TS selector's docstring (one-line addition). |
| `ui/src/audio/voiceModulation.ts` `applyEmotionalModulation` + threshold/factor constants (lines 17–80+) | Verified at HEAD. Pure function. Constants: `MODULATION_DIVERGENCE_THRESHOLD=0.05`, `PITCH_BOUNDS=[0,2]`, `RATE_BOUNDS=[0.1,10]`, `VOLUME_BOUNDS=[0,1]`, trust-delta thresholds `±0.2`, factors per rule (`RESPONDING_RATE=1.05` / `BLOCKED_RATE=0.92` / `BLOCKED_PITCH=0.95` / `HIGH_TRUST_PITCH=1.03` / `LOW_TRUST_PITCH=0.97` / `TIER3_RATE=1.15` / `TIER3_VOLUME=1.05`). | **Pattern absorption — re-implement on the backend as `apply_voice_modulation(profile, signals) -> ModulationResult`.** New module `src/probos/avatars/telemetry.py` carries the same rule table as plain Python constants. **Reviewer flags any divergence between the TS thresholds/factors and the Python ones**: drafter must paste the TS constants verbatim into the prompt and assert byte-equivalence in a Python test. AD-722-1 (filed in this wave's tracking) is the consolidation AD — extract the rule table into a JSON or YAML manifest both languages read. v1 ships the duplication intentionally because the TS file is widely-imported and a manifest extraction is its own AD. |
| `src/probos/cognitive/cognitive_agent.py` `class CognitiveAgent(BaseAgent)` | Verified at HEAD line 93. `instructions: str | None = None`, `_runtime` set in `__init__`, `tier = "domain"`. Existing `propose_appearance` method already calls into the LLM — same pattern as the new `observe_self_avatar` will follow (minus the LLM call). | **Add `observe_self_avatar` method.** Signature: `async def observe_self_avatar(self) -> AvatarTelemetrySnapshot:`. Calls `from probos.avatars.telemetry import build_telemetry_snapshot; return await build_telemetry_snapshot(self.id, self._runtime)`. Pure delegation — zero business logic in `cognitive_agent.py`. **Reviewer fails any prompt that puts the snapshot-assembly code in `cognitive_agent.py` instead of in the new module.** |
| `src/probos/routers/agents.py` `APIRouter(prefix="/api/agent")` | Verified at HEAD line 29. Existing routes: `/{agent_id}/identity`, `/{agent_id}/profile`, `/{agent_id}/appearance/propose`, `/{agent_id}/appearance` (PUT). | **Add `GET /{agent_id}/avatar-telemetry`.** Sibling to existing routes. Same `runtime: Any = Depends(get_runtime)` injection pattern. 404 when agent absent, 503 when `cfg.avatars.enabled is False`, 200 with `AvatarTelemetrySnapshot.to_dict()` body otherwise. **Reuses the existing `_avatars_feature_check(runtime)` helper** (verified — the appearance routes call it). |
| `ui/src/components/profile/AgentProfilePanel.tsx` `type ProfileTab = 'chat' | 'work' | 'profile' | 'health' | 'memory'` (line 12) | Verified at HEAD. Tab list at line 14 (`TAB_LABELS`), `activeTab` state line 36, render switch later in file. `visibleTabs` filters by `isCrew` (lines 153–155). | **Extend `ProfileTab` union with `'self_image'`.** Append a `{key: 'self_image', label: 'Self-image'}` entry to `TAB_LABELS`. Render `<SelfImageTab agentId={agentId} />` in the existing tab-render switch. Tab is **only visible for crew agents** (same `isCrew` filter as `chat`/`memory`). Reviewer fails any diff that surfaces it on non-crew agents (avatar concept doesn't apply). |
| `data/avatars/.gitkeep` + AD-721 D6 `/system/avatars/{filename}` static-file route | Verified at HEAD `routers/system.py:611-637`. Path-traversal-safe resolver, size cap, `cfg.avatars.enabled` gate. | **Reuse verbatim.** `<SelfImageTab>` does NOT render the VRM (CrewVRM does that). The tab is text + structured panels. |

**Top-level license posture:** Apache 2.0 stays Apache 2.0. **Zero new Python deps. Zero new JS deps.** No model weights, no third-party services, no SDKs. The duplication of the modulation rule table (TS + Python) is documented as v1 cost; AD-722-1 (extract to YAML manifest) is filed as the consolidation forward marker.

---

## 3. Engineering-principles checklist

Builder must verify each in the per-AD prompt acceptance criteria. Reviewer flags any miss as **Required**.

| Principle (`.github/copilot-instructions.md`) | Where it applies | Verifying deliverable |
|---|---|---|
| **Single Responsibility** | `src/probos/avatars/telemetry.py` is a **pure module**: no I/O, no LLM, no writes, no logging side effects beyond a single tier-2 warning per failure mode. The HTTP endpoint is a thin adapter. The `CognitiveAgent` method is one-line delegation. | Reviewer fails any diff that puts persistence, LLM calls, or trust-mutation logic into the telemetry module. |
| **Tier-2 log-and-degrade** | Missing `AppearanceProfile`, missing `VoiceProfile`, malformed persisted `dsl` dict (re-validation fails), `trust_network.history_for(...)` returning empty list, `notification_store` unavailable — **none of these raise.** Each path returns the snapshot with the affected field set to `None` and a structured `degraded_reasons: list[str]` field on `AvatarTelemetrySnapshot` populated. Single `logger.warning("AD-722 telemetry: %s for agent=%s; field=%s set to None", reason, agent_id, field)` per degraded field. | Tests must assert at least one degraded path returns a `200`-shape snapshot with `degraded_reasons` non-empty. |
| **DRY (HARD CONSTRAINT)** | Modulation rule table | Constants `RESPONDING_RATE_FACTOR`, `BLOCKED_RATE_FACTOR`, etc. live at module-top of `src/probos/avatars/telemetry.py`. The `apply_voice_modulation` function is the **only** place these constants are consumed in Python. **Reviewer fails** any duplicate constant definition elsewhere in `src/probos/`. The TS-side duplication in `voiceModulation.ts` is acknowledged as v1 cost — drafter adds a one-line comment in `voiceModulation.ts` pointing at `src/probos/avatars/telemetry.py` and AD-722-1 (the manifest-extraction marker). |
| **No private-attr access** | Backend wiring | Telemetry module consumes only public names from `runtime.profile_store`, `runtime.trust_network`, `runtime.registry`, `runtime.notification_store`, and `agent.id` / `agent.agent_type` / `agent.state`. **Zero `runtime._something` reach-through. Zero `agent._something`.** The new `_last_reply_emit_ts: float = 0.0` field on `CognitiveAgent` is set via a public `mark_reply_emitted()` method (called from the existing chat-reply emission path); the telemetry module reads it via `agent.last_reply_emitted_at` property, NOT via `agent._last_reply_emit_ts`. |
| **Type annotations** | All new public methods + properties on `CognitiveAgent`, `AvatarTelemetrySnapshot`, `build_telemetry_snapshot`, `apply_voice_modulation` are fully annotated. Frozen `@dataclass(frozen=True)` for `AvatarTelemetrySnapshot` (read-only telemetry is the contract). | Reviewer fails missing return types, missing parameter types, missing field types. |
| **Defense in depth** | HTTP endpoint | 404 when agent missing, 503 when `cfg.avatars.enabled is False`, 422 when persisted `dsl` dict fails `AvatarDSL.model_validate(...)` (telemetry returns the snapshot with `dsl_summary=None` + degraded reason; the endpoint still returns 200 — re-validation failure is a degraded field, not an endpoint failure). |
| **Async discipline** | `build_telemetry_snapshot` is `async def`. All `runtime.*_store` calls already async per HEAD pattern. **No fire-and-forget.** No `asyncio.ensure_future`. The `mark_reply_emitted()` setter is sync (one timestamp assignment). |
| **Cancellation handling** | `build_telemetry_snapshot` doesn't spawn long-running tasks; cancellation propagation is automatic via `await`. No long sleeps. |
| **Fail Fast** | `AvatarTelemetryConfig` validates `inject_into_agent_context: bool` and `mouth_active_window_seconds: float` (must be > 0) at parse time via Pydantic field validators. |
| **No emoji in HXI** | `<SelfImageTab>` UI | All icons are inline `<svg>` with `strokeWidth: 1.5`, `strokeLinecap: round`. Active = amber `#f0b060`, inactive = `#666680`. **Reviewer fails on any emoji literal.** |
| **No new top-level deps** | Whole wave | `pyproject.toml` and `ui/package.json` are **bit-for-bit unchanged**. Reviewer runs `git diff <pre>..<post> -- pyproject.toml ui/package.json` — any change is a hard fail. |
| **Test gates** | All deliverables | AD-722 Python: ≥ 12 tests across `tests/test_ad722_avatar_telemetry.py` (snapshot happy path with full DSL, snapshot when no DSL persisted → degraded + dsl_summary=None, snapshot when no AppearanceProfile → degraded across multiple fields, snapshot when trust history < 2 → trust_delta=0.0, snapshot when notification_store missing → tier3_alert=False + degraded reason, modulation rule byte-parity vs TS constants, mouth_active true within window, mouth_active false outside window, modulation rule composition (responding + tier3 multiplicative), HTTP endpoint 200 happy path, HTTP endpoint 404 unknown agent, HTTP endpoint 503 avatars disabled). UI Vitest: ≥ 6 tests in `ui/src/__tests__/SelfImageTab.test.tsx` (renders panel headers, polls every 2 seconds when active, stops polling when tab inactive, renders degraded-reasons strip when present, renders modulation factors as numbers (not formatted strings) for test stability, renders mouth_active indicator with amber-active / dim-inactive stroke states). |

---

## 4. AD-722 v1 scope (single commit)

**Issue:** [#545](https://github.com/seangalliher/ProbOS/issues/545).

### Backend deliverables

| ID | Deliverable | File(s) | Verification |
|---|---|---|---|
| **B1** | `AvatarTelemetryConfig` Pydantic model | `src/probos/config.py` | Sibling to `AvatarsConfig`. Fields: `enabled: bool = True`, `inject_into_agent_context: bool = False` (gates the optional prompt block), `mouth_active_window_seconds: float = 3.0` (with `field_validator` rejecting `<= 0`), `polling_interval_ms: int = 2000` (UI-side hint only — Pydantic-validated for symmetry; `field_validator` rejects `< 250` to prevent UI hammering). Mounted on `SystemConfig.avatars` is the wrong place (that's the existing 3D-avatar config); mount as a **new top-level field** `avatar_telemetry: AvatarTelemetryConfig` on `SystemConfig` adjacent to `avatars`. Drafter must verify the exact insertion site at HEAD before writing the SEARCH/REPLACE. |
| **B2** | `AvatarTelemetrySnapshot` dataclass | `src/probos/avatars/telemetry.py` (new) | `@dataclass(frozen=True)`. Fields: `agent_id: str`, `expression_resting: str | None` (from DSL), `current_signals: AgentSignalsSnapshot` (sub-dataclass with `trust_delta: float / load: float / working_state: str / tier3_alert: bool`), `mouth_active: bool`, `applied_modulation: ModulationSnapshot | None` (sub-dataclass with `pitch_factor: float / rate_factor: float / volume_factor: float / fired_rules: tuple[str, ...]`), `dsl_summary: DslSummarySnapshot | None` (sub-dataclass with `body_type: str / hair_style: str / primary_color: str / outfit_style: str / color_palette_hint: str`), `last_observed_at: float` (UNIX seconds), `degraded_reasons: tuple[str, ...]` (frozen — empty tuple when fully populated). Method `to_dict() -> dict[str, Any]` returns the JSON-serializable shape (HTTP endpoint + HXI consumer). |
| **B3** | `apply_voice_modulation` | `src/probos/avatars/telemetry.py` | Pure function. Signature `def apply_voice_modulation(profile: VoiceProfile, signals: AgentSignalsSnapshot) -> ModulationSnapshot`. Constants table at module top — verbatim port of TS constants from `ui/src/audio/voiceModulation.ts` (drafter pastes the source TS lines into the prompt's "Verified Against Codebase" footer for byte-parity). Returns `ModulationSnapshot` with the post-clamp factors AND the list of fired rule names (`"responding_rate"`, `"blocked_rate_pitch"`, `"high_trust_pitch"`, `"low_trust_pitch"`, `"tier3_rate_volume"`). Multiplicative composition matches TS. Clamping uses `PITCH_BOUNDS`/`RATE_BOUNDS`/`VOLUME_BOUNDS`. |
| **B4** | `build_telemetry_snapshot` | `src/probos/avatars/telemetry.py` | `async def build_telemetry_snapshot(agent_id: str, runtime: Any) -> AvatarTelemetrySnapshot`. Steps: (1) fetch agent via `runtime.registry.get(agent_id)` — if None, return a snapshot with all-degraded fields and `degraded_reasons=("agent_not_found",)`; (2) fetch crew profile via `runtime.profile_store.get(agent_id)` (verified pattern `routers/agents.py:124`); (3) re-validate `crew.appearance.dsl` via `AvatarDSL.model_validate(crew.appearance.dsl)` if present, catch `ValidationError` → `dsl_summary=None` + degraded reason `"dsl_invalid"`; (4) compute `trust_delta` from `runtime.trust_network.get_history(agent_id, limit=2)` — when `len(history) < 2` → `0.0` + degraded reason `"insufficient_trust_history"`, otherwise `history[-1] - history[-2]`; (5) compute `working_state` from agent state enum + `load` per the rule table in §2; (6) compute `tier3_alert` by scanning `runtime.bridge_alerts.get_recent_alerts(10)` for entries with `severity == AlertSeverity.ALERT` referencing this `agent_id` — when `runtime.bridge_alerts is None` → `False` + degraded reason `"bridge_alerts_unavailable"`; (7) compute `mouth_active`: `(time.time() - agent.last_reply_emitted_at) < cfg.avatar_telemetry.mouth_active_window_seconds`; (8) compute `applied_modulation` via `apply_voice_modulation(crew.voice_profile, signals)` — if `crew.voice_profile` missing → `None` + degraded reason `"voice_profile_missing"`; (9) build `dsl_summary` from validated DSL; (10) return `AvatarTelemetrySnapshot(...)`. |
| **B5** | `mark_reply_emitted` + `last_reply_emitted_at` | `src/probos/cognitive/cognitive_agent.py` | New private attr `self._last_reply_emit_ts: float = 0.0` initialised in `__init__` adjacent to `self._working_memory`. Public method `def mark_reply_emitted(self) -> None: self._last_reply_emit_ts = time.time()`. Public read-only property `@property def last_reply_emitted_at(self) -> float: return self._last_reply_emit_ts`. **Caller wiring**: drafter greps for the existing chat-reply emission path (start at `routers/agents.py:460-` `/{agent_id}/chat` handler — that's where the agent's reply text leaves the runtime) and inserts a single `agent.mark_reply_emitted()` call AFTER the reply payload is finalized but BEFORE the response is returned. Reviewer verifies the call site is the SINGLE source of truth — multiple call sites are a Demeter smell. |
| **B6** | `observe_self_avatar` method | `src/probos/cognitive/cognitive_agent.py` | `async def observe_self_avatar(self) -> AvatarTelemetrySnapshot:` — one-line delegation: `from probos.avatars.telemetry import build_telemetry_snapshot; return await build_telemetry_snapshot(self.id, self._runtime)`. Reviewer fails any expansion. |
| **B7** | Optional prompt-context injection (feature-gated) | `src/probos/cognitive/cognitive_agent.py` | When `self._runtime.config.avatar_telemetry.inject_into_agent_context is True`, the existing prompt-assembly path (`_build_cognitive_baseline` or whichever method most cleanly intercepts the system-prompt string — drafter MUST verify by reading `cognitive_agent.py` at the SENSORIUM_REGISTRY hooks at lines 137–153) appends a new sensorium block: layer `INTEROCEPTION`, name `_build_avatar_self_observation`, content = `f"Your current avatar state:\n  expression_resting: {snap.expression_resting}\n  working_state: {snap.current_signals.working_state}\n  applied_modulation: rate={snap.applied_modulation.rate_factor:.2f}, pitch={snap.applied_modulation.pitch_factor:.2f}\n  mouth_active: {snap.mouth_active}\n  dsl: {snap.dsl_summary.body_type} {snap.dsl_summary.hair_style} {snap.dsl_summary.outfit_style} (color {snap.dsl_summary.primary_color})\n"`. **Default OFF**. Drafter must register the new method in `SENSORIUM_REGISTRY` (HEAD line 137) so the existing introspection contract holds. |
| **B8** | HTTP endpoint | `src/probos/routers/agents.py` | New `@router.get("/{agent_id}/avatar-telemetry")`. Calls `_avatars_feature_check(runtime)` (raises 503 when disabled — same helper used by appearance routes), 404 when agent missing, otherwise `snap = await build_telemetry_snapshot(agent_id, runtime); return snap.to_dict()`. **The endpoint MUST also gate on `runtime.config.avatar_telemetry.enabled`** — when False, return 503 with reason `"avatar_telemetry_disabled"`. Drafter writes a small inline gate; do NOT extend `_avatars_feature_check` (single-responsibility — `_avatars_feature_check` is for the 3D-avatar surface). |
| **B9** | Tests (Python) | `tests/test_ad722_avatar_telemetry.py` (new) | ≥ 12 tests per the Engineering Principles checklist row above. Use the same `_FakeRuntime` / `_FakeProfileStore` / `_FakeTrustNetwork` stub-class pattern seen in existing AD-721 tests. Modulation byte-parity test reads the TS constants from `ui/src/audio/voiceModulation.ts` literally (file-read + regex extract — drafter writes the regex) and asserts equality with the Python constants. |

### UI deliverables

| ID | Deliverable | File(s) | Verification |
|---|---|---|---|
| **U1** | `<SelfImageTab>` component | `ui/src/components/profile/SelfImageTab.tsx` (new) | Props: `{ agentId: string; isActive: boolean }`. Polls `/api/agent/{agentId}/avatar-telemetry` every `polling_interval_ms` (read from a config endpoint or hard-coded to 2000ms — drafter chooses the simpler path; v1 hard-codes 2000). Polling effect: `useEffect` registers `setInterval`, cleans up on unmount or `isActive=false`. Renders **5 stacked panels**: (1) DSL summary (body type / hair style / outfit style / primary color swatch — small inline `<svg>` filled with the hex), (2) Current signals (working_state badge with amber-active / dim-inactive stroke; trust_delta as `+0.12` numeric; load 0..1 as a stroke-arc; tier3_alert as a stroke-svg alert glyph), (3) Voice modulation (pitch_factor / rate_factor / volume_factor as numbers; fired_rules as a stroke-svg labeled list), (4) `mouth_active` indicator (amber pulse when true, dim when false — CSS `@keyframes` for the pulse, no JS animation lib), (5) Degraded reasons strip (only renders when `degraded_reasons.length > 0` — amber outline with the reason strings). |
| **U2** | Tab registration in `AgentProfilePanel` | `ui/src/components/profile/AgentProfilePanel.tsx` | Extend `ProfileTab` union with `'self_image'`. Append `{key: 'self_image', label: 'Self-image'}` to `TAB_LABELS`. Render `<SelfImageTab agentId={agentId} isActive={effectiveTab === 'self_image'} />` in the existing tab-render switch. **Tab is only visible for crew agents** (same `isCrew` filter as `chat`/`memory`). |
| **U3** | Tests (Vitest) | `ui/src/__tests__/SelfImageTab.test.tsx` (new) | ≥ 6 tests per the Engineering Principles checklist row above. Mock `fetch` with `vi.fn()`. Use `vi.useFakeTimers()` to advance the polling clock. Render the component with `isActive=true`, assert `fetch` called once on mount + once after 2000ms tick. Toggle `isActive=false`, advance another 2000ms, assert no additional fetch. Render a mocked snapshot with `degraded_reasons=["dsl_invalid"]`, assert the strip renders. Render mocked snapshot with `mouth_active=true`, assert the amber-active stroke state. |

### Wiring

| What | Change |
|---|---|
| Endpoint registration | New `GET /api/agent/{agent_id}/avatar-telemetry` registered in the same `APIRouter` as the appearance endpoints. No new router file. |
| Module imports | `src/probos/cognitive/cognitive_agent.py` adds `from probos.avatars.telemetry import build_telemetry_snapshot, AvatarTelemetrySnapshot` at the bottom of the existing import block. `src/probos/routers/agents.py` adds the same imports at the top of the route function (lazy import) to avoid circulars. |
| Config wiring | `SystemConfig.avatar_telemetry: AvatarTelemetryConfig = Field(default_factory=AvatarTelemetryConfig)` adjacent to `SystemConfig.avatars`. Drafter must verify the exact insertion site by reading `config.py` at HEAD before writing the SEARCH/REPLACE. |
| Reply-emit hook | One call to `agent.mark_reply_emitted()` in the `/api/agent/{agent_id}/chat` handler (`routers/agents.py:460+`), placed AFTER the reply text is finalized but BEFORE the response returns. Drafter must read the handler at HEAD and confirm there's exactly ONE such site. If the handler has multiple emit branches, the call moves into a tiny private helper to preserve single-source-of-truth. |
| `SENSORIUM_REGISTRY` | One new entry: `"_build_avatar_self_observation": (SensoriumLayer.INTEROCEPTION, "AD-722: agent's own avatar state — gated by avatar_telemetry.inject_into_agent_context")`. |
| HXI store | **Zero changes.** `<SelfImageTab>` polls the HTTP endpoint directly; it does NOT route through Zustand. Drafter must explicitly state this in the prompt. |
| Backward compat | Existing `propose_appearance` / `set_appearance` endpoints + `AppearanceProfile.dsl` persistence + AD-721b lipsync + AD-718d voice modulation are bit-for-bit unchanged. |

---

## 5. What this wave does NOT change

| Out-of-scope | AD | Reason |
|---|---|---|
| Intent-vs-presentation divergence detector → trust/Hebbian update | **AD-722a** (forward marker) | The first consumer of the telemetry channel. Compares "what the agent intended to express" (LLM output's emotional valence) vs "what the avatar actually presented" (telemetry). Mismatch → trust delta. v1 ships the channel; AD-722a is the consumer. |
| Push channel via WebSocket (server → agent telemetry) | **AD-722b** (forward marker) | v1 = poll. Push deferred; UI polls every 2s, agent code calls `observe_self_avatar()` on demand. WebSocket is its own AD. |
| Avatar-telemetry history for analytics | **AD-722c** (forward marker) | v1 returns the current snapshot only. Time-series storage is a separate concern. |
| Auto-write telemetry to Ship's Records (`RecordsStore`) | **AD-722d** (forward marker) | v1 doesn't touch `RecordsStore`. Records integration is opt-in and forward-deferred. |
| Visual self-perception (agent renders own avatar to bytes and compares against intent) | **AD-722e** (forward marker) | v1 returns structured state only — no image bytes. Visual self-perception requires the Blender render path or a browser-side capture pipeline; out of scope. |
| Modulation rule table extracted to YAML/JSON manifest (single source of truth for TS + Python) | **AD-722-1** (forward marker, filed as part of this wave's tracking) | v1 ships the TS/Python duplication acknowledged. Manifest extraction is its own AD because both consumers are widely-imported and a well-tested manifest loader is a real artifact. |
| 3D / VRM / lipsync surface modifications | (out of scope; explicit non-goal) | Wave 140 touches zero VRM / lipsync code. All telemetry consumes existing AD-721 / AD-721b state read-only. |
| Audio attachments / TTS pipeline | (out of scope; explicit non-goal) | Wave 140 doesn't touch `voice.ts`, `speechAmplitude.ts`, `voiceModulation.ts` (TS-side stays put — only a 1-line comment pointer is added). |

Reviewer fails the prompt if it touches `voice.ts`, any VRM file, `CognitiveCanvas.tsx`, `pyproject.toml`, or `ui/package.json`.

---

## 6. Tracking

After Wave 140 ships:

1. **`PROGRESS.md`** — flip the AD-722 row to ✅ in the new Wave 140 section. One-line outcome ("Agent-observable avatar telemetry channel — read-side; CognitiveAgent.observe_self_avatar() + GET /api/agent/{id}/avatar-telemetry + <SelfImageTab>; feature-gated prompt injection default OFF").
2. **`docs/development/roadmap.md`** — close Wave 140 row. Add forward-marker rows: AD-722a (intent-vs-presentation divergence → trust update), AD-722b (push channel via WebSocket), AD-722c (telemetry history for analytics), AD-722d (auto-write to Ship's Records), AD-722e (visual self-perception via image rendering), AD-722-1 (modulation manifest extraction). File GH issues for all six (mirror Wave 138's pattern).
3. **`DECISIONS.md` + `decisions-era-5-unification.md`** — append AD-722 entry. Cite (a) the read-only-v1 contract, (b) the poll-not-push trade-off, (c) the duplicated modulation rule table as v1 cost + AD-722-1 path, (d) the `mouth_active` 3-second window approximation, (e) the feature-gated prompt injection default-OFF rationale.
4. **GH issues** — close [#545](https://github.com/seangalliher/ProbOS/issues/545) with summary comment. File AD-722a / AD-722b / AD-722c / AD-722d / AD-722e / AD-722-1 markers.
5. **`session/wave-queue-batch2.md`** — Builder appends `W140 #545 done (+12 pytest, +6 vitest)` to the Done section.

---

## 7. Acceptance criteria (wave-level)

The Builder must, by the end of the wave:

1. ✅ One commit. Reviewer fails any split into multiple commits — AD-722 v1 is a single atomic feature.
2. ✅ `pytest tests/ -q -n 4 --dist=loadfile` green at the commit. Test count delta: ≥ +12.
3. ✅ `cd ui && npx vitest run` green at the commit. Test count delta: ≥ +6.
4. ✅ Existing AD-721 / AD-721b / AD-721d / AD-718d tests stay green at the commit. Reviewer fails any modification of those test bodies.
5. ✅ `pwsh scripts/phantom-api-precheck.ps1 prompts/ad-722-avatar-telemetry-v1.md` clean.
6. ✅ `pyproject.toml [project.dependencies]` AND `ui/package.json` `"dependencies"` + `"devDependencies"` are **bit-for-bit identical** between pre-Wave-140 and post-Wave-140 commits. Reviewer runs `git diff <pre>..<post> -- pyproject.toml ui/package.json` — any change is a hard fail.
7. ✅ Manual smoke (Captain runs after merge): open the HXI, click any crew agent's profile, switch to the new "Self-image" tab. Within 2 seconds the panel populates with the agent's expression_resting, current signals, voice modulation factors, mouth_active indicator, and DSL summary. Send the agent a message — within 3 seconds the `mouth_active` indicator pulses amber, then settles to dim.
8. ✅ Manual smoke (Captain): with `inject_into_agent_context=False` (default), the agent's chat replies are byte-for-byte unchanged from baseline (re-run any AD-721d appearance-proposal test as the regression guard). With `inject_into_agent_context=True` (operator flips), the agent's reply may reference its own appearance ("My uniform is teal today") — Captain logs the qualitative behaviour shift in the Wave 140 retrospective.
9. ✅ Verify all changes comply with the Engineering Principles in `.github/copilot-instructions.md`.

---

## 8. Risk classification

| AD | Risk | Subcategory drivers |
|---|---|---|
| **AD-722** | **MEDIUM** | New module + new endpoint + new tab + new method on `CognitiveAgent`. Read-only contract is the safety belt — zero state mutations, zero LLM calls, zero writes. The single state-mutation introduced (`mark_reply_emitted` setter on `CognitiveAgent`) is a one-line timestamp assignment with one call site. Highest single-source-of-regression: the prompt-context injection (B7) is feature-gated default-OFF — operator must opt in to see any behaviour shift. The duplicated modulation rule table is the main DRY debt; mitigated by a byte-parity test that fails the build if the TS and Python constants drift. |
| **Wave 140 cumulative** | **MEDIUM** | One AD, one commit. New surface area is well-bounded. Build order is trivial (single AD). |

**Subcategories:**
- **Bundle size:** zero new MB (no new JS deps, no new model weights, no new font files). One new TSX component is well under 300 LOC.
- **Browser compatibility:** `setInterval` polling + standard fetch — universal.
- **Backend impact:** one new module (`avatars/telemetry.py`), one new endpoint, one new config model, one new method + property + setter on `CognitiveAgent`, one one-line caller change in the chat handler.
- **Regression surface:**
  - `CognitiveAgent.__init__` — new private attr added (zero blast radius — no existing code reads it).
  - Chat handler — new one-line `agent.mark_reply_emitted()` call. Mitigated by re-running existing `agents.py` chat tests.
  - Prompt-context injection — default OFF. Mitigated by the AD-721d appearance test staying green at default config.
  - `AgentProfilePanel` — new tab in the union. Mitigated by existing `AgentProfilePanel` Vitest tests staying green (drafter verifies the existing test suite covers the tab-render switch).

---

## 9. Wave-specific reminders for the prompt drafter

1. **`mouth_active` IS A KNOWN APPROXIMATION.** Backend has no authoritative speaking signal — TTS happens browser-side. v1 derives it from `(now - agent.last_reply_emitted_at) < cfg.avatar_telemetry.mouth_active_window_seconds` (default 3.0s). Drafter MUST state this explicitly in the prompt's "Constraints" section AND in the `AvatarTelemetrySnapshot` docstring AND in a one-line comment at the top of `<SelfImageTab>`. AD-722b's WebSocket channel is what makes it authoritative.
2. **THE MODULATION RULE TABLE IS DUPLICATED ON PURPOSE in v1.** TS source (`ui/src/audio/voiceModulation.ts`) is widely imported; a manifest extraction is a real AD on its own (AD-722-1). Drafter MUST: (a) paste the TS constants verbatim into the prompt's `## Verified Against Codebase` footer, (b) ship a Python byte-parity test that file-reads the TS source and asserts equality, (c) add a one-line comment in `voiceModulation.ts` pointing at `src/probos/avatars/telemetry.py` and AD-722-1.
3. **PROMPT-CONTEXT INJECTION IS DEFAULT OFF.** v1 does NOT change agent behaviour out of the box. Operator flips `inject_into_agent_context: True` to test the loop. The default-False matters because the existing `_build_cognitive_baseline` prompt budget is sensitive — adding ~150 tokens to every reasoning cycle without operator opt-in is a behavioural regression.
4. **`mark_reply_emitted` HAS EXACTLY ONE CALL SITE.** Drafter greps `routers/agents.py` for the `/{agent_id}/chat` handler (line ~460) and identifies the single point where the reply text leaves the runtime. If multiple emit branches exist, the call MUST move into a private helper so the call site stays singular — Demeter / single-source-of-truth.
5. **NO PRIVATE-ATTR ACCESS ANYWHERE.** Telemetry module reads `agent.last_reply_emitted_at` (the public property), not `agent._last_reply_emit_ts`. `runtime.profile_store`, `runtime.trust_network`, `runtime.notification_store` — all public. Reviewer fails on any reach-through.
6. **`<SelfImageTab>` DOES NOT GO THROUGH THE ZUSTAND STORE.** It polls the HTTP endpoint directly. v1 keeps the store unchanged. AD-722b's WebSocket-driven push could push into the store; that's the future call.
7. **`degraded_reasons` IS THE TIER-2 OBSERVABILITY HOOK.** Every degraded path adds a structured reason string and the snapshot still returns 200. Reviewer fails any path that raises instead of degrading. The HTTP endpoint NEVER returns 422 for malformed persisted DSL — that's a degraded field, not an endpoint failure.
8. **NO EMOJI.** All `<SelfImageTab>` icons are inline `<svg>` with `strokeWidth: 1.5` and `strokeLinecap: round`. Active = amber `#f0b060`, inactive = `#666680`. Reviewer fails on any emoji literal in the diff.
9. **VERIFY-FIRST.** Before any concrete file/line/method citation in the prompt body, drafter greps HEAD and pastes the result in the prompt's `## Verified Against Codebase (YYYY-MM-DD)` footer. Especially: every line number cited from `src/probos/cognitive/cognitive_agent.py`, `src/probos/routers/agents.py`, `src/probos/config.py`, `src/probos/avatars/dsl.py`, `src/probos/crew_profile.py`, `ui/src/components/profile/AgentProfilePanel.tsx`, `ui/src/components/profile/avatarSignals.ts`, `ui/src/audio/voiceModulation.ts` MUST have a grep hit shown.
10. **HXI FRAGILITY RULE.** `CognitiveCanvas.tsx`, `agents.tsx`, `animations.tsx`, `CrewVRM.tsx`, `ParametricAvatar.tsx` — all UNTOUCHED. Reviewer fails any diff that touches them. The new tab is additive on `AgentProfilePanel.tsx` only.
11. **TRUST HISTORY API NAME IS `get_history(agent_id, limit=N)`, NOT `history_for(...)`.** Verified at HEAD `routers/agents.py:92`. `history_for` is a different method on `security/autonomy_boundaries.py` for source-tracking — do not confuse. The implementation prompt's verified footer MUST paste this grep hit.
12. **TIER-3 ALERT SOURCE IS `runtime.bridge_alerts.get_recent_alerts(10)`.** Verified at HEAD `runtime.py:3229`, severity enum at `bridge_alerts.py:28` (`AlertSeverity.ALERT == "alert"`). There is no `runtime.notification_store`. Drafter pastes the grep hits in the impl prompt's verified footer.
13. **`load` SIGNAL HAS NO CANONICAL PER-AGENT BACKEND SOURCE AT HEAD.** UI binds it to `store.processing` (a global flag) — that doesn't translate per-agent on the backend. Implementation prompt drafter MUST inspect `cognitive_agent.py` + runtime at HEAD for an in-flight signal (candidates: a per-agent processing flag, `runtime.concurrency_manager` counters, or — fallback — bind `load = 1.0 if mouth_active else 0.0` as the v1 approximation). The chosen source is documented in the `AvatarTelemetrySnapshot` docstring as the v1 approximation; AD-722b's WebSocket channel is what makes load authoritative. **Reviewer fails any impl prompt that asserts `runtime.processing_state` or any other unverified runtime attribute without a paired grep hit — those names do not exist at HEAD.**

---

## 10. Forward markers to file (Builder mints these as GH issues during retrospective)

| Marker | One-line description | Forcing function |
|---|---|---|
| **AD-722a** | First consumer: intent-vs-presentation divergence detector → trust/Hebbian update | Operator flips `inject_into_agent_context=True` for ≥ 1 week, observes ≥ 5 qualitative cases of "agent referenced their appearance correctly / incorrectly" — log them as input data. |
| **AD-722b** | Push channel via WebSocket (server-sent telemetry) | UI polling at 2s creates ≥ 100 `/avatar-telemetry` requests per agent per hour at scale. When the agent count × polling rate produces measurable load on the chat handler, AD-722b ships. |
| **AD-722c** | Avatar-telemetry history for analytics | AD-722a needs ≥ 1 week of historical snapshots to compute divergence rates. AD-722c is the storage layer. |
| **AD-722d** | Auto-write telemetry to `RecordsStore` | When AD-722c is live, persisting summaries to Ship's Records is one ledger entry per N snapshots. |
| **AD-722e** | Visual self-perception via image rendering | Captain explicitly requests "show me what the agent thinks they look like" comparison view; needs Blender render path or browser-side capture. Earliest non-orthogonal sibling is AD-721's parametric fallback rendering. |
| **AD-722-1** | Modulation rule table extracted to YAML/JSON manifest (single source of truth for TS + Python) | Byte-parity test in v1 starts failing OR a third consumer of the rule table appears (e.g., a server-side voice gateway). |

---
